From c6140bff588a3e9a0b4c90c1ef33f62e058c76d3 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Tue, 5 May 2026 22:28:13 -0400 Subject: [PATCH 1/2] fix(validate): catch typos and missing fields in containers config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes plus a workflow refactor, all surfaced while running argus against argus's own source and container images. 1. argus validate now validates the top-level containers block. Until now the schema validator recognized 'containers' as a known top-level key but never inspected its contents — typos (e.g. image_path instead of dockerfile), an empty images list, missing both image and dockerfile on an entry, and sub-scanner names outside trivy/grype/ syft all sailed past argus validate and only surfaced (or failed silently) at scan time. New _validate_containers helper in argus/core/schema.py walks the block and produces the same ConfigError objects the rest of the validator already emits, so argus validate and the pre-scan validation in ArgusConfig._load_file both pick it up. 15 unit tests in argus/tests/core/test_schema_containers.py. 2. argus scan container auto-loads argus.yml when no --config is given. The source-scan dispatcher has always done this; the container subcommand used to require an explicit --config FILE even when an argus.yml sat right at the project root with a containers block. Now both flows search the canonical _DEFAULT_CONFIG_NAMES list. CLI flags still take precedence over config-file values. Test in argus/tests/test_cli.py. 3. .ai/ doc cleanup: the recent context refresh claimed argus list and argus version are subcommands. They are not — argus --version is a top-level flag, and the registered subcommand list is now correctly spelled out in .ai/context.yaml and .ai/architecture.yaml. 4. .github/workflows/build-containers.yml: extract the four hard-coded image entries into a preflight matrix job that reads argus.yml containers.images and emits a JSON matrix the build, scan, and test-cli jobs consume. argus.yml becomes the single source of truth for the dogfood image list — adding a fifth scanner image is now one entry, not three matrix edits across the workflow plus the dogfood config. The actual scanning still runs aquasecurity/trivy-action and anchore/scan-action (authored actions, not argus-scanning-argus) — argus.yml drives the matrix, the trust boundary stays outside our own codebase. Tests: 1513 passed (was 1497 before this PR; 16 new from schema tests and the auto-config-load regression test). --- .ai/architecture.yaml | 6 +- .ai/context.yaml | 6 +- .github/workflows/build-containers.yml | 67 ++++++-- argus/cli.py | 15 ++ argus/core/schema.py | 141 +++++++++++++++++ argus/tests/core/test_schema_containers.py | 172 +++++++++++++++++++++ argus/tests/test_cli.py | 26 ++++ 7 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 argus/tests/core/test_schema_containers.py diff --git a/.ai/architecture.yaml b/.ai/architecture.yaml index e6f6b497..e84e16dc 100644 --- a/.ai/architecture.yaml +++ b/.ai/architecture.yaml @@ -494,15 +494,15 @@ docsite: config_file: "argus.yml" cli_commands: - "argus scan [scanner] --path --config --severity-threshold --format [--interface=terminal|browser]" - - "argus scan container [--image REF | --discover [PATH]] [--scanners trivy,grype,syft] [--no-keep-raw]" + - "argus scan container [--config argus.yml | --image REF | --discover [PATH]] [--scanners trivy,grype,syft] [--no-keep-raw]" - "argus init [--force] — generate tailored argus.yml from auto-detection" - - "argus list — show registered scanners (SCANNER_REGISTRY)" - - "argus validate [argus.yml] — JSON Schema validation" + - "argus validate — JSON Schema validation of the auto-detected argus.yml" - "argus report --results-dir --output-dir — re-emit canonical results" - "argus view [terminal|browser] [PATH] [--port N] [--no-open]" - "argus mcp — start MCP server over stdio" - "argus completion {bash,zsh,fish} — shell completion (dynamic from registry)" - "argus cache info|clean — manage scanner DB cache volumes" + - "argus --version — top-level flag (no `argus version` subcommand exists)" directory_structure: "argus/": "Standalone Python SDK (primary interface, ADR-013)" diff --git a/.ai/context.yaml b/.ai/context.yaml index a9bcdeee..a8324f66 100644 --- a/.ai/context.yaml +++ b/.ai/context.yaml @@ -41,14 +41,16 @@ entrypoints: cli_subcommands: scan: "argus scan [scanner ...] — primary entry; supports source, container, lint flows" init: "argus init — generate a tailored argus.yml from project auto-detection" - list: "argus list — enumerate registered scanners (SCANNER_REGISTRY + LINTER_REGISTRY)" + classify: "argus classify — classify findings by category" + collect: "argus collect — collect scanner outputs" validate: "argus validate — typecheck argus.yml against the JSON Schema" view: "argus view [terminal|browser] — interactive triage of argus-results.json" report: "argus report — re-emit results in another format without re-scanning" completion: "argus completion {bash,zsh,fish} — shell completion (dynamic from registry)" mcp: "argus mcp — start the MCP server over stdio for AI-assistant integration" cache: "argus cache info|clean — manage per-scanner DB cache volumes" - version: "argus version" + cli_flags: + version: "argus --version (top-level flag, NOT a subcommand)" mcp_server: "argus mcp" mcp_install: "pip install argus-security[mcp]" viewer_extras: diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 7e48dae4..3a1477fb 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -34,23 +34,53 @@ concurrency: cancel-in-progress: true jobs: + # ── Step 0: Build matrix from argus.yml ────────────────────────────── + # Single source of truth for the image list — argus.yml ``containers:`` + # block. Removes the duplication where build/, scan/ and test-cli/ + # each had a separate hardcoded matrix that could drift out of sync + # with the dogfood scan target list. + matrix: + name: Resolve image matrix + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.build.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build matrix from argus.yml + id: build + # yq is preinstalled on ubuntu-latest. Reads only the local + # checked-out argus.yml (no untrusted input) so the run-step + # is safe to compose inline. + run: | + set -euo pipefail + matrix=$(yq -o=json '.containers.images | map({ + "image": (.image | split(":") | .[0] | split("/") | .[-1]), + "image_ref_template": .image, + "dockerfile": .dockerfile, + "context": (.context // ".") + })' argus.yml) + count=$(echo "$matrix" | yq 'length') + if [ "$count" -eq 0 ]; then + echo "::error::argus.yml containers.images is empty — no images to build/scan/test." + exit 1 + fi + echo "Resolved $count image(s) from argus.yml" + echo "$matrix" | yq -P + echo "matrix=$(echo "$matrix" | jq -c .)" >> "$GITHUB_OUTPUT" + # ── Step 1: Build all custom images ────────────────────────────────── build: name: Build Images + needs: matrix runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: true matrix: - include: - - image: scanner-bandit - dockerfile: docker/Dockerfile.bandit - - image: scanner-opengrep - dockerfile: docker/Dockerfile.opengrep - - image: scanner-supply-chain - dockerfile: docker/Dockerfile.supply-chain - - image: cli - dockerfile: docker/Dockerfile.cli + include: ${{ fromJson(needs.matrix.outputs.matrix) }} steps: - name: Checkout @@ -89,17 +119,13 @@ jobs: # ── Step 2: Scan each image with Trivy + Grype ────────────────────── scan: name: Scan ${{ matrix.image }} - needs: [build] + needs: [matrix, build] runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - include: - - image: scanner-bandit - - image: scanner-opengrep - - image: scanner-supply-chain - - image: cli + include: ${{ fromJson(needs.matrix.outputs.matrix) }} steps: - name: Download image artifact @@ -220,7 +246,7 @@ jobs: # ── Step 3: Test argus CLI using built images ─────────────────────── test-cli: name: Test Argus CLI - needs: [build] + needs: [matrix, build] runs-on: ubuntu-latest timeout-minutes: 15 @@ -251,12 +277,18 @@ jobs: path: /tmp/images - name: Load and retag images + # Image names come from the matrix output (resolved from + # argus.yml) — no hardcoded list to drift from the dogfood scan + # configuration. ``IMAGE_NAMES`` is a JSON array; jq extracts + # the short names. Reads only env vars and JSON we constructed + # ourselves; no untrusted input. run: | + set -euo pipefail for tarball in /tmp/images/*.tar.gz; do gunzip -c "$tarball" | docker load done # Retag from SHA to version tag that containers.py expects - for image in scanner-bandit scanner-opengrep scanner-supply-chain cli; do + for image in $(echo "$IMAGE_NAMES_JSON" | jq -r '.[].image'); do SHA_TAG="ghcr.io/huntridge-labs/argus/${image}:${GITHUB_SHA}" VERSION_TAG="ghcr.io/huntridge-labs/argus/${image}:0.7.0" if docker image inspect "$SHA_TAG" > /dev/null 2>&1; then @@ -265,6 +297,7 @@ jobs: done env: GITHUB_SHA: ${{ github.sha }} + IMAGE_NAMES_JSON: ${{ needs.matrix.outputs.matrix }} - name: Package safety check run: python -m scripts.ci.check_package diff --git a/argus/cli.py b/argus/cli.py index 645e0e6b..e97bfd8d 100644 --- a/argus/cli.py +++ b/argus/cli.py @@ -1122,6 +1122,21 @@ def _load_container_config(args: argparse.Namespace) -> dict: """ config: dict = {} config_path = getattr(args, "config", None) + + # When --config wasn't supplied, auto-detect argus.yml the same way + # ``argus scan`` (source) does. Source scans have always done this; + # the container subcommand used to require an explicit --config, + # which made config-driven container scans feel inconsistent with + # the rest of the CLI. Search the project root for the canonical + # filenames; if none exist, fall through with no config (CLI flags + # alone may still supply targets). + if not config_path: + from argus.core.config import _DEFAULT_CONFIG_NAMES + for candidate in _DEFAULT_CONFIG_NAMES: + if Path(candidate).is_file(): + config_path = candidate + break + if config_path: try: import yaml diff --git a/argus/core/schema.py b/argus/core/schema.py index e66e965f..7b247a4f 100644 --- a/argus/core/schema.py +++ b/argus/core/schema.py @@ -39,6 +39,15 @@ # Known execution keys _EXECUTION_KEYS = {"backend", "registry", "pull_policy"} +# Top-level containers block keys +_CONTAINERS_KEYS = {"images", "discover", "search_paths", "scanners"} + +# Per-image entry keys (under containers.images[*]) +_CONTAINER_IMAGE_KEYS = {"image", "dockerfile", "context", "name"} + +# Sub-scanners argus scan container can dispatch to +_CONTAINER_SUB_SCANNERS = {"trivy", "grype", "syft"} + class ConfigError: """A single configuration issue.""" @@ -103,6 +112,11 @@ def validate_config(data: dict) -> list[ConfigError]: if execution is not None: errors.extend(_validate_execution("execution", execution)) + # Containers (top-level lifecycle targets for ``argus scan container``) + containers = data.get("containers") + if containers is not None: + errors.extend(_validate_containers("containers", containers)) + return errors @@ -240,6 +254,133 @@ def _validate_execution(path: str, data: Any) -> list[ConfigError]: return errors +def _validate_containers(path: str, data: Any) -> list[ConfigError]: + """Validate the top-level ``containers:`` block. + + Catches the common authoring mistakes that previously only surfaced + at scan time (or got silently ignored): typo'd image-entry keys, + discover without search_paths, an empty images list, sub-scanner + names that aren't trivy/grype/syft, and image entries that name + neither a registry ref nor a Dockerfile. + """ + errors: list[ConfigError] = [] + + if not isinstance(data, dict): + errors.append(ConfigError( + path, f"Must be a mapping, got {type(data).__name__}", + )) + return errors + + # Unknown keys + for key in data: + if key not in _CONTAINERS_KEYS: + errors.append(ConfigError( + f"{path}.{key}", + f"Unknown containers key '{key}'. " + f"Valid keys: {', '.join(sorted(_CONTAINERS_KEYS))}", + level="warning", + )) + + images = data.get("images") + discover = data.get("discover", False) + + # At least one source of targets must be configured. + if not images and not discover: + errors.append(ConfigError( + path, + "containers: must declare at least one of `images:` (a list) " + "or `discover: true` — otherwise `argus scan container --config` " + "has no targets to scan.", + )) + + # images: list of mappings + if images is not None: + if not isinstance(images, list): + errors.append(ConfigError( + f"{path}.images", "Must be a list of image entries", + )) + elif len(images) == 0: + errors.append(ConfigError( + f"{path}.images", + "Empty images list — drop the key entirely or add at least one entry.", + level="warning", + )) + else: + for i, entry in enumerate(images): + errors.extend( + _validate_container_image_entry(f"{path}.images[{i}]", entry) + ) + + # discover requires search_paths (or defaults to ["."]) + if "search_paths" in data: + sp = data["search_paths"] + if not isinstance(sp, list) or not all(isinstance(p, str) for p in sp): + errors.append(ConfigError( + f"{path}.search_paths", + "Must be a list of path strings", + )) + + # scanners: must be a list of valid sub-scanner names + if "scanners" in data: + sc = data["scanners"] + if not isinstance(sc, list): + errors.append(ConfigError( + f"{path}.scanners", + f"Must be a list. Valid values: " + f"{', '.join(sorted(_CONTAINER_SUB_SCANNERS))}", + )) + else: + for i, s in enumerate(sc): + if s not in _CONTAINER_SUB_SCANNERS: + errors.append(ConfigError( + f"{path}.scanners[{i}]", + f"Unknown container sub-scanner '{s}'. " + f"Valid values: {', '.join(sorted(_CONTAINER_SUB_SCANNERS))}", + )) + + return errors + + +def _validate_container_image_entry(path: str, entry: Any) -> list[ConfigError]: + """Validate a single ``containers.images[*]`` entry.""" + errors: list[ConfigError] = [] + + if not isinstance(entry, dict): + errors.append(ConfigError( + path, + f"Must be a mapping with at least an 'image:' field, " + f"got {type(entry).__name__}", + )) + return errors + + # Unknown keys + for key in entry: + if key not in _CONTAINER_IMAGE_KEYS: + errors.append(ConfigError( + f"{path}.{key}", + f"Unknown image-entry key '{key}'. " + f"Valid keys: {', '.join(sorted(_CONTAINER_IMAGE_KEYS))}", + level="warning", + )) + + # An entry must declare either an image ref or a dockerfile to build. + if "image" not in entry and "dockerfile" not in entry: + errors.append(ConfigError( + path, + "Image entry must have either 'image:' (registry reference) " + "or 'dockerfile:' (build-then-scan) set.", + )) + + # Type checks for present fields + for field in ("image", "dockerfile", "context", "name"): + if field in entry and not isinstance(entry[field], str): + errors.append(ConfigError( + f"{path}.{field}", "Must be a string", + )) + + return errors + + def report_validation(errors: list[ConfigError]) -> bool: """Log validation errors/warnings and return True if config is valid. diff --git a/argus/tests/core/test_schema_containers.py b/argus/tests/core/test_schema_containers.py new file mode 100644 index 00000000..a3a96f1d --- /dev/null +++ b/argus/tests/core/test_schema_containers.py @@ -0,0 +1,172 @@ +"""Tests for validate_config's ``containers:`` block validation. + +Locks in the contract: typo'd image-entry keys, missing image/dockerfile +fields, empty images list, and invalid sub-scanner names all surface +during ``argus validate`` instead of failing silently at scan time. +""" + +import pytest + +from argus.core.schema import validate_config + + +def _errors(data: dict) -> list: + """Run validation and return only fatal errors (drops warnings).""" + return [e for e in validate_config(data) if e.level == "error"] + + +def _warnings(data: dict) -> list: + return [e for e in validate_config(data) if e.level == "warning"] + + +def _has_error_at(errors, path_substr: str, msg_substr: str = "") -> bool: + return any( + path_substr in e.path and msg_substr in e.message + for e in errors + ) + + +# --------------------------------------------------------------------- # +# Happy paths # +# --------------------------------------------------------------------- # + + +class TestContainersValid: + def test_minimal_image_entry(self): + cfg = {"containers": {"images": [{"image": "nginx:latest"}]}} + assert _errors(cfg) == [] + + def test_dockerfile_entry(self): + cfg = { + "containers": { + "images": [ + { + "image": "myapp:dev", + "dockerfile": "docker/Dockerfile", + "context": ".", + } + ] + } + } + assert _errors(cfg) == [] + + def test_discover_only(self): + cfg = {"containers": {"discover": True, "search_paths": ["docker/"]}} + assert _errors(cfg) == [] + + def test_scanners_subset(self): + cfg = { + "containers": { + "images": [{"image": "x:1"}], + "scanners": ["trivy", "grype"], + } + } + assert _errors(cfg) == [] + + +# --------------------------------------------------------------------- # +# Structural errors # +# --------------------------------------------------------------------- # + + +class TestContainersStructuralErrors: + def test_not_a_mapping(self): + cfg = {"containers": ["nginx:latest"]} + errors = _errors(cfg) + assert _has_error_at(errors, "containers", "Must be a mapping") + + def test_no_targets_at_all(self): + # No images, no discover — nothing for ``argus scan container`` to do. + cfg = {"containers": {}} + errors = _errors(cfg) + assert _has_error_at(errors, "containers", "at least one") + + def test_empty_images_list_is_warning(self): + cfg = {"containers": {"images": [], "discover": True}} + warnings = _warnings(cfg) + assert any("images" in w.path and "Empty" in w.message for w in warnings) + + def test_images_must_be_list(self): + cfg = {"containers": {"images": {"image": "x:1"}}} + errors = _errors(cfg) + assert _has_error_at(errors, "containers.images", "Must be a list") + + +# --------------------------------------------------------------------- # +# Image-entry validation # +# --------------------------------------------------------------------- # + + +class TestImageEntryErrors: + def test_image_entry_missing_image_and_dockerfile(self): + cfg = {"containers": {"images": [{"context": "."}]}} + errors = _errors(cfg) + assert _has_error_at(errors, "containers.images[0]", "must have either") + assert _has_error_at(errors, "containers.images[0]", "dockerfile") + + def test_unknown_image_entry_key_is_warning(self): + cfg = { + "containers": { + "images": [ + {"image": "x:1", "registry_url": "ghcr.io"} # not a real key + ] + } + } + warnings = _warnings(cfg) + assert any("registry_url" in w.path for w in warnings) + + def test_image_field_must_be_string(self): + cfg = {"containers": {"images": [{"image": 42}]}} + errors = _errors(cfg) + assert _has_error_at(errors, "containers.images[0].image", "Must be a string") + + def test_image_entry_must_be_mapping(self): + cfg = {"containers": {"images": ["nginx:latest"]}} # bare string, not dict + errors = _errors(cfg) + assert _has_error_at( + errors, "containers.images[0]", "Must be a mapping", + ) + + +# --------------------------------------------------------------------- # +# Sub-scanner whitelist # +# --------------------------------------------------------------------- # + + +class TestSubScannerValidation: + def test_invalid_subscanner_name(self): + cfg = { + "containers": { + "images": [{"image": "x:1"}], + "scanners": ["trivy", "snyk"], # snyk isn't supported + } + } + errors = _errors(cfg) + assert _has_error_at(errors, "containers.scanners[1]", "Unknown") + + def test_scanners_must_be_list(self): + cfg = { + "containers": { + "images": [{"image": "x:1"}], + "scanners": "trivy,grype", # comma-string, not a YAML list + } + } + errors = _errors(cfg) + assert _has_error_at(errors, "containers.scanners", "Must be a list") + + +# --------------------------------------------------------------------- # +# Unknown top-level containers key # +# --------------------------------------------------------------------- # + + +class TestUnknownKeys: + def test_unknown_top_level_key_is_warning(self): + cfg = { + "containers": { + "images": [{"image": "x:1"}], + "matrix": {"strategy": "fail-fast"}, # not a real key + } + } + warnings = _warnings(cfg) + assert any("matrix" in w.path for w in warnings) diff --git a/argus/tests/test_cli.py b/argus/tests/test_cli.py index 62b4a8c7..7bd2b735 100644 --- a/argus/tests/test_cli.py +++ b/argus/tests/test_cli.py @@ -398,6 +398,32 @@ def test_container_lifecycle_yaml_parse_error_is_caught(self, tmp_path): _load_container_config(args) assert "YAML parse error" in str(excinfo.value) + def test_container_lifecycle_auto_loads_argus_yml_without_config_flag( + self, tmp_path, monkeypatch, + ): + """``argus scan container`` with NO --config should pick up the + nearest argus.yml automatically — same UX as ``argus scan``. + + Previously the container dispatcher required ``--config FILE`` + explicitly, which made config-driven scans feel inconsistent + with the source-scan flow that has always auto-detected + argus.yml at the project root. + """ + from argus.cli import _container_config_has_targets, _load_container_config + + config_file = tmp_path / "argus.yml" + config_file.write_text( + "containers:\n images:\n - image: nginx:1.27\n" + ) + # Run from inside tmp_path so argus.yml resolves relative to cwd — + # no --config flag, no --image, no --discover. + monkeypatch.chdir(tmp_path) + args = _make_scan_args(scanner="container") + + loaded = _load_container_config(args) + assert _container_config_has_targets(loaded) is True + assert loaded["images"] == [{"image": "nginx:1.27"}] + def test_scan_source_always_emits_canonical_json(self, monkeypatch, tmp_path): """Regression for Option C: argus-results.json must be written regardless of the user's ``reporting.formats``. Captures the From 538bbc516aa2aa371c2b1088a54e32ef23618787 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Tue, 5 May 2026 22:36:01 -0400 Subject: [PATCH 2/2] feat(validate): surface containers block in success summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validate-success summary listed scanners, formats, and backend but ignored the top-level containers block entirely. With containers silently absent from the summary the user had no signal that argus validate had inspected it — a typo at the block name (e.g. ``containerz:``) would still show up as a top-level-key warning, but a correctly-named block with valid contents looked identical to no block at all. Add a Containers line that shows: Containers: 4 image(s) - ghcr.io/myorg/scanner-bandit:dev - ghcr.io/myorg/scanner-opengrep:dev ... Containers: discover from docker/, . Containers: 2 image(s) + discover from docker/ The line is only printed when the block is structurally a mapping; if no containers block exists, the validator stays silent (matching the optional nature of the block). Three new regression tests in TestCmdValidate cover the present, absent, and discover variants. --- argus/cli.py | 25 +++++++++++++ argus/tests/test_cli.py | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/argus/cli.py b/argus/cli.py index e97bfd8d..e4c2378f 100644 --- a/argus/cli.py +++ b/argus/cli.py @@ -2609,6 +2609,31 @@ def cmd_validate(args: argparse.Namespace) -> int: backend = data.get("execution", {}).get("backend", "auto") print(f" Backend: {backend}") + # Containers: only printed when the block exists and is structurally + # sound (validate already surfaced any errors above). The line gives + # the user a "yes, your containers config was inspected" signal that + # was missing — without it, a typo'd top-level key like ``containerz`` + # used to fail silently here too. + containers = data.get("containers") + if isinstance(containers, dict): + images = containers.get("images") or [] + discover = containers.get("discover", False) + search_paths = containers.get("search_paths") or [] + parts = [] + if isinstance(images, list) and images: + parts.append(f"{len(images)} image(s)") + if discover: + paths_str = ", ".join(search_paths) if search_paths else "." + parts.append(f"discover from {paths_str}") + summary = " + ".join(parts) if parts else "no targets" + print(f" Containers: {summary}") + if isinstance(images, list) and images: + for entry in images: + if not isinstance(entry, dict): + continue + ref = entry.get("image") or entry.get("dockerfile") or "" + print(f" - {ref}") + # Tool readiness check unavailable = [] tool_statuses = [] diff --git a/argus/tests/test_cli.py b/argus/tests/test_cli.py index 7bd2b735..0157d8e0 100644 --- a/argus/tests/test_cli.py +++ b/argus/tests/test_cli.py @@ -872,6 +872,85 @@ def test_validate_strict_fails_on_warnings(self, tmp_path, capsys): captured = capsys.readouterr() assert "strict" in captured.out.lower() or "warning" in captured.out.lower() + def test_validate_summary_lists_containers_when_block_present( + self, tmp_path, capsys, + ): + """The summary should surface configured container targets so + the user knows the ``containers:`` block was inspected.""" + config_file = tmp_path / "argus.yml" + config_file.write_text( + "scanners:\n" + " bandit:\n" + " enabled: true\n" + "containers:\n" + " images:\n" + " - image: ghcr.io/myorg/app:1.0\n" + " - image: myorg/inhouse:dev\n" + " dockerfile: docker/Dockerfile\n" + ) + args = argparse.Namespace( + config=str(config_file), + check_tools=False, + strict=False, + ) + result = cmd_validate(args) + + assert result == EXIT_SUCCESS + out = capsys.readouterr().out + assert "Containers: 2 image(s)" in out + assert "ghcr.io/myorg/app:1.0" in out + assert "myorg/inhouse:dev" in out + + def test_validate_summary_omits_containers_line_when_block_absent( + self, tmp_path, capsys, + ): + """When no ``containers:`` block exists, the summary should not + mention containers at all — the block is optional and silence + is the right signal.""" + config_file = tmp_path / "argus.yml" + config_file.write_text( + "scanners:\n" + " bandit:\n" + " enabled: true\n" + ) + args = argparse.Namespace( + config=str(config_file), + check_tools=False, + strict=False, + ) + result = cmd_validate(args) + + assert result == EXIT_SUCCESS + out = capsys.readouterr().out + assert "Containers:" not in out + + def test_validate_summary_shows_discover_when_set(self, tmp_path, capsys): + """``discover: true`` should surface in the summary alongside + any configured search_paths.""" + config_file = tmp_path / "argus.yml" + config_file.write_text( + "scanners:\n" + " bandit:\n" + " enabled: true\n" + "containers:\n" + " discover: true\n" + " search_paths:\n" + " - docker/\n" + " - .\n" + ) + args = argparse.Namespace( + config=str(config_file), + check_tools=False, + strict=False, + ) + result = cmd_validate(args) + + assert result == EXIT_SUCCESS + out = capsys.readouterr().out + assert "Containers:" in out + assert "discover from" in out + assert "docker/" in out + class TestCmdReport: """Integration tests for cmd_report."""