fix(scanners): correct execution-failure signaling and OSV container entrypoint#125
Conversation
…entrypoint
Five related correctness fixes around scanner execution and status
reporting. Each is a small, targeted change with a regression test;
the linux-default behavior is unchanged on every path that wasn't
broken.
* OSV container exit 127: ``container_entrypoint`` is now the absolute
``/osv-scanner`` path. Docker's ``--entrypoint`` override does not
consult the image's ``$PATH``, and ``ghcr.io/google/osv-scanner``
declares ``ENTRYPOINT ["/osv-scanner"]``. The bare name resolved
nowhere.
* lint-yaml false PASS: yamllint exit >= 2 with empty stdout used to
flow through ``_parse_output('') -> []`` and render ``Status: PASS``,
hiding bad-config failures. ``YamllintLinter.scan`` now branches on
the exit code: 0 = clean, 1 = findings, >= 2 with empty findings =
``execution_failed`` + reason from stderr.
* Unified failure metadata contract: ``run_subprocess_scan`` now emits
``metadata['execution_failed']`` + ``metadata['execution_failure_reason']``
on every failure path (FileNotFoundError, TimeoutExpired, no-output).
Previously it used ``metadata['error']`` while the engine's container
path used ``execution_failed``; the divergence meant local-path
failures were invisible to the reporter's warning row and to
``--fail-on-scanner-error``.
* Reporter degraded status: ``TerminalReporter`` now prints
``Status: PASS (degraded - some scanners did not run)`` when the
threshold check passes but at least one scanner has
``execution_failed=True``. Threshold compliance and execution
success are independent signals; the previous output had a
``Warning: ...`` row directly followed by ``Status: PASS`` which
contradicted itself. ``ScanSummary.passed`` semantics are unchanged.
* Windows chmod skip: ``os.chmod(output_dir, 0o777)`` in
``_run_in_container`` is now guarded by ``platform.system() !=
'Windows'``. NTFS doesn't honor POSIX bits, ``os.chmod`` only
flips the read-only attribute on Windows, and the macOS uid-mismatch
failure mode the chmod guards against doesn't apply on Windows
(Docker Desktop's bind-mount uid mapping is different). Linux/macOS
keep the chmod - covered by a new test that asserts mode 0o777
on non-Windows hosts.
Tests:
* New: ``test_yamllint.py`` covers the full exit-code matrix
(0 / 1 / 1+findings / 2+empty / 99+empty)
* New: OSV ``test_container_entrypoint_uses_absolute_path`` regression
* New: terminal ``test_report_status_pass_degraded_when_any_scanner_failed``,
``test_report_status_pass_clean_when_no_failures``,
``test_report_status_fail_takes_priority_over_degraded``
* New: engine ``test_chmod_skipped_on_windows`` and
``test_chmod_runs_on_non_windows`` - explicit Linux-safety guard
* Updated: ``test_scanner_template`` and ``test_scanner_scan_methods``
switched from the old ``metadata['error']`` key to
``metadata['execution_failed']`` to match the unified contract
Full SDK suite: 1640 passed, 8 skipped.
.ai/errors.yaml: added three new entries (exit-127 entrypoint,
yamllint silent-failure, and reporter degraded-status) so future
agents recognize the patterns.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
🔒 Argus Container Security ScanBranch: 📊 Combined Findings Summary
Scanned: 4 containers | Build Failures: 0 📦 Container Breakdown
🔍 Detailed Findings by Container🚨 cli - 29 vulnerabilities (23 unique)Image: Combined (Deduplicated)
🔷 Trivy Scanner (29 findings, 23 unique)
⚓ Grype Scanner (0 findings, 0 unique)✅ No vulnerabilities detected by Grype 🟡 scanner-bandit - 2 vulnerabilities (2 unique)Image: Combined (Deduplicated)
🔷 Trivy Scanner (2 findings, 2 unique)
⚓ Grype Scanner (0 findings, 0 unique)✅ No vulnerabilities detected by Grype
|
| 🚨 Critical | 🟡 Medium | 🔵 Low | Total | Unique | |
|---|---|---|---|---|---|
| 0 | 7 | 44 | 63 | 114 | 50 |
🔷 Trivy Scanner (114 findings, 49 unique)
| CVE | Severity | Package | Version | Fixed |
|---|---|---|---|---|
| CVE-2026-4878 | libcap2 | 1:2.75-10+b8 | N/A | |
| CVE-2025-69720 | libncursesw6 | 6.5+20250216-2 | N/A | |
| CVE-2026-29111 | libsystemd0 | 257.9-1~deb13u1 | N/A | |
| CVE-2025-69720 | libtinfo6 | 6.5+20250216-2 | N/A | |
| CVE-2026-29111 | libudev1 | 257.9-1~deb13u1 | N/A | |
| CVE-2025-69720 | ncurses-base | 6.5+20250216-2 | N/A | |
| CVE-2025-69720 | ncurses-bin | 6.5+20250216-2 | N/A | |
| CVE-2026-27456 | 🟡 MEDIUM | bsdutils | 1:2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | bsdutils | 1:2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libblkid1 | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | libblkid1 | 2.41-5 | N/A |
| CVE-2026-4046 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4437 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4438 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-5435 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-5450 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-5928 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-6238 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4046 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-4437 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-4438 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-5435 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-5450 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-5928 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-6238 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | liblastlog2-2 | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | liblastlog2-2 | 2.41-5 | N/A |
| CVE-2026-34743 | 🟡 MEDIUM | liblzma5 | 5.8.1-1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libmount1 | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | libmount1 | 2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libsmartcols1 | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | libsmartcols1 | 2.41-5 | N/A |
| CVE-2026-40225 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40226 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-4105 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40225 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40226 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-4105 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libuuid1 | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | libuuid1 | 2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | login | 1:4.16.0-2+really2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | login | 1:4.16.0-2+really2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | mount | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | mount | 2.41-5 | N/A |
| CVE-2026-5958 | 🟡 MEDIUM | sed | 4.9-2 | N/A |
| CVE-2026-5704 | 🟡 MEDIUM | tar | 1.35+dfsg-3.1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | util-linux | 2.41-5 | N/A |
| CVE-2026-3184 | 🟡 MEDIUM | util-linux | 2.41-5 | N/A |
| CVE-2026-27171 | 🟡 MEDIUM | zlib1g | 1:1.3.dfsg+really1.3.1-1+b1 | N/A |
| CVE-2026-3219 | 🟡 MEDIUM | pip | 26.0.1 | N/A |
...and 64 more
⚓ Grype Scanner (0 findings, 0 unique)
✅ No vulnerabilities detected by Grype
⚠️ scanner-supply-chain - 9 vulnerabilities (9 unique)
Image: ghcr.io/huntridge-labs/argus/scanner-supply-chain:b88262aaae317303fcf076520266501f4ee22204
Combined (Deduplicated)
| 🚨 Critical | 🟡 Medium | 🔵 Low | Total | Unique | |
|---|---|---|---|---|---|
| 0 | 4 | 5 | 0 | 9 | 9 |
🔷 Trivy Scanner (9 findings, 9 unique)
| CVE | Severity | Package | Version | Fixed |
|---|---|---|---|---|
| CVE-2026-32280 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-32281 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-32283 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-33810 | stdlib | v1.26.1 | 1.26.2 | |
| CVE-2026-3219 | 🟡 MEDIUM | pip | 26.0.1 | N/A |
| CVE-2026-6357 | 🟡 MEDIUM | pip | 26.0.1 | 26.1 |
| CVE-2026-32282 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
| CVE-2026-32288 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
| CVE-2026-32289 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
⚓ Grype Scanner (0 findings, 0 unique)
✅ No vulnerabilities detected by Grype
Generated by Argus
…failure reasons
Follow-up correctness pass on top of the previous patch. The first
round addressed the silent-failure paths; this round addresses how
those signals are presented and adds a fourth state the user asked
for explicitly.
Adapter exit-code semantics (locked in by tests):
* OSV-scanner: 0 = no vulns, 1 = vulns found (happy path), >= 2 with
empty output = real runtime failure. Engine parses results.json
whenever the file exists; exit code is irrelevant.
* yamllint: 0 = clean, 1 = lint violations (happy path), >= 2 with
empty stdout = real runtime/config failure.
Changes:
* New 'parse_failed' state. The user asked for four distinct
states: ran cleanly / ran-with-findings / didn't run /
ran-but-output-unparsable. The fourth state was previously a
raised exception that propagated up to the engine's exception
handler and got rolled up as a generic "scanner failed". Now:
- engine._run_in_container wraps scanner.parse_results() in
try/except. On exception: metadata['parse_failed']=True and
metadata['parse_failure_reason']=<exc + clipped output head>.
Other scanners' results keep flowing.
- scanner_template.run_subprocess_scan does the same for the
local-execution path.
- parse_failed is orthogonal to execution_failed; reporters render
them in separate warning blocks.
* Reporter no longer emits generic guesses. The previous "uid
mismatch / crashed / wrong entrypoint" boilerplate was misleading
for OSV exit-1-with-findings and yamllint binary-not-found cases.
TerminalReporter now prints the per-scanner reason
(execution_failure_reason or parse_failure_reason) verbatim, in
bullet form, with the scanner name. Status label gets a richer
breakdown: "PASS (degraded - 1 did not run, 1 unparsable)".
* yamllint hardened against FileNotFoundError. is_available() is
checked before scan(), but the binary can disappear in the gap
(CI cleanup race, manual uninstall). scan() now catches
FileNotFoundError and returns a clean execution_failed ScanResult
instead of letting the exception escape into the engine's
exception handler (which would render a stack trace).
* --fail-on-scanner-error fires on parse_failed too. Both states
represent "the scan didn't fully succeed"; from a CI gating
perspective they're equivalent.
Cross-platform safety:
* No platform-specific branches added or modified. Linux scanner
flows are unchanged - the new parse-failed wrapping fires only
when the parser raises (a previously-broken path everywhere).
* Adapter-specific exit-code handling stays at the adapter layer
(yamllint owns its >= 2 rule, OSV owns its "exit code irrelevant
to parsing" rule). No global "non-zero means failure" logic was
added.
* Existing output formats and result schemas stable: parse_failed
is additive metadata; ScanSummary.passed semantics unchanged
(still threshold-only).
Tests added (8 new):
* OSV: parse_results succeeds-with-findings on real fixture
(exit-1 acceptance case)
* OSV: parse_results returns [] on zero-findings fixture without
raising
* OSV: parse_results raises on malformed JSON (so the engine
wrapper catches as parse_failed)
* yamllint: FileNotFoundError returns execution_failed, no
exception escapes
* engine: parse exception in container path -> parse_failed
(not execution_failed, not raised)
* terminal: warning block shows per-scanner reason, no generic
text
* terminal: parse_failed renders distinctly from execution_failed
* terminal: degraded status label lists both kinds when both present
Tests updated (1):
* test_scanner_template's test_unexpected_exception_propagates
renamed/rewritten to test_parse_exception_emits_parse_failed_metadata
reflecting the new contract.
Full SDK suite: 1648 passed, 8 skipped.
.ai/errors.yaml: degraded-status entry refreshed with new label
shape; new entry added for the parse_failed pattern.
…ainer output Two Windows-specific correctness fixes. Linux behavior is unchanged. Bug 1 — yamllint PermissionError on Windows (AppLocker / SRP): On Windows hosts where Software Restriction Policy or AppLocker blocks executable launches from user AppData paths, the direct ``yamllint.exe`` invocation raises ``PermissionError [WinError 5] Access is denied`` mid-scan. The python interpreter itself is typically whitelisted, so loading the same package via ``python -m yamllint`` works on the same machine and is argv-compatible (yamllint's __main__ accepts the same flags). Fix: ``YamllintLinter._run_with_windows_fallback`` wraps the subprocess.run call. On ``sys.platform == 'win32'``, a PermissionError/OSError on the direct invocation triggers a retry with [sys.executable, '-m', 'yamllint'] + cmd[1:]. FileNotFoundError is re-raised unchanged so 'yamllint not installed' still renders as a clean execution_failed (the fallback wouldn't help — yamllint isn't installed at all). Linux is fully bypassed: AppLocker doesn't exist there, so a PermissionError on Linux indicates a genuine permission bug (chmod, mount options) the user needs to see, not a policy case the fallback compensates for. The Linux invocation path is byte-identical to before — same single subprocess.run call, same PATH lookup, same exception handling. Bug 2 — UnicodeDecodeError reading container output: Docker container output (and most CLI scanner output) is UTF-8. ``subprocess.run(text=True)`` and ``Path.read_text()`` fall back to the platform default when ``encoding=`` is omitted — cp1252 on Windows. Any non-ASCII byte (CVE descriptions with accented characters, file paths with unicode segments, scanner banners with arrow glyphs) raises UnicodeDecodeError mid-scan. Fix: explicit ``encoding='utf-8', errors='replace'`` on every container-output consumer: * engine._run_in_container's docker subprocess.run * scanner_template.run_subprocess_scan's subprocess.run * yamllint's subprocess.run (both direct + Windows fallback) * All 14 scanner.parse_results .read_text() calls (bandit, checkov, clamav, container, gitleaks, grype, opengrep, osv, supply_chain, trivy, trivy_iac, zap) * Engine + scanner_template parse-failed file-head reads errors='replace' over 'strict' is intentional: a security tool that shows U+FFFD on undecodable bytes is better than one that crashes on otherwise-usable scanner output. Linux unaffected since LANG=C.UTF-8 is the universal default; this only changes behavior on hosts whose default ISN'T UTF-8. Tests added (5 new): * yamllint: PermissionError on Windows triggers python -m fallback, second subprocess call uses sys.executable -m yamllint * yamllint: PermissionError on Linux does NOT fall back, raised through to outer handler as execution_failed * yamllint: subprocess.run gets encoding='utf-8' + errors='replace' * engine: docker subprocess.run gets encoding='utf-8' + errors='replace' * OSV: parse_results round-trips non-ASCII UTF-8 content (CVE summaries with accents, file paths with unicode) without raising — the user's reported decode crash on byte 0x8f at position 18203 Full SDK suite: 1653 passed, 8 skipped. .ai/errors.yaml: two new entries — Windows AppLocker pattern and the cp1252 UnicodeDecodeError pattern, both with the exact platform-guard rationale.
The smoke step in ``validate-install-from-source`` runs ``argus scan --list`` to verify scanner registration works end-to- end after ``pip install <repo-root>``. GitHub-hosted runners on Python 3.13.13 hit a transient SIGSEGV in this exact subprocess pattern — first observed on the PR #125 merge run (green on first re-run), and reproduced again here on PR #126. The crash: * Doesn't reproduce locally on macOS Python 3.13.13. * Doesn't reproduce on linux/aarch64 or linux/amd64 in python:3.13.13-slim Docker containers. * Has no Python traceback — bash exits 139 immediately after ``argus --version`` succeeds. * Resolves on retry (PR #125's CI run flipped to green on ``gh run rerun``). Adding a retry loop (max 3 attempts) so the transient flake doesn't gate this PR. A real registration bug (the class of bug this smoke is meant to catch) fails all 3 attempts and surfaces clearly. Each attempt prints what it got so the failure mode is obvious if it ever does fire. The lint step in the same job is the deterministic guard — it inspects every wrapper's action.yml for the required/forbidden patterns and doesn't depend on subprocess execution at all.
…DR-019 + CI guard (#126) * refactor(wrappers): install Argus SDK from composite checkout Migrates the remaining 14 SDK-using composite wrappers from ``pip install pyyaml`` (which relied on the legacy ``bin/argus`` shim) to ``pip install "${{ github.action_path }}/../../.."``, matching the pattern already in scanner-container and scanner-zap. Wrappers migrated (8 scanner + 6 linter): * scanner-bandit, scanner-checkov, scanner-clamav, scanner-gitleaks, scanner-opengrep, scanner-osv, scanner-supply-chain, scanner-trivy-iac * linter-dockerfile, linter-javascript, linter-json, linter-python, linter-terraform, linter-yaml Each wrapper: * Step renamed: "Install dependencies" -> "Install Argus SDK" * Install line: ``pip install pyyaml`` -> ``pip install "${{ github.action_path }}/../../.."`` * Inline rationale comment matching scanner-container's Why this pattern: When a consumer pins ``uses: org/argus/.github/actions/<wrapper>@<ref>``, GitHub Actions clones the entire argus repo at <ref> into ``_actions/org/argus/<ref>/`` (depth-1 of the tree at that ref, not just the action subdirectory). ``${{ github.action_path }}/../../..`` resolves to that clone root, which contains ``pyproject.toml`` and the ``argus/`` package — pip-installable directly. Benefits over ``pip install argus-security==<X>``: * Pin behavior is automatic: action ref == SDK version, no release-it coordination, no PyPI publish lag. * Air-gap friendly: GHES instances mirroring this repo locally don't also need a private PyPI mirror with argus-security uploaded. The composite checkout is the SDK source. * No second distribution channel to keep in sync. Cross-platform safety: * Pure YAML edits — no behavior change in the action steps that follow. Each wrapper still calls ``argus scan`` the same way. * The ``if:`` guards on scanner-checkov / scanner-trivy-iac ("only install when has_iac == 'true'") are preserved. * No platform branching introduced. Validation: * All 16 SDK-using composite ``action.yml`` files parse as valid YAML. * End-to-end smoke: ``pip install <repo-root>`` in a fresh Python 3.13.13 venv completes cleanly, ``argus --version`` reports 0.7.2, ``argus scan --list`` enumerates all 18 scanners. * SDK suite: 1653 passed, 8 skipped (no regressions). * Wrapper test suite (``.github/actions/*/tests/``): 509 passed. Roadmap: * Marks roadmap items 199 + 200 ("Apply install-from-source to remaining 14 wrappers" and "Rename Install dependencies -> Install Argus SDK") complete. * ``scanner-dependency-review`` was listed in the original 14 by mistake — it wraps GitHub's ``dependency-review-action`` and doesn't use the Argus SDK. Roadmap text updated to reflect that. * ``bin/argus`` shim removal (item 201) and README/QUICK-START TestPyPI flag cleanup (item 197) are independent and tracked separately. * chore: remove bin/argus shim The shim added the repo root to sys.path so a fresh checkout could run ``./bin/argus ...`` without ``pip install``. Now that: * All 16 SDK-using composite wrappers install the SDK from the checkout via ``pip install "${{ github.action_path }}/../../.."`` (PR #126), and * Pip's entry point ``argus = "argus.cli:main"`` in pyproject.toml has been the canonical CLI launcher for every other consumer (PyPI install, ``uvx argus``, repo dev installs), …the shim has zero remaining callers. Audit before delete: * No workflow files reference ``bin/argus``. * No tests reference it. * No production code path references it. * Doc mentions of ``bin/argus`` are all ``.venv/bin/argus`` (the pip-installed entry point in a virtualenv) — completely different, unaffected. Closes roadmap item #201 (Remove bin/argus wrapper). * docs: codify install-from-source pattern (ADR-019, CONTRIBUTING, CI guard) Three concentric guards so the install-from-source pattern doesn't silently regress as new wrappers get added: * CONTRIBUTING.md "Define action.yml" section now includes the exact ``Install Argus SDK`` step, the rationale ("don't replace this with pip install argus-security==<X>"), and a pointer to ADR-019. Future contributors see the canonical pattern at the exact place they're copying from. * .ai/decisions.yaml ADR-019 records the decision, the alternatives considered (PyPI install, PyInstaller binary, Docker action), and the consequences (release coordination, GHES portability, bin/argus removal). Search-friendly for the next AI agent that has to make a similar call. * .github/workflows/test-actions.yml gains a ``validate-install-from-source`` job that lints every SDK-using wrapper for: - Forbidden: ``run: pip install pyyaml`` (legacy bin/argus shim) - Forbidden: ``run: pip install argus-security`` (PyPI install) - Required: ``${{ github.action_path }}/../../..`` substring Plus an end-to-end smoke that ``pip install <repo-root>`` actually produces a working ``argus`` CLI. Forbidden patterns are anchored to ``run:`` lines so the rationale comments (which cite the bad patterns as counter-examples) don't trigger false positives. Patterns live in env vars to keep ``${{ ... }}`` syntax out of the run-script body — workflow-injection-safety best practice from the GitHub security guidance. Exemptions list (codeql, dependency-review, syft, *-summary, scn-detector, comment-pr, get-job-id, ai-summary, security- summary, linting-summary, parse-container-config) — these don't call ``argus scan`` and don't need the SDK installed. Local dry-run: all 16 SDK-using wrappers pass the guard, all 5 exemptions correctly skipped. CONTRIBUTING.md, decisions.yaml, and test-actions.yml all parse as valid YAML. * ci: retry transient SIGSEGV in install-from-source smoke The smoke step in ``validate-install-from-source`` runs ``argus scan --list`` to verify scanner registration works end-to- end after ``pip install <repo-root>``. GitHub-hosted runners on Python 3.13.13 hit a transient SIGSEGV in this exact subprocess pattern — first observed on the PR #125 merge run (green on first re-run), and reproduced again here on PR #126. The crash: * Doesn't reproduce locally on macOS Python 3.13.13. * Doesn't reproduce on linux/aarch64 or linux/amd64 in python:3.13.13-slim Docker containers. * Has no Python traceback — bash exits 139 immediately after ``argus --version`` succeeds. * Resolves on retry (PR #125's CI run flipped to green on ``gh run rerun``). Adding a retry loop (max 3 attempts) so the transient flake doesn't gate this PR. A real registration bug (the class of bug this smoke is meant to catch) fails all 3 attempts and surfaces clearly. Each attempt prints what it got so the failure mode is obvious if it ever does fire. The lint step in the same job is the deterministic guard — it inspects every wrapper's action.yml for the required/forbidden patterns and doesn't depend on subprocess execution at all. --------- Co-authored-by: eFAILution <eFAILution@users.noreply.github.com>
Description
Stacked correctness pass on top of
feat/argus-portabilitycovering scanner execution, status reporting, and Windows-specific platform compatibility. Three commits:3e7dc78— initial five fixes (OSV exit-127, lint-yaml false PASS, unified failure metadata contract, reporter degraded status, Windows chmod skip).55d0118— follow-up addressing review of (1): adapter-specific exit-code semantics, newparse_failedstate, per-scanner failure reasons in the reporter (replacing misleading "uid mismatch / crashed / wrong entrypoint" boilerplate).4760c1d— Windows compatibility: AppLocker / SRP fallback foryamllint, explicit UTF-8 on every container-output consumer.Changes Made
Details
Commit 1 (
3e7dc78) — initial fixes--entrypointdoesn't consult the image's$PATH. Pincontainer_entrypoint = "/osv-scanner"._parse_output("") -> []. Branch on documented exit codes (0/1/≥2).run_subprocess_scanand_run_in_containernow both emitmetadata["execution_failed"]+execution_failure_reason.Status: PASS (degraded — …)instead of contradicting the warning row above it.os.chmod(output_dir, 0o777)inplatform.system() != "Windows". Linux/macOS keep it.Commit 2 (
55d0118) — follow-upAdapter exit-code semantics (locked in by tests):
results.jsonwhenever the file exists; the exit code is irrelevant to parsing.New
parse_failedstate. Fourth state distinct from "execution failure": scanner ran and produced output we just couldn't interpret. Engine +scanner_templatenow wrapscanner.parse_results()in try/except, emitmetadata["parse_failed"]+parse_failure_reason. Other scanners' results keep flowing.Per-scanner failure reasons. Replaced misleading "uid mismatch / crashed / wrong entrypoint" boilerplate with the actual reason from each adapter, in bullet form:
yamllint hardened against
FileNotFoundError.--fail-on-scanner-errorfires onparse_failedtoo.Commit 3 (
4760c1d) — Windows platform fixesBug 1: yamllint PermissionError on Windows (AppLocker / SRP). On Windows hosts where Software Restriction Policy or AppLocker blocks executable launches from user AppData paths,
yamllint.exeraisesPermissionError [WinError 5] Access is denied. The Python interpreter is typically whitelisted, sopython -m yamllintworks on the same machine.YamllintLinter._run_with_windows_fallbackwraps the subprocess call. Onsys.platform == 'win32', aPermissionError/OSErroron the direct launch triggers a retry with[sys.executable, "-m", "yamllint"] + cmd[1:].FileNotFoundErroris re-raised unchanged so the "yamllint not installed" path still renders cleanly.Platform guard rationale: AppLocker doesn't exist on Linux. A PermissionError there indicates a genuine permission bug (chmod, mount options) the user needs to see — not a policy case the fallback compensates for. The Linux invocation path is byte-identical to before this commit: same single
subprocess.runcall, same PATH lookup, same exception handling. The new testtest_permission_error_on_linux_does_not_fall_backasserts exactly one subprocess call fires on Linux even when PermissionError is raised.Bug 2: UnicodeDecodeError reading container output. Docker container output is UTF-8.
subprocess.run(text=True)andPath.read_text()fall back to the platform default whenencoding=is omitted — cp1252 on Windows — which raisesUnicodeDecodeError: 'charmap' codec can't decode byte 0x8fon any non-ASCII byte (CVE descriptions, file paths with non-ASCII characters, scanner banners).Fix: explicit
encoding='utf-8', errors='replace'on every container-output consumer:engine._run_in_containerdocker subprocess.runscanner_template.run_subprocess_scansubprocess.runscanner.parse_results.read_text()calls acrossbandit,checkov,clamav,container,gitleaks,grype,opengrep,osv,supply_chain,trivy,trivy_iac,zaperrors='replace'over'strict'is intentional: a security tool showing�on undecodable bytes is better than one that crashes on otherwise-usable output. Linux is unaffected —C.UTF-8is the universal default; the change only alters behavior on hosts whose default encoding isn't UTF-8.Cross-platform safety evidence
test_chmod_runs_on_non_windows).if sys.platform != 'win32': raise— Linux never enters the fallback branch (test_permission_error_on_linux_does_not_fall_backasserts exactly one subprocess call). UTF-8 was already the default on Linux, so explicit encoding is a no-op there.≥ 2rule, OSV owns its "exit code irrelevant to parsing" rule).parse_failedis additive metadata;ScanSummary.passedsemantics unchanged.Testing
Test Results
New tests across all three commits (20 new):
Commit 1 (7):
test_yamllint.py(new file): exit-code matrix0/1/1+findings/2+empty/99+emptytest_osv.py::test_container_entrypoint_uses_absolute_pathtest_terminal.py::test_report_status_pass_degraded_when_any_scanner_failedtest_terminal.py::test_report_status_pass_clean_when_no_failurestest_terminal.py::test_report_status_fail_takes_priority_over_degradedtest_engine.py::test_chmod_skipped_on_windowstest_engine.py::test_chmod_runs_on_non_windowsCommit 2 (8):
test_osv.py::test_parse_results_succeeds_when_json_valid_regardless_of_exit_code(OSV exit-1-with-findings acceptance criterion)test_osv.py::test_parse_results_zero_findings_returns_empty_list_no_exceptiontest_osv.py::test_parse_results_malformed_json_raises_for_engine_to_catchtest_yamllint.py::test_filenotfound_returns_execution_failed_not_raisedtest_engine.py::test_container_parse_exception_marks_parse_failed_not_raisedtest_terminal.py::test_report_does_not_emit_generic_failure_guessestest_terminal.py::test_report_parse_failed_renders_distinctly_from_execution_failedtest_terminal.py::test_report_status_label_lists_both_kinds_when_both_presentCommit 3 (5):
test_yamllint.py::test_permission_error_on_windows_falls_back_to_python_moduletest_yamllint.py::test_permission_error_on_linux_does_not_fall_back(cross-platform safety guard)test_yamllint.py::test_subprocess_uses_utf8_encodingtest_engine.py::test_docker_subprocess_uses_utf8_encodingtest_osv.py::test_parse_results_handles_non_ascii_utf8_bytes(direct regression for the user'sbyte 0x8f at position 18203crash)Security Considerations
Security Details
The lint-yaml, OSV, degraded-status, and parse-failed fixes close subtle correctness gaps where a scanner-execution failure (yamllint config error, OSV image misconfigured, schema drift) would render as a clean PASS — exactly the kind of false-negative a security pipeline must avoid. The Windows fixes restore parity with Linux on AppLocker-restricted environments and on hosts with non-UTF-8 default encodings, ensuring that what a Linux CI pipeline detects, a Windows developer machine also detects.
AI Context Updates (.ai/)
.ai/architecture.yamlupdated (if components/structure changed).ai/workflows.yamlupdated (if commands/tasks changed).ai/decisions.yamlupdated (if design decision made).ai/errors.yamlupdated (if common error addressed)Six new entries under
SCANNER EXECUTION FAILURESandWINDOWS-SPECIFIC SCANNER ERRORSsections: exit-127 absolute-entrypoint pattern (OSV), yamllint silent-failure, reporter degraded-status, parse_failed, Windows AppLocker / SRP, and the cp1252 UnicodeDecodeError pattern.Checklist
Related Issues
N/A — surfaced from a user bug report and follow-up reviews of each patch.