Skip to content

Commit 9768767

Browse files
committed
feat(supply-chain): cosign-verify argus images + security policy doc
Closes hardening items (3) and (4) from "Secret Handling & Credential Surface Hardening" in docs/developer/SDK-ROADMAP.md. Item (3) — cosign + digest-pin verification at pull time: - argus/core/image_verify.py: new module classifies every pulled image into one of four paths: * argus-owned (ghcr.io/huntridge-labs/argus/*): cosign keyless verify against the publish workflow's identity + GitHub Actions OIDC issuer. Failure is fatal — scanner does not run. * third-party with @sha256: digest pin: Docker enforces content-hash match at pull, no cosign call. Logged at DEBUG. * third-party with tag-only pin: no crypto guarantee. One WARNING per scan run via report_tag_pinned_summary listing every tag-pinned image with a migration hint. * verification disabled: skipped wholesale (DEBUG log). - argus/core/engine.py: _run_in_container now calls verify_image right after _pull_image succeeds, accumulating results in self._verify_results. Fatal verification raises RuntimeError before subprocess.run, so the scanner subprocess never starts. Summary tag-pinned WARNING emitted once at end of run(). - argus/core/config.py: ExecutionConfig.verify_image_signatures defaults to True. Opt-out for air-gapped environments via execution.verify_image_signatures: false in argus.yml. - argus/core/schema.py: new bool-typed config key with validator. - Stdlib + cosign binary on PATH; no Python sigstore dependency. Fails up front with an install hint if cosign is missing. Item (4) — written policy doc: - docs/security.md: TL;DR table, threat model (defends-against + does-not-defend-against), credential precedence with worked examples (stdin > <field>_env > literal), container image provenance section walking through each verification path, the air-gapped opt-out, and a vulnerability-reporting pointer to GitHub Security Advisories. Tests: - argus/tests/core/test_image_verify.py: 21 tests covering image classification, all four verification paths, cosign output truncation, the tag-pinned summary dedup/no-warning behavior. - argus/tests/test_engine.py::TestSupplyChainVerificationGate: 6 engine-integration tests verifying the verify-then-run gate, cosign-fail-aborts-scanner contract, third-party digest-pin no-cosign-call, tag-only summary at run end, opt-out behavior, and missing-cosign-binary fatal handling. .ai/ updates: - architecture.yaml: new core/image_verify.py entry in both SDK structure blocks describing classification + engine integration. Roadmap: - Items (3) and (4) flipped to shipped with implementation summary and links. Items (5) — defensive audit-trail redact — remains queued. Full suite: 3107 passed (+27 new), 2 skipped. Follow-up roadmap item already noted: migrate third-party image tags in argus/containers.py to @sha256: digest pins. Renovate can keep them current once pinned.
1 parent 8e51955 commit 9768767

9 files changed

Lines changed: 1033 additions & 3 deletions

File tree

.ai/architecture.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ components:
3939
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
4040
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
4141
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field, *, stdin_override=None) accepts three forms in precedence order: stdin_override (highest, populated by CLI --*-password-stdin flags via a module-level slot registry — set_stdin_override / get_stdin_override / clear_stdin_overrides), <field>_env (env-var name reference; reads os.environ at scan time), or <field> literal (back-compat, warned at config-load if vendor-shaped via looks_like_literal_secret). The stdin path never reaches the per-scanner config dict so it can't leak into argus-audit.json / argus.log. validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
42+
"core/image_verify.py": "Supply-chain verification for container images at pull time. verify_image(ref) classifies the ref into: argus-owned (ghcr.io/huntridge-labs/argus/* — cosign keyless verify against the publish workflow's identity + GitHub Actions OIDC issuer; failure is fatal), third-party with @sha256: digest pin (Docker enforces content-hash match at pull, no cosign call needed), or third-party tag-only (logged once per scan via report_tag_pinned_summary). Engine calls this after _pull_image succeeds; fatal results raise RuntimeError before the scanner runs. Config knob: execution.verify_image_signatures (default True; opt-out for air-gapped). Stdlib + cosign binary on PATH; no Python sigstore dep. See docs/security.md."
4243
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary, diff_scans (scan-over-scan bucketing keyed off (scanner, id, location)). Consumed by argus view terminal (TUI ``D`` keybind, DiffScreen) and argus view browser (web UI ``/diff`` route)."
4344
"viewers/": "`argus view` interfaces (optional extras)"
4445
"viewers/__init__.py": "ViewerUnavailable shared exception"
@@ -472,6 +473,7 @@ docsite:
472473
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
473474
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
474475
"core/secrets.py": "Credential resolution for any scanner config field. resolve_secret(config, field, *, stdin_override=None) accepts three forms in precedence order: stdin_override (highest, populated by CLI --*-password-stdin flags via a module-level slot registry — set_stdin_override / get_stdin_override / clear_stdin_overrides), <field>_env (env-var name reference; reads os.environ at scan time), or <field> literal (back-compat, warned at config-load if vendor-shaped via looks_like_literal_secret). The stdin path never reaches the per-scanner config dict so it can't leak into argus-audit.json / argus.log. validate_env_var_name enforces POSIX shell identifier rules. Stdlib-only. Used by scanners.container and scanners.zap; future scanners with credential needs use the same helper. See ADR-024."
476+
"core/image_verify.py": "Supply-chain verification for container images at pull time. verify_image(ref) classifies the ref into: argus-owned (ghcr.io/huntridge-labs/argus/* — cosign keyless verify against the publish workflow's identity + GitHub Actions OIDC issuer; failure is fatal), third-party with @sha256: digest pin (Docker enforces content-hash match at pull, no cosign call needed), or third-party tag-only (logged once per scan via report_tag_pinned_summary). Engine calls this after _pull_image succeeds; fatal results raise RuntimeError before the scanner runs. Config knob: execution.verify_image_signatures (default True; opt-out for air-gapped). Stdlib + cosign binary on PATH; no Python sigstore dep. See docs/security.md."
475477
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary, diff_scans (scan-over-scan bucketing keyed off (scanner, id, location)). Consumed by argus view terminal (TUI ``D`` keybind, DiffScreen) and argus view browser (web UI ``/diff`` route)."
476478
"viewers/": "`argus view` interfaces (optional extras)"
477479
"viewers/__init__.py": "ViewerUnavailable shared exception"

argus/core/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ class ExecutionConfig:
4545
# enough overlap to win on a 5-scanner run with distinct images, low
4646
# enough that ghcr.io / dockerhub don't throttle.
4747
prewarm_workers: int = 4
48+
# Supply-chain: cosign-verify argus-owned container images on pull
49+
# (third-party with @sha256: digest pins are trusted by Docker's
50+
# pull-time content match; tag-only third-party images log one
51+
# WARNING per scan run). Default True — fail-closed for the
52+
# 4 images argus itself publishes. Opt out wholesale by setting
53+
# this to False (e.g., air-gapped environments with no Sigstore /
54+
# Rekor network access). See docs/security.md.
55+
verify_image_signatures: bool = True
4856

4957

5058
@dataclass
@@ -241,4 +249,5 @@ def _parse_execution_config(raw: dict | None) -> ExecutionConfig:
241249
pull_policy=raw.get("pull_policy", "if-not-present"),
242250
prewarm_images=bool(raw.get("prewarm_images", True)),
243251
prewarm_workers=int(raw.get("prewarm_workers", 4)),
252+
verify_image_signatures=bool(raw.get("verify_image_signatures", True)),
244253
)

argus/core/engine.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ def __init__(self, config: ArgusConfig):
6868
# can consult it from a worker thread without arg-threading every
6969
# internal call site.
7070
self._prewarmer = None
71+
# Supply-chain verification results, one per container pull this
72+
# run. Consumed by ``report_tag_pinned_summary`` at end of
73+
# ``run()`` to emit a single WARNING listing tag-pinned third-
74+
# party images (rather than N warnings for N scanners).
75+
from argus.core.image_verify import VerifyResult # local import
76+
self._verify_results: list[VerifyResult] = []
7177

7278
def register_scanner(self, scanner: Scanner) -> None:
7379
"""Register a scanner instance for use by the engine."""
@@ -192,6 +198,13 @@ def run(
192198
# network already has the bandwidth — no point racing it).
193199
self._shutdown_prewarm()
194200

201+
# Supply-chain: one WARNING per run listing all third-party
202+
# tag-pinned images. Logged here (not per-scanner) so a
203+
# user with N scanners pointing at trivy doesn't see N
204+
# identical warnings.
205+
from argus.core.image_verify import report_tag_pinned_summary
206+
report_tag_pinned_summary(self._verify_results)
207+
195208
# TODO: Add total_duration_ms to ScanSummary for audit trail.
196209
# Requires a model change (new field on the ScanSummary dataclass).
197210
# Per-scanner duration_ms is already recorded in each ScanResult.metadata.
@@ -759,6 +772,43 @@ def _run_in_container(
759772
if not self._pull_image(image):
760773
raise RuntimeError(f"Failed to pull container image: {image}")
761774

775+
# Supply-chain verification (ADR-024 + roadmap item #3):
776+
# - argus-owned images get cosign-verified against the
777+
# publish workflow's identity; failure aborts the scanner;
778+
# - third-party images with @sha256: digest pins are trusted
779+
# by Docker's pull-time content-hash enforcement;
780+
# - third-party tag-only pins log nothing here — the engine
781+
# emits a single summary WARNING at end of ``run()``.
782+
# Default is verify_image_signatures=True (security-first); the
783+
# user can opt out wholesale via execution.verify_image_signatures.
784+
from argus.core.image_verify import verify_image, VerifyStatus
785+
verify_signatures = getattr(
786+
self.config.execution, "verify_image_signatures", True,
787+
)
788+
v = verify_image(image, verify_signatures=verify_signatures)
789+
self._verify_results.append(v)
790+
if v.is_fatal:
791+
logger.error(
792+
"Supply-chain verification FAILED for '%s' (%s): %s",
793+
image, v.status.value, v.message,
794+
)
795+
raise RuntimeError(
796+
f"Supply-chain verification failed for {image}: "
797+
f"{v.message}"
798+
)
799+
if v.status == VerifyStatus.VERIFIED_COSIGN:
800+
logger.info(
801+
"Supply-chain: %s — cosign verified (argus-owned)", image,
802+
)
803+
elif v.status == VerifyStatus.VERIFIED_DIGEST_PIN:
804+
logger.debug(
805+
"Supply-chain: %s — verified via digest pin", image,
806+
)
807+
elif v.status == VerifyStatus.SKIPPED_BY_CONFIG:
808+
logger.debug(
809+
"Supply-chain: %s — verification disabled by config", image,
810+
)
811+
762812
digest = self._get_image_digest(image)
763813
logger.info(
764814
"Running '%s' in container: %s (digest=%s)",

argus/core/image_verify.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""Container image signature / pin verification.
2+
3+
Two complementary supply-chain checks are run for every image argus
4+
pulls:
5+
6+
* **Argus-owned images** (``ghcr.io/huntridge-labs/argus/*``) are
7+
cosign-signed at publish time via keyless Sigstore. We verify
8+
each pull against the expected certificate identity (our publish
9+
workflow URI) and OIDC issuer (GitHub Actions). Verification
10+
failure is fatal — refuse to run the scanner.
11+
12+
* **Third-party images** (Trivy, Grype, Checkov, OSV, ZAP,
13+
Hadolint, ESLint, etc.) are not in our signing surface. Two
14+
sub-paths:
15+
- ``image@sha256:...`` (digest pin): trust by content hash.
16+
Docker enforces digest match at pull time, so the pull
17+
itself *is* the verification — we report this and move on.
18+
- tag-only (``image:tag``): no cryptographic guarantee. We
19+
let the scan proceed but emit a single WARNING listing the
20+
tag-pinned images, with a hint to migrate to digest pins.
21+
22+
The combined policy is documented in ``docs/security.md`` and is
23+
the implementation of items (3) + (4) from the
24+
*Secret Handling & Credential Surface Hardening* roadmap section.
25+
26+
Stdlib + ``cosign`` binary only — no Python sigstore dependency to
27+
keep the install lean.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import logging
33+
import shutil
34+
import subprocess
35+
from dataclasses import dataclass
36+
from enum import Enum
37+
from typing import Iterable
38+
39+
logger = logging.getLogger("argus")
40+
41+
42+
# The Fulcio certificate's ``subject`` field for keyless sigstore
43+
# signatures captures the URI of the GitHub workflow that performed
44+
# the signing. We anchor verification to *our* publish workflow plus
45+
# *our* OIDC issuer; the certificate-identity regexp is permissive
46+
# across refs (tags, branches) so a workflow-internal change doesn't
47+
# force a security-policy update.
48+
ARGUS_OWNED_PREFIX = "ghcr.io/huntridge-labs/argus/"
49+
ARGUS_CERT_IDENTITY_REGEXP = (
50+
r"^https://github\.com/huntridge-labs/argus/"
51+
r"\.github/workflows/publish-release\.yml@"
52+
)
53+
ARGUS_OIDC_ISSUER = "https://token.actions.githubusercontent.com"
54+
55+
56+
class VerifyStatus(Enum):
57+
"""Outcome of a single image verification attempt."""
58+
59+
VERIFIED_COSIGN = "verified_cosign"
60+
VERIFIED_DIGEST_PIN = "verified_digest_pin"
61+
SKIPPED_TAG_PIN = "skipped_tag_pin"
62+
SKIPPED_BY_CONFIG = "skipped_by_config"
63+
FAILED_COSIGN = "failed_cosign"
64+
FAILED_COSIGN_BINARY_MISSING = "failed_cosign_binary_missing"
65+
66+
67+
@dataclass(frozen=True)
68+
class VerifyResult:
69+
"""The result of verifying a single image reference."""
70+
71+
status: VerifyStatus
72+
image: str
73+
message: str
74+
75+
@property
76+
def is_fatal(self) -> bool:
77+
"""True if the scanner using this image must NOT run."""
78+
return self.status in (
79+
VerifyStatus.FAILED_COSIGN,
80+
VerifyStatus.FAILED_COSIGN_BINARY_MISSING,
81+
)
82+
83+
84+
def is_argus_owned(image: str) -> bool:
85+
"""Return True if ``image`` is published from this repository."""
86+
return image.startswith(ARGUS_OWNED_PREFIX)
87+
88+
89+
def has_digest_pin(image: str) -> bool:
90+
"""Return True if ``image`` has a content-addressable ``@sha256:`` pin."""
91+
return "@sha256:" in image
92+
93+
94+
def verify_image(
95+
image: str,
96+
*,
97+
verify_signatures: bool = True,
98+
cosign_runner=None,
99+
) -> VerifyResult:
100+
"""Verify a single image reference.
101+
102+
``cosign_runner`` is an optional callable for tests; it receives
103+
the argv list and must return a ``subprocess.CompletedProcess``-
104+
shaped object with ``returncode``, ``stdout``, ``stderr``. The
105+
default uses ``subprocess.run`` with cosign on PATH.
106+
"""
107+
if not image:
108+
return VerifyResult(
109+
status=VerifyStatus.SKIPPED_BY_CONFIG,
110+
image=image,
111+
message="empty image reference — nothing to verify",
112+
)
113+
114+
if not verify_signatures:
115+
return VerifyResult(
116+
status=VerifyStatus.SKIPPED_BY_CONFIG,
117+
image=image,
118+
message="signature verification disabled via "
119+
"execution.verify_image_signatures: false",
120+
)
121+
122+
# Argus-owned: cosign verify, fail-on-failure.
123+
if is_argus_owned(image):
124+
return _verify_cosign(image, cosign_runner=cosign_runner)
125+
126+
# Third-party with digest pin: Docker enforces content-hash match
127+
# at pull. No additional verification needed.
128+
if has_digest_pin(image):
129+
return VerifyResult(
130+
status=VerifyStatus.VERIFIED_DIGEST_PIN,
131+
image=image,
132+
message="verified via @sha256 digest pin (Docker enforces "
133+
"content-hash match at pull)",
134+
)
135+
136+
# Third-party with tag-only pin: no crypto guarantee. Let the scan
137+
# proceed but report so the caller can warn once per run.
138+
return VerifyResult(
139+
status=VerifyStatus.SKIPPED_TAG_PIN,
140+
image=image,
141+
message="third-party image is tag-pinned (mutable). Pull "
142+
"succeeds but no signature or digest guarantee. "
143+
"Migrate to image@sha256:... for content-addressable "
144+
"trust.",
145+
)
146+
147+
148+
def _verify_cosign(image: str, *, cosign_runner=None) -> VerifyResult:
149+
"""Run ``cosign verify`` against an argus-owned image."""
150+
if cosign_runner is None:
151+
if not shutil.which("cosign"):
152+
return VerifyResult(
153+
status=VerifyStatus.FAILED_COSIGN_BINARY_MISSING,
154+
image=image,
155+
message=(
156+
"cosign binary not on PATH but signature verification "
157+
"is enabled. Install cosign (https://docs.sigstore.dev/cosign/installation/) "
158+
"or set execution.verify_image_signatures: false in "
159+
"argus.yml to opt out."
160+
),
161+
)
162+
cosign_runner = _default_cosign_runner
163+
164+
cmd = [
165+
"cosign", "verify", image,
166+
"--certificate-identity-regexp", ARGUS_CERT_IDENTITY_REGEXP,
167+
"--certificate-oidc-issuer", ARGUS_OIDC_ISSUER,
168+
]
169+
try:
170+
result = cosign_runner(cmd)
171+
except FileNotFoundError:
172+
return VerifyResult(
173+
status=VerifyStatus.FAILED_COSIGN_BINARY_MISSING,
174+
image=image,
175+
message=(
176+
"cosign binary not on PATH but signature verification "
177+
"is enabled. Install cosign or set "
178+
"execution.verify_image_signatures: false."
179+
),
180+
)
181+
182+
if result.returncode == 0:
183+
return VerifyResult(
184+
status=VerifyStatus.VERIFIED_COSIGN,
185+
image=image,
186+
message="cosign keyless verify passed "
187+
"(identity=argus publish workflow, "
188+
"issuer=token.actions.githubusercontent.com)",
189+
)
190+
191+
# Cosign failure — surface stderr so the user can act. Be careful
192+
# not to log credential-shaped strings; cosign output is the
193+
# canonical Sigstore/Fulcio error path and doesn't carry secrets.
194+
stderr = (result.stderr or "").strip()
195+
return VerifyResult(
196+
status=VerifyStatus.FAILED_COSIGN,
197+
image=image,
198+
message=(
199+
f"cosign verify FAILED for argus-owned image. "
200+
f"cosign output: {stderr[:500]}"
201+
),
202+
)
203+
204+
205+
def _default_cosign_runner(cmd: list[str]) -> subprocess.CompletedProcess:
206+
"""Production cosign runner — captures output, never raises on rc != 0."""
207+
return subprocess.run(
208+
cmd, capture_output=True, text=True,
209+
encoding="utf-8", errors="replace",
210+
)
211+
212+
213+
def report_tag_pinned_summary(results: Iterable[VerifyResult]) -> None:
214+
"""Emit one WARNING summarizing third-party tag-pinned images.
215+
216+
Called once per scan run after all verification has occurred so
217+
users don't get N separate warnings for N scanners pointing at the
218+
same registry. Silent if nothing was tag-pinned.
219+
"""
220+
tag_pinned = [
221+
r.image for r in results
222+
if r.status == VerifyStatus.SKIPPED_TAG_PIN
223+
]
224+
if not tag_pinned:
225+
return
226+
# Deduplicate but preserve order (Python 3.7+ dict ordering).
227+
unique = list(dict.fromkeys(tag_pinned))
228+
logger.warning(
229+
"%d third-party image(s) pulled with mutable tag pins "
230+
"(no cryptographic guarantee): %s. Migrate to "
231+
"@sha256:... digest pins in argus/containers.py for "
232+
"content-addressable trust. See docs/security.md for the "
233+
"supply-chain policy.",
234+
len(unique),
235+
", ".join(unique),
236+
)

argus/core/schema.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"pull_policy",
7272
"prewarm_images",
7373
"prewarm_workers",
74+
"verify_image_signatures",
7475
}
7576

7677
# Top-level containers block keys
@@ -416,6 +417,16 @@ def _validate_execution(path: str, data: Any) -> list[ConfigError]:
416417
f"Must be a positive integer (>=1), got {workers!r}",
417418
))
418419

420+
# Supply-chain signature verification flag — bool
421+
if "verify_image_signatures" in data and not isinstance(
422+
data["verify_image_signatures"], bool,
423+
):
424+
errors.append(ConfigError(
425+
f"{path}.verify_image_signatures",
426+
f"Must be a boolean (true/false), got "
427+
f"{type(data['verify_image_signatures']).__name__}",
428+
))
429+
419430
return errors
420431

421432

0 commit comments

Comments
 (0)