Skip to content

Commit 79def1a

Browse files
authored
fix(engine): make container /output dir writable for non-root scanner users (#110)
Container scanners running as a non-root USER (e.g. our custom images all use ``USER argus`` uid 1000) couldn't write ``/output/results.json`` on hosts where the invoking user has a different uid (uid 501 on macOS being the canonical case). Python's ``tempfile.TemporaryDirectory`` creates dirs with mode 0o700, so the cross-uid write fails with EACCES and the scanner exits without writing anything. Argus' "produced no output files" warning was the only signal, and the scan still rolled up to ``Status: PASS``. Symptom matrix observed by user on argus 0.7.2.dev75: - bandit, opengrep, lint-dockerfile (custom images, uid 1000): "permission denied" on /output/results.json - gitleaks (official image, runs as root): worked fine - supply-chain (custom image, uid 1000): silent no-op Fix - ``ArgusEngine._run_in_container``: ``os.chmod(output_dir, 0o777)`` immediately after ``TemporaryDirectory.__enter__``. Mode 0o777 is safe because the dir lives under ``tempfile.gettempdir()``, has a random name, holds only one scan's transient output (no secrets), and is removed at the end of the with-block. Visibility for partial-execution failures - When a container produces no output files and no stdout, mark the ScanResult with ``metadata["execution_failed"] = True`` plus an ``execution_failure_reason`` string carrying the exit code and a clipped stderr. This gives reporters and CI gates something structured to act on instead of grepping log lines. - ``TerminalReporter._print_status`` surfaces a clear warning row above the PASS/FAIL line listing the failed scanners and pointing at ``--fail-on-scanner-error`` for hard CI gating. Threshold- driven PASS/FAIL is unchanged — the warning is a *separate* signal. - New ``--fail-on-scanner-error`` flag on ``argus scan`` (default False, so existing default behavior stays): when set and any scanner had ``execution_failed=True``, the run exits ``EXIT_ERROR`` even if the threshold check passes. CI users who require every configured scanner to actually run can opt in. Tests (+7) - ``test_engine.TestRunInContainer.test_container_output_dir_is_world_writable`` — regression: tempdir mode is 0o777 by the time docker run starts. - ``test_container_no_output_marks_execution_failed`` — exit-13 + empty /output produces a ScanResult with execution_failed metadata and a stderr-bearing reason. - ``test_container_with_output_does_not_mark_execution_failed`` — successful runs stay clean. - ``test_terminal.test_report_warns_on_execution_failure_above_pass_status`` — terminal reporter prints the warning row above PASS, names the failed scanners, mentions the --fail-on-scanner-error flag. - ``test_report_no_warning_when_all_scanners_produced_output`` — successful runs don't get the warning row. - ``test_cli.test_fail_on_scanner_error_flag`` — flag parses as False by default and True when supplied. Validation - Real-world smoke test: ``argus scan bandit`` against the argus repo on a host with uid 501. Before: 0 findings, "produced no output files" warning. After: 153 findings, ``Output files: ['results.json']``, no warnings. - Full SDK suite: 1400 passed. Co-authored-by: eFAILution <eFAILution@users.noreply.github.com>
1 parent b6a7861 commit 79def1a

7 files changed

Lines changed: 239 additions & 7 deletions

File tree

argus/cli.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,15 @@ def _build_scan_parser(subparsers: argparse._SubParsersAction) -> None:
522522
action="store_true",
523523
help="Abort immediately if any scanner fails instead of continuing.",
524524
)
525+
scan_parser.add_argument(
526+
"--fail-on-scanner-error",
527+
action="store_true",
528+
help="Exit non-zero when any scanner produced no output (typically "
529+
"a uid-mismatch on /output, container crash, or wrong "
530+
"entrypoint). Default behavior treats these as warnings so "
531+
"partial scans still surface findings; opt in for hard CI "
532+
"gates that require every configured scanner to actually run.",
533+
)
525534
scan_parser.add_argument(
526535
"--timeout",
527536
type=int,
@@ -1301,11 +1310,29 @@ def _cmd_source_scan(args: argparse.Namespace) -> int:
13011310
# 2. Otherwise, if any SBOM failed hard during a batch → EXIT_ERROR.
13021311
# This always fires AFTER every SBOM in the batch was attempted;
13031312
# we never abort the loop on the first failure.
1304-
# 3. Otherwise → EXIT_SUCCESS.
1313+
# 3. Otherwise, if --fail-on-scanner-error is set AND any scanner
1314+
# produced no output → EXIT_ERROR. Opt-in so existing default
1315+
# "warn but pass" behavior stays unchanged.
1316+
# 4. Otherwise → EXIT_SUCCESS.
1317+
scanner_execution_failures = [
1318+
r.scanner for r in summary.results
1319+
if r.metadata.get("execution_failed")
1320+
]
13051321
if not summary.passed:
13061322
exit_code = EXIT_FINDINGS
13071323
elif sbom_batch_failures:
13081324
exit_code = EXIT_ERROR
1325+
elif (
1326+
getattr(args, "fail_on_scanner_error", False)
1327+
and scanner_execution_failures
1328+
):
1329+
log.error(
1330+
"Exiting non-zero: %d scanner(s) produced no output (%s) and "
1331+
"--fail-on-scanner-error is set.",
1332+
len(scanner_execution_failures),
1333+
", ".join(scanner_execution_failures),
1334+
)
1335+
exit_code = EXIT_ERROR
13091336
else:
13101337
exit_code = EXIT_SUCCESS
13111338
finalize_manifest(manifest, summary=summary, exit_code=exit_code, output_dir=output_dir)
@@ -2026,6 +2053,7 @@ def _generate_zsh_completion(scanners: str) -> str:
20262053
'--no-spinner[Disable spinner]'
20272054
'--no-timestamp[Flat output directory]'
20282055
'--fail-fast[Abort on first failure]'
2056+
'--fail-on-scanner-error[Exit non-zero if any scanner produced no output]'
20292057
'--timeout[Per-scanner timeout]:seconds:'
20302058
'--no-parallel[Run scanners sequentially]'
20312059
'--allow-local-versions[Skip version enforcement]'
@@ -2136,7 +2164,7 @@ def _generate_bash_completion(scanners: str) -> str:
21362164
--scan-type) COMPREPLY=($(compgen -W "baseline full" -- "$cur")); return ;;
21372165
--path|-p|--output-dir|-o|--config|-c|--output-vars) COMPREPLY=($(compgen -d -- "$cur")); return ;;
21382166
esac
2139-
COMPREPLY=($(compgen -W "--path --config --output-dir --severity-threshold --format --interface --output-vars --list --verbose --no-spinner --no-timestamp --fail-fast --timeout --no-cache --no-parallel --allow-local-versions" -- "$cur"))
2167+
COMPREPLY=($(compgen -W "--path --config --output-dir --severity-threshold --format --interface --output-vars --list --verbose --no-spinner --no-timestamp --fail-fast --fail-on-scanner-error --timeout --no-cache --no-parallel --allow-local-versions" -- "$cur"))
21402168
;;
21412169
report)
21422170
if [ "$COMP_CWORD" -eq 2 ]; then

argus/core/engine.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,26 @@ def _run_in_container(
609609
abs_path = str(Path(path).resolve())
610610

611611
with tempfile.TemporaryDirectory() as output_dir:
612+
# Make the host-side temp dir world-writable BEFORE the
613+
# container starts. Python's TemporaryDirectory creates
614+
# dirs with mode 0o700 (owner-only). When a scanner image
615+
# runs as a non-root user (e.g., bandit / opengrep / our
616+
# custom images all use ``USER argus`` uid 1000) and the
617+
# invoking host user has a different uid (commonly 501 on
618+
# macOS), the container's process can't write
619+
# ``/output/results.json`` and we get the silent "produced
620+
# no output files" failure mode.
621+
#
622+
# Mode 0o777 is safe here:
623+
# - dir lives under ``tempfile.gettempdir()`` (host-only,
624+
# not network-shared)
625+
# - random name from ``mkdtemp`` (collision-resistant)
626+
# - removed at the end of this with-block
627+
# - holds only one scan's transient output (no secrets;
628+
# findings travel through ``parse_results`` and end up
629+
# in the user-specified output_dir, never here).
630+
os.chmod(output_dir, 0o777)
631+
612632
docker_cmd = [
613633
self._runtime, "run", "--rm",
614634
"-v", f"{abs_path}:/workspace:ro",
@@ -727,6 +747,26 @@ def _run_in_container(
727747

728748
findings = []
729749
metadata_extra = {}
750+
# Track scanner execution failures distinctly from "ran and
751+
# found nothing". A scanner that produced no output files and
752+
# no stdout most likely failed to run — could not write to
753+
# /output (uid mismatch), crashed without flushing, or had
754+
# the wrong entrypoint chain. We mark these on the ScanResult
755+
# so the CLI / reporters can surface them, and so consumers
756+
# who want hard CI gates can opt into ``--fail-on-scanner-error``
757+
# without having to grep our log lines.
758+
if not result_files:
759+
metadata_extra["execution_failed"] = True
760+
stderr_clipped = proc.stderr.strip()[:400]
761+
if stderr_clipped:
762+
metadata_extra["execution_failure_reason"] = (
763+
f"no output files (exit={proc.returncode}). "
764+
f"stderr: {stderr_clipped}"
765+
)
766+
else:
767+
metadata_extra["execution_failure_reason"] = (
768+
f"no output files and no stdout (exit={proc.returncode})"
769+
)
730770
if result_files and hasattr(scanner, "parse_results"):
731771
parsed = scanner.parse_results(result_files[0])
732772
# parse_results may return either a list of Findings,

argus/reporters/terminal.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ def _print_scanner_results(self, summary: ScanSummary) -> None:
100100
print()
101101

102102
def _print_status(self, summary: ScanSummary) -> None:
103+
# Scanner-execution failures (no output produced) are flagged
104+
# separately so a single bad scanner image doesn't quietly slip
105+
# past a "Status: PASS" line. The PASS/FAIL line still reflects
106+
# *threshold* outcome; this row reflects *execution* outcome.
107+
# CI-callers who want hard-fail behavior on missing output use
108+
# ``--fail-on-scanner-error``.
109+
failed = [
110+
r.scanner for r in summary.results
111+
if r.metadata.get("execution_failed")
112+
]
113+
if failed:
114+
names = ", ".join(failed)
115+
print(f"Warning: {len(failed)} scanner(s) produced no output: {names}")
116+
print(" These scanners likely failed to execute (uid mismatch on")
117+
print(" /output mount, crashed, or wrong entrypoint). Re-run with")
118+
print(" --verbose for stderr; pass --fail-on-scanner-error to fail")
119+
print(" the scan when this happens.")
120+
103121
if summary.passed:
104122
print("Status: PASS")
105123
else:

argus/tests/reporters/test_terminal.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,53 @@ def test_report_fail_status_with_threshold(self, capsys):
7777
output = capsys.readouterr().out
7878
assert "FAIL" in output
7979

80+
def test_report_warns_on_execution_failure_above_pass_status(self, capsys):
81+
"""Scanners that produced no output (execution_failed=True in
82+
metadata) get a clear warning in the terminal output so a single
83+
bad scanner image doesn't quietly slip past the PASS line."""
84+
reporter = TerminalReporter()
85+
summary = ScanSummary(
86+
results=[
87+
ScanResult(scanner="gitleaks", findings=[]), # ran fine
88+
ScanResult(
89+
scanner="bandit", findings=[],
90+
metadata={
91+
"execution_failed": True,
92+
"execution_failure_reason": (
93+
"no output files (exit=13). stderr: "
94+
"cannot open /output/results.json: permission denied"
95+
),
96+
},
97+
),
98+
ScanResult(
99+
scanner="opengrep", findings=[],
100+
metadata={"execution_failed": True},
101+
),
102+
],
103+
severity_threshold=None,
104+
)
105+
reporter.report(summary)
106+
output = capsys.readouterr().out
107+
108+
# Failed scanners are named, count is correct, and the hint
109+
# points at --fail-on-scanner-error for hard CI gating.
110+
assert "2 scanner(s) produced no output" in output
111+
assert "bandit" in output
112+
assert "opengrep" in output
113+
assert "--fail-on-scanner-error" in output
114+
# PASS status still renders below — execution failure is a
115+
# separate signal from threshold compliance.
116+
assert "PASS" in output
117+
118+
def test_report_no_warning_when_all_scanners_produced_output(self, capsys):
119+
"""Successful runs must not get the warning row."""
120+
reporter = TerminalReporter()
121+
summary = _make_summary()
122+
reporter.report(summary)
123+
124+
output = capsys.readouterr().out
125+
assert "produced no output" not in output
126+
80127
def test_report_empty_results(self, capsys):
81128
reporter = TerminalReporter()
82129
summary = ScanSummary()

argus/tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,17 @@ def test_sbom_flag_default_none(self):
812812
args = parser.parse_args(["scan"])
813813
assert args.sbom is None
814814

815+
def test_fail_on_scanner_error_flag(self):
816+
"""--fail-on-scanner-error is opt-in (default False) so existing
817+
``argus scan`` users keep getting partial-scan PASS behavior;
818+
CI callers who want hard fails set the flag."""
819+
parser = build_parser()
820+
args = parser.parse_args(["scan"])
821+
assert args.fail_on_scanner_error is False
822+
823+
args = parser.parse_args(["scan", "--fail-on-scanner-error"])
824+
assert args.fail_on_scanner_error is True
825+
815826
def test_interface_flag_terminal(self):
816827
parser = build_parser()
817828
args = parser.parse_args(["scan", "--interface", "terminal"])

argus/tests/test_engine.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for argus.core.engine — ArgusEngine."""
22

3+
import os
34
import subprocess
45
from pathlib import Path
56

@@ -719,6 +720,91 @@ def mock_run(cmd, **kwargs):
719720
assert result.findings[0].title == "from stdout"
720721
assert captured_file["path"].name == "stdout.txt"
721722

723+
def test_container_output_dir_is_world_writable(self, monkeypatch):
724+
"""Regression: scanners running as USER non-root (uid 1000)
725+
couldn't write /output/results.json on hosts with uid != 1000
726+
because Python's TemporaryDirectory creates dirs mode 0o700.
727+
Engine now chmods the dir 0o777 right after creation."""
728+
engine = self._make_engine()
729+
scanner = self._make_scanner()
730+
731+
monkeypatch.setattr(engine, "_pull_image", lambda img: True)
732+
monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc")
733+
734+
captured_mode = {}
735+
736+
def mock_run(cmd, **_kwargs):
737+
# The chmod happens before docker run, so by the time we're
738+
# invoked the host-side temp dir already has the new mode.
739+
for i, arg in enumerate(cmd):
740+
if ":/output" in str(arg):
741+
host_dir = arg.split(":")[0]
742+
captured_mode["mode"] = os.stat(host_dir).st_mode & 0o777
743+
Path(host_dir).joinpath("results.json").write_text("{}")
744+
break
745+
return subprocess.CompletedProcess(
746+
args=cmd, returncode=0, stdout="", stderr="",
747+
)
748+
749+
monkeypatch.setattr(subprocess, "run", mock_run)
750+
751+
engine._run_in_container(scanner, "/src", {})
752+
# 0o777 means rwx for owner, group, other — every container
753+
# uid can write to /output regardless of its image's USER.
754+
assert captured_mode["mode"] == 0o777
755+
756+
def test_container_no_output_marks_execution_failed(self, monkeypatch):
757+
"""Empty result_files + no stdout means the container ran but
758+
produced nothing. Mark the ScanResult so reporters and the
759+
--fail-on-scanner-error gate can surface it instead of silently
760+
rolling it up as an empty PASS."""
761+
engine = self._make_engine()
762+
scanner = self._make_scanner()
763+
764+
monkeypatch.setattr(engine, "_pull_image", lambda img: True)
765+
monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc")
766+
767+
def mock_run(cmd, **_kwargs):
768+
# Container exits 13 (permission denied) without writing
769+
# anything to /output — the exact bug in the user report.
770+
return subprocess.CompletedProcess(
771+
args=cmd, returncode=13, stdout="",
772+
stderr="cannot open /output/results.json: permission denied",
773+
)
774+
775+
monkeypatch.setattr(subprocess, "run", mock_run)
776+
777+
result = engine._run_in_container(scanner, "/src", {})
778+
assert result.findings == []
779+
assert result.metadata.get("execution_failed") is True
780+
# Reason carries the stderr for the user — they shouldn't have
781+
# to bump the log level to find out why.
782+
reason = result.metadata.get("execution_failure_reason", "")
783+
assert "permission denied" in reason
784+
assert "exit=13" in reason
785+
786+
def test_container_with_output_does_not_mark_execution_failed(self, monkeypatch):
787+
"""Successful container runs must not get the failure marker."""
788+
engine = self._make_engine()
789+
scanner = self._make_scanner(parse_results=lambda f: [])
790+
791+
monkeypatch.setattr(engine, "_pull_image", lambda img: True)
792+
monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc")
793+
794+
def mock_run(cmd, **_kwargs):
795+
for i, arg in enumerate(cmd):
796+
if ":/output" in str(arg):
797+
Path(arg.split(":")[0]).joinpath("results.json").write_text("[]")
798+
break
799+
return subprocess.CompletedProcess(
800+
args=cmd, returncode=0, stdout="", stderr="",
801+
)
802+
803+
monkeypatch.setattr(subprocess, "run", mock_run)
804+
805+
result = engine._run_in_container(scanner, "/src", {})
806+
assert "execution_failed" not in result.metadata
807+
722808
def test_container_custom_entrypoint(self, monkeypatch):
723809
engine = self._make_engine()
724810
scanner = self._make_scanner(container_entrypoint="/bin/custom")

docs/cli-reference.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Argus CLI Reference (v0.7.2)
22

3-
> Auto-generated from argparse definitions on 2026-05-02.
3+
> Auto-generated from argparse definitions on 2026-05-05.
44
> Do not edit manually — run `python -m scripts.ci.gen_cli_docs` to regenerate.
55
66
Argus Security Scanner — comprehensive security scanning for your codebase
@@ -62,10 +62,11 @@ argus scan [-h] [--path PATH] [--config CONFIG]
6262
[--output-vars FILE] [--exclude PATTERNS]
6363
[--no-default-excludes] [--dry-run] [--sbom PATH]
6464
[--interface {terminal,browser}] [--fail-fast]
65-
[--timeout SECONDS] [--no-parallel] [--allow-local-versions]
66-
[--no-cache] [--discover [PATH]] [--image REF]
67-
[--scanners SCANNERS] [--target URL] [--port PORT]
68-
[--env KEY=VALUE] [--scan-type {baseline,full}]
65+
[--fail-on-scanner-error] [--timeout SECONDS]
66+
[--no-parallel] [--allow-local-versions] [--no-cache]
67+
[--discover [PATH]] [--image REF] [--scanners SCANNERS]
68+
[--target URL] [--port PORT] [--env KEY=VALUE]
69+
[--scan-type {baseline,full}]
6970
[--startup-timeout STARTUP_TIMEOUT]
7071
[scanner]
7172
```
@@ -94,6 +95,7 @@ argus scan [-h] [--path PATH] [--config CONFIG]
9495
| `--sbom` | Scan a pre-built SBOM or directory of SBOMs (CycloneDX JSON/XML, SPDX JSON/tag-value, or Syft JSON). When PATH is a directory, argus walks it recursively, sniffs each file, and scans every SBOM it finds. Auto-enables all SBOM-capable scanners (osv, grype, trivy) regardless of argus.yml. Filesystem scanners (bandit, gitleaks, ...) are skipped since they have nothing to scan. | |
9596
| `--interface`, `-i` | After the scan completes, open a viewer on the just-written results. 'terminal' launches the TUI (requires 'argus-security[terminal]'); 'browser' launches the local web UI (requires 'argus-security[browser]'). (terminal, browser) | |
9697
| `--fail-fast` | Abort immediately if any scanner fails instead of continuing. | `false` |
98+
| `--fail-on-scanner-error` | Exit non-zero when any scanner produced no output (typically a uid-mismatch on /output, container crash, or wrong entrypoint). Default behavior treats these as warnings so partial scans still surface findings; opt in for hard CI gates that require every configured scanner to actually run. | `false` |
9799
| `--timeout` | Per-scanner timeout in seconds. Scanners exceeding this limit are killed. | |
98100
| `--no-parallel` | Run scanners sequentially instead of concurrently. | `false` |
99101
| `--allow-local-versions` | Allow local tool versions that differ from argus-pinned versions. Use in airgapped environments where tool updates are constrained. | `false` |

0 commit comments

Comments
 (0)