Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .ai/architecture.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <format> --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)"
Expand Down
6 changes: 4 additions & 2 deletions .ai/context.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <format> — 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:
Expand Down
67 changes: 50 additions & 17 deletions .github/workflows/build-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions argus/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2594,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 "<unknown>"
print(f" - {ref}")

# Tool readiness check
unavailable = []
tool_statuses = []
Expand Down
141 changes: 141 additions & 0 deletions argus/core/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading