Skip to content

Commit 4339591

Browse files
committed
add --no-fail flag to skip non-zero exit on findings
Useful in CI pipelines where findings should be reported but not block the build. Also wired into .ai-sec-scan.yaml config via no_fail key.
1 parent c050805 commit 4339591

File tree

2 files changed

+116
-2
lines changed

2 files changed

+116
-2
lines changed

src/ai_sec_scan/cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"no_cache": "no_cache",
5151
"parallel": "parallel",
5252
"baseline": "baseline",
53+
"no_fail": "no_fail",
5354
}
5455

5556

@@ -257,6 +258,12 @@ def version() -> None:
257258
type=click.Path(exists=True),
258259
help="Baseline file to suppress known findings.",
259260
)
261+
@click.option(
262+
"--no-fail",
263+
is_flag=True,
264+
default=False,
265+
help="Always exit 0 even when findings are present.",
266+
)
260267
def scan(
261268
path: str,
262269
provider: str,
@@ -274,6 +281,7 @@ def scan(
274281
no_cache: bool,
275282
parallel: int,
276283
baseline: str | None,
284+
no_fail: bool,
277285
) -> None:
278286
"""Scan a file or directory for security vulnerabilities."""
279287
from ai_sec_scan.scanner import collect_files, run_scan_sync
@@ -361,8 +369,8 @@ def scan(
361369
if annotations_output:
362370
click.echo(annotations_output)
363371

364-
# Exit with non-zero if findings exist
365-
if result.findings:
372+
# Exit with non-zero if findings exist (unless --no-fail)
373+
if result.findings and not no_fail:
366374
sys.exit(1)
367375

368376

tests/test_cli.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,112 @@ def fake_run_scan_sync(
332332
assert output["findings"][0]["title"] == "XSS"
333333

334334

335+
class TestNoFailFlag:
336+
def test_no_fail_exits_zero_with_findings(self, monkeypatch, tmp_path: Path) -> None:
337+
"""--no-fail should exit 0 even when findings are present."""
338+
from ai_sec_scan.models import Finding, Severity
339+
340+
finding = Finding(
341+
file_path="app.py",
342+
line_start=10,
343+
severity=Severity.HIGH,
344+
title="Hardcoded Secret",
345+
description="API key in source",
346+
recommendation="Use environment variables",
347+
)
348+
349+
def fake_get_provider(provider_name: str, model: str | None) -> BaseProvider:
350+
return _DummyProvider()
351+
352+
def fake_run_scan_sync(
353+
path,
354+
provider,
355+
include=None,
356+
exclude=None,
357+
max_file_size_kb=100,
358+
min_severity=None,
359+
quiet=False,
360+
cache_dir=None,
361+
no_cache=False,
362+
parallel=1,
363+
):
364+
return ScanResult(
365+
findings=[finding],
366+
files_scanned=1,
367+
scan_duration=0.01,
368+
provider=provider.name,
369+
model=provider.model,
370+
)
371+
372+
monkeypatch.setattr("ai_sec_scan.cli._get_provider", fake_get_provider)
373+
monkeypatch.setattr("ai_sec_scan.scanner.run_scan_sync", fake_run_scan_sync)
374+
375+
runner = CliRunner()
376+
with runner.isolated_filesystem():
377+
project_dir = Path("project")
378+
project_dir.mkdir()
379+
(project_dir / "app.py").write_text("print('ok')", encoding="utf-8")
380+
381+
result = runner.invoke(
382+
main,
383+
["scan", "project", "-o", "json", "-q", "--no-fail"],
384+
)
385+
386+
assert result.exit_code == 0
387+
388+
def test_without_no_fail_exits_nonzero(self, monkeypatch, tmp_path: Path) -> None:
389+
"""Without --no-fail, findings should produce exit code 1."""
390+
from ai_sec_scan.models import Finding, Severity
391+
392+
finding = Finding(
393+
file_path="app.py",
394+
line_start=10,
395+
severity=Severity.LOW,
396+
title="Debug Mode Enabled",
397+
description="debug=True in production",
398+
recommendation="Disable debug mode",
399+
)
400+
401+
def fake_get_provider(provider_name: str, model: str | None) -> BaseProvider:
402+
return _DummyProvider()
403+
404+
def fake_run_scan_sync(
405+
path,
406+
provider,
407+
include=None,
408+
exclude=None,
409+
max_file_size_kb=100,
410+
min_severity=None,
411+
quiet=False,
412+
cache_dir=None,
413+
no_cache=False,
414+
parallel=1,
415+
):
416+
return ScanResult(
417+
findings=[finding],
418+
files_scanned=1,
419+
scan_duration=0.01,
420+
provider=provider.name,
421+
model=provider.model,
422+
)
423+
424+
monkeypatch.setattr("ai_sec_scan.cli._get_provider", fake_get_provider)
425+
monkeypatch.setattr("ai_sec_scan.scanner.run_scan_sync", fake_run_scan_sync)
426+
427+
runner = CliRunner()
428+
with runner.isolated_filesystem():
429+
project_dir = Path("project")
430+
project_dir.mkdir()
431+
(project_dir / "app.py").write_text("print('ok')", encoding="utf-8")
432+
433+
result = runner.invoke(
434+
main,
435+
["scan", "project", "-o", "json", "-q"],
436+
)
437+
438+
assert result.exit_code == 1
439+
440+
335441
class TestCacheStatsCommand:
336442
def test_stats_empty_cache(self, tmp_path: Path) -> None:
337443
cache_dir = tmp_path / "cache"

0 commit comments

Comments
 (0)