diff --git a/.github/workflows/cve-scan.yml b/.github/workflows/cve-scan.yml new file mode 100644 index 00000000..7bc9335f --- /dev/null +++ b/.github/workflows/cve-scan.yml @@ -0,0 +1,135 @@ +# CVE Scan: weekly vulnerability scan + automatic rebuild dispatch. +# +# Two-job design: +# scan - Trivy across all images x published platforms, classifies +# findings, verifies fixes in the base image, reports in the job +# summary, emits fixable/versions outputs. +# rebuild - dispatches valkey-container ci.yml with the exact version lines +# (--field version=) only on verified evidence: distro fix +# published + present in base + absent in published image. +name: CVE Scan + +on: + schedule: + - cron: "0 6 * * 1" # Weekly Monday 06:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: "Print findings without dispatching rebuild" + required: false + type: boolean + default: false + severity_threshold: + description: "Minimum severity to report (manual runs only)" + required: false + type: choice + options: [LOW, MEDIUM, HIGH, CRITICAL] + default: HIGH + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + +# A newer scan run supersedes an older in-flight run. +concurrency: + group: cve-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: CVE scan sweep + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + fixable: ${{ steps.scan.outputs.fixable }} + versions: ${{ steps.scan.outputs.versions }} + steps: + - name: Checkout agent repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Set up agent dependencies + uses: ./.github/actions/setup-agent + + - name: Install Trivy + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 + with: + version: v0.72.0 + + # Needed to execute foreign-arch base images for the per-platform base pre-check. + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Run CVE scan sweep + id: scan + env: + CVE_SCAN_VERSIONS_URL: "https://raw.githubusercontent.com/valkey-io/valkey-container/mainline/versions.json" + CVE_SCAN_REPOSITORY: "valkey/valkey" + CVE_SCAN_INCLUDE_UNSTABLE: "false" + CVE_SCAN_SCANNER: "trivy" + CVE_SCAN_SEVERITY_THRESHOLD: ${{ inputs.severity_threshold || 'HIGH' }} + run: | + set -euo pipefail + args=( + -m scripts.cve_scan.sweep + --repo valkey-io/valkey-container + --verbose + ) + + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + args+=(--dry-run) + fi + + python "${args[@]}" + + rebuild: + name: Dispatch container rebuild + needs: scan + if: > + needs.scan.outputs.fixable == 'true' && + needs.scan.outputs.versions != '' && + (github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Generate Valkeyrie Bot token + id: token + if: github.repository_owner == 'valkey-io' + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.VALKEYRIE_BOT_APP_ID }} + private-key: ${{ secrets.VALKEYRIE_BOT_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: valkey-container + permission-actions: write + permission-metadata: read + + - name: Dispatch rebuild workflow + id: dispatch + env: + GH_TOKEN: ${{ github.repository_owner == 'valkey-io' && steps.token.outputs.token || secrets.AUTOMATION_PAT }} + VERSIONS: ${{ needs.scan.outputs.versions }} + run: | + set -euo pipefail + echo "Dispatching rebuild for versions: ${VERSIONS}" + gh workflow run ci.yml \ + --repo valkey-io/valkey-container \ + --field "version=${VERSIONS}" + + - name: Job summary + if: always() + env: + VERSIONS: ${{ needs.scan.outputs.versions }} + run: | + set -euo pipefail + echo "## Rebuild Dispatch" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ "${{ steps.dispatch.outcome }}" == "success" ]]; then + echo "Dispatched \`ci.yml\` on \`valkey-io/valkey-container\` for versions: \`${VERSIONS}\`" >> "$GITHUB_STEP_SUMMARY" + else + echo "**Failed** to dispatch \`ci.yml\` on \`valkey-io/valkey-container\` for versions: \`${VERSIONS}\` (outcome: ${{ steps.dispatch.outcome }})" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md index 1798bd99..42e52501 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ scripts/ fuzzer/ Fuzzer run monitoring (active) ci_fix/ On-demand CI test-fix bot (active) release_notes/ Release cutter: AI notes + version bump (active) + cve_scan/ CVE scanning + automatic base-verified rebuild dispatch (active) common/ Shared infrastructure (git auth, GitHub client, safety guards) .github/actions/setup-agent Shared workflow setup for Python deps and optional Claude Code @@ -30,6 +31,7 @@ New workflows are added as sibling directories to `backport/`. Each workflow pic | CI Fix | Active | On-demand `@valkeyrie-bot fix ` - diagnoses and fixes a failing test on a backport PR | | Test Failure Detector | Active | Detects test failures from Daily CI, files/updates GitHub issues | | Release Notes | Active | Cuts a release: AI-generates notes from `release-notes` PRs plus AI-triaged candidates without that label, promotes them onto a release line branch, bumps `src/version.h`, opens a PR (held as a draft when the cut flags issues) | +| CVE Scan | Active | Scans container images for vulnerabilities, dispatches rebuilds automatically when fixes are verified present in the base image | | PR Reviewer | Planned | Two-stage code review with skeptic pass | | Additional Daily CI Analysis | Planned | Detects flaky tests, generates fix PRs | @@ -508,12 +510,102 @@ cut, so an ordinary cut is never blocked when the App installation lacks it. The App installation must hold `repository-advisories:read` for an advisory cut to read the advisories. +## CVE Scan Workflow + +Scans published container images for vulnerabilities across all published platforms, classifies which CVEs are fixable by a plain rebuild, verifies fixes are actually present in the upstream base image using native dpkg/apk comparison semantics, and dispatches targeted rebuilds automatically when confirmed. The trigger condition is verified evidence, consistent with valkey-container's publishing model (daily unstable cron, versions.json merges). All findings (confirmed-fixable and not-fixable) are reported in the GitHub Actions job summary. No GitHub issues are created. + +This is Phase 1: a concrete implementation targeting `valkey-io/valkey-container`. Phase 2 (reusable `workflow_call` extraction for other repos) is planned. + +### How it works + +A single workflow (`.github/workflows/cve-scan.yml`) with two jobs: + +**Job 1: `scan`** (weekly cron + manual `workflow_dispatch`) + +1. **Install scanner**: sets up Trivy on the runner. +2. **Resolve image matrix**: in dynamic mode, `image_matrix.py` fetches the upstream `versions.json` manifest and derives the full set of image tags to scan. It also builds an image-to-base mapping (e.g. `valkey/valkey:9.1-alpine` to `alpine:3.23`, `valkey/valkey:9.1` to `debian:trixie-slim`) used by the base pre-check. +3. **Scan (multi-arch)**: `sweep.py` runs Trivy per image per platform via `scanner.py`. Each image is scanned on all 4 published platforms (linux/amd64, linux/arm64, linux/arm/v7, linux/ppc64le). Findings are deduplicated by (image, package, cve_id, installed_version, platform): exact duplicates within a platform collapse, while the same CVE on different platforms stays distinct for per-platform base verification. +4. **Classify**: `rebuild_decider.py` marks each finding with a published fix as a rebuild candidate; findings with no fix are not fixable. Candidacy trusts Trivy's distro-aware matching (Trivy only reports a finding when the installed version is below the fix). The authoritative version comparison happens in the base pre-check. +5. **Base pre-check** (dynamic mode only): `base_precheck.py` reads the base image package database (apk for Alpine, dpkg for Debian) and compares installed package versions against the fix. This is the authoritative version comparison, using native dpkg/apk tools via docker for correct Debian/Alpine ordering. Any comparison error is fail-closed (downgraded). Findings where the base image has not yet been republished with the fix are downgraded to not-fixable, avoiding no-op rebuilds. +6. **Report findings**: all findings (confirmed-fixable and not-fixable) are rendered in the GitHub Actions job summary as grouped markdown tables. No GitHub issues are created. +7. **Emit outputs**: writes `fixable` (true/false) and `versions` (space-separated version lines, e.g. `8.0 9.1`) to `GITHUB_OUTPUT` for the downstream job. The `versions` output is derived from confirmed-fixable image tags (e.g. `valkey/valkey:8.0-alpine` and `valkey/valkey:8.0` both contribute `8.0`). It is empty when no fixable findings exist. + +**Job 2: `rebuild`** (conditional, automatic) + +1. **Condition**: runs only when the scan job emits `fixable == 'true'` AND `versions != ''` and the run is not a dry run. +2. **Dispatch**: the job mints a scoped Valkeyrie Bot App token (`actions:write` on valkey-container) and dispatches the rebuild workflow (`gh workflow run ci.yml --repo valkey-io/valkey-container --field version=""`), where `` is the space-separated list of version lines from the `versions` output (e.g. `8.0 9.1`). This targets only the affected versions rather than rebuilding everything. + +The trigger condition is verified evidence: the distro published a fix, the base pre-check confirmed the fix is present in the current base image tag using native package-manager comparison, and the published container image still lacks it. This is consistent with valkey-container's publishing model, which builds and publishes on cron and on versions.json merges. + +A concurrency group (`cve-scan-${{ github.ref }}`, cancel-in-progress) ensures stale in-flight runs are superseded by newer scans. + +### Installation + +#### Prerequisites + +- The **Valkeyrie Bot GitHub App** installed on the target repository with: + - `actions: write` (dispatch the rebuild workflow) + - `contents: read`, `metadata: read` +- Org-level secrets: `VALKEYRIE_BOT_APP_ID` and `VALKEYRIE_BOT_PRIVATE_KEY` + +#### Step 1: Configure secrets + +On the repo hosting the agent workflows: + +| Type | Name | Value | +|------|------|-------| +| Secret | `VALKEYRIE_BOT_APP_ID` | Valkeyrie Bot GitHub App ID | +| Secret | `VALKEYRIE_BOT_PRIVATE_KEY` | App private key | + +For forks without org secrets, the workflow falls back to `AUTOMATION_PAT`. + +### Configuration + +Settings are loaded from `CVE_SCAN_*` environment variables with sensible defaults +targeting `valkey-io/valkey-container`. The workflow pins all values explicitly in +its `env:` block (house style: visible-in-workflow configuration). Override any +variable to change behavior for forks or testing. + +| Variable | Default | Description | +|----------|---------|-------------| +| `CVE_SCAN_VERSIONS_URL` | `https://raw.githubusercontent.com/valkey-io/valkey-container/mainline/versions.json` | URL to the versions.json manifest for dynamic image resolution | +| `CVE_SCAN_REPOSITORY` | `valkey/valkey` | Docker Hub repository prefix for derived image tags | +| `CVE_SCAN_INCLUDE_UNSTABLE` | `false` | Include the `unstable` version line (truthy: `1`, `true`, `yes`, `on`; falsy: `0`, `false`, `no`, `off`, empty) | +| `CVE_SCAN_SCANNER` | `trivy` | Vulnerability scanner (trivy only; env var kept for forward compatibility) | +| `CVE_SCAN_SEVERITY_THRESHOLD` | `HIGH` | Ignore findings below this severity (`UNKNOWN`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`). | +| `CVE_SCAN_IMAGES` | *(empty)* | Optional static image list (comma-separated). When set, overrides dynamic resolution from versions.json. Testing/escape hatch. | +| `CVE_SCAN_PLATFORMS` | `linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le` | Comma-separated platforms to scan per image. Defaults to the verified published set for valkey images. | + +Invalid values (unknown scanner, bad severity, empty labels) raise immediately: +a typo must not silently scan nothing. + +### Usage + +#### Weekly scan (automatic) + +Runs weekly on the configured schedule (default: Monday 06:00 UTC). Scans all images in the matrix on all configured platforms, reports findings in the job summary, and dispatches a targeted rebuild automatically if confirmed-fixable CVEs are found. + +#### Manual scan + +```bash +gh workflow run cve-scan.yml --repo +``` + +Supports a `dry_run` input that prints findings without dispatching a rebuild. Supports a `severity_threshold` input for ad-hoc investigation. + +#### Reviewing results + +After each scan, check the workflow run's job summary in GitHub Actions. The summary lists all findings (confirmed-fixable and not-fixable) as grouped markdown tables. Confirmed-fixable findings trigger a targeted rebuild automatically. + ## Safety - **Branch namespace** - the agent writes only `agent/backport/...` (backports) and `agent/release-cut/...` (release cuts) branches and opens PRs for maintainer review. It never force-pushes a release line directly. - **Credential isolation** - all GitHub auth uses `GIT_ASKPASS`; tokens never appear in `.git/config` or URLs - **Claude Code env isolation** - `GITHUB_TOKEN`, `GH_TOKEN`, and `*_SECRET` are stripped from the subprocess environment. Claude cannot see credentials. - **Deterministic validation** - registry-configured build commands run before push. A validation failure blocks the push. +- **CVE scan: verified-evidence trigger** - rebuild dispatch requires three conditions to be true simultaneously: (1) the distro published a fix for the CVE, (2) the base-image pre-check confirms the patched package is present in the current base tag using native dpkg/apk comparison tools, and (3) the published container image still carries the vulnerable version. If any condition is ambiguous or unverifiable, the finding is downgraded (fail-closed). The workflow run log is the audit record for every automatic dispatch. +- **CVE scan: targeted dispatch** - rebuilds are dispatched with `--field version=""` for only the affected version lines (e.g. `8.0 9.1`), not a rebuild-all. This minimizes the blast radius of automatic rebuilds. +- **CVE scan: no AI in the pipeline** - the entire scan-classify-precheck-dispatch path is deterministic code (scanner, classifier, base pre-check, `gh workflow run`). No AI layer participates in any decision or dispatch step. - **Fork sync** - when a different-owner `push_repo` is configured, the agent fast-forwards that fork's release branch to match upstream before cherry-picking - **Stale branch pruning** - if a previous backport PR was closed without merging, the agent deletes the orphaned branch before starting fresh - **DCO** - backport commits are signed off. ci_fix commits are authored by the bot without a sign-off, so a human certifies the change before merge. diff --git a/docs/architecture.md b/docs/architecture.md index 08554eb6..4974039d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ scripts/ test_failure_detector/ Test Failure Detector workflow ci_fix/ CI test-fix bot release_notes/ Release-notes cutter: AI notes + version bump + cve_scan/ CVE scan + automatic base-verified rebuild workflow ai/ Claude Code subprocess orchestration common/ Shared infrastructure repos.yml Registry of repos, release branches, and project boards @@ -380,6 +381,83 @@ calendar date. - `scripts/release_notes/version_bump.py` - `src/version.h` macro rewriting - `scripts/release_notes/contributors.py` - contributor discovery; cumulative rendering reconciles display-name, login, and co-author email aliases +## CVE Scan Flow + +A single workflow (`.github/workflows/cve-scan.yml`) with two jobs: a deterministic scan +that classifies findings, verifies fixes in the base image across all published platforms, +and reports all findings in the job summary, followed by an automatic rebuild +dispatch targeting only the affected version lines when confirmed-fixable findings exist. + +```text +Job 1 - scan (scheduled / workflow_dispatch) +sweep.py + → image_matrix.py + resolve_matrix() fetches versions.json once + derives image tags + image-to-base mapping + (e.g. valkey/valkey:9.1-alpine → alpine:3.23) + → scanner.py (Trivy subprocess per image per platform) + scans each image on each published platform (amd64, arm64, arm/v7, ppc64le) + deduplicates findings by (image, package, cve_id, installed_version) + → rebuild_decider.py (classify: fix published → rebuild candidate; no fix → not fixable) + → base_precheck.py (dynamic mode only) + reads each distinct base image's package database (apk/dpkg) + compares versions using native dpkg/apk tools via docker (authoritative, correct Debian semantics) + downgrades findings where base is still vulnerable (fail-closed) + → summary.py (render grouped findings tables for job summary) + → emit GITHUB_OUTPUT: fixable=true/false, versions= + +Job 2 - rebuild (runs only if fixable == 'true' AND versions != '' AND not dry_run) + → mint Valkeyrie Bot App token (actions:write, scoped to valkey-container) + → gh workflow run ci.yml --repo valkey-io/valkey-container + --field "version=" +``` + +The rebuild dispatches automatically because the trigger condition is verified +evidence: the distro published a fix, the base-image pre-check confirmed the fix is +present in the current base tag using native dpkg/apk comparison semantics, and the +published image still lacks it. This is consistent with the publishing model of +valkey-container, which builds and publishes on cron (daily unstable builds) and on +versions.json merges. + +### Entry Points + +- `scripts/cve_scan/sweep.py`: orchestrates the full pipeline (scan, classify, base pre-check, job summary, output emission) +- `scripts/cve_scan/summary.py`: renders grouped findings tables for the job summary +- `scripts/cve_scan/base_precheck.py`: verifies fixable findings against the actual upstream base image using native package-manager version comparison +- `scripts/cve_scan/version_compare.py`: native dpkg/apk version comparison via docker (used by base_precheck as the safety gate) +- `scripts/cve_scan/image_matrix.py`: resolves image tags and image-to-base mappings from versions.json +- `scripts/cve_scan/scanner.py`: per-image per-platform Trivy invocation with finding deduplication + +### Security Model + +The design relies on verified evidence and deterministic code: + +- **Verified-evidence trigger (fail-closed)**: rebuild dispatch requires three conditions simultaneously: (1) the distro published a fix (fixed_version exists), (2) the base-image pre-check confirms the patched package is present in the current base tag by reading the base image package database and comparing versions using the native dpkg/apk tools (correct Debian/Alpine semantics, fail-closed on any comparison error), and (3) the published container image still carries the vulnerable version. If any condition is ambiguous or unverifiable, the finding is downgraded rather than triggering a rebuild. +- **Targeted version dispatch**: the rebuild job passes `--field version=""` with only the affected version lines (e.g. `8.0 9.1`) rather than rebuilding all images, minimizing blast radius. +- **Multi-arch coverage**: each image is scanned on all 4 published platforms (amd64, arm64, arm/v7, ppc64le). Findings are deduplicated across platforms so a CVE present on all architectures is reported once. Base package versions are assumed arch-uniform per tag (validated indirectly by the multi-arch scan). +- **Deterministic code path**: scanning, classification, base pre-check, and dispatch are all deterministic code with no AI in the loop. The pipeline is stdlib Python plus a scanner subprocess. +- **Least-privilege tokens**: the scan job needs only `contents:read`. The rebuild job mints a separate token scoped to `actions:write` + `metadata:read`. Neither token is broader than required. +- **Audit trail**: every automatic dispatch is recorded in the workflow run log and the job summary. All findings are visible in the run summary as grouped markdown tables. + +This matches valkey-container's existing posture: the same `ci.yml` workflow already runs automatically on cron and on push. The CVE scanner dispatches it through the same path with the same effect, triggered by verified vulnerability evidence rather than a timer. + +### Authentication + +The rebuild job authenticates as the **Valkeyrie Bot GitHub App** by minting a short-lived, repo-scoped installation token (via `actions/create-github-app-token`) with `actions:write` scope. The scan job requires only `contents:read`. A fork-safe fallback uses `AUTOMATION_PAT` when org secrets are unavailable. + +### Idempotency + +The concurrency group (`cancel-in-progress: true`) supersedes stale workflow runs +so only the latest scan result drives the dispatch decision. + +### Configuration + +Settings are loaded from `CVE_SCAN_*` environment variables with sensible defaults +targeting `valkey-io/valkey-container`. The workflow pins all values explicitly in +its `env:` block (house style: visible-in-workflow configuration, matching the +`CI_FIX_*` and `RELEASE_NOTES_*` patterns used by other workflows). Invalid values +raise immediately rather than acting on defaults. + ## Planned Workflows Future sibling modules and extensions: diff --git a/scripts/cve_scan/__init__.py b/scripts/cve_scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/cve_scan/base_precheck.py b/scripts/cve_scan/base_precheck.py new file mode 100644 index 00000000..69b590ef --- /dev/null +++ b/scripts/cve_scan/base_precheck.py @@ -0,0 +1,269 @@ +"""Base-image pre-check for CVE rebuild decisions. + +Verifies a rebuild-fixable CVE is actually patched in the upstream base image +(advisory FixedVersion may precede a republished base tag). Reads each base's +package database via one-shot ``docker run`` and compares with native dpkg/apk +semantics (version_compare), not the pure-Python approximation. Any comparison +error or ambiguity downgrades conservatively (fail-closed). Deterministic, no +AI, stdlib only. +""" + +from __future__ import annotations + +import logging +import subprocess + +from scripts.cve_scan.models import Classification +from scripts.cve_scan.version_compare import compare_versions as _native_compare + +logger = logging.getLogger(__name__) + + +class BasePrecheckError(Exception): + """Raised when the base image package database cannot be read.""" + + +# --------------------------------------------------------------------------- +# Package database readers +# --------------------------------------------------------------------------- + +_DOCKER_TIMEOUT = 300 # seconds + + +def _parse_apk_installed(raw: str) -> dict[str, str]: + """Parse Alpine's /lib/apk/db/installed (blank-line-separated P:/V: stanzas) into {name: version}.""" + packages: dict[str, str] = {} + name: str | None = None + version: str | None = None + + for line in raw.splitlines(): + if line.startswith("P:"): + name = line[2:] + elif line.startswith("V:"): + version = line[2:] + elif line == "": + # End of stanza + if name is not None and version is not None: + packages[name] = version + name = None + version = None + + # Handle last stanza if file does not end with blank line + if name is not None and version is not None: + packages[name] = version + + return packages + + +def _parse_dpkg_query(raw: str) -> dict[str, str]: + """Parse ' ' lines from dpkg-query -W output.""" + packages: dict[str, str] = {} + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(" ", 1) + if len(parts) == 2: + packages[parts[0]] = parts[1] + return packages + + +def get_base_packages(base_ref: str, platform: str = "") -> dict[str, str]: + """Read the base image's package database via one-shot docker run. + + Args: + base_ref: Base image reference (e.g. "alpine:3.23", "debian:trixie-slim"). + platform: Optional platform passed as ``--platform`` to docker run. + + Returns: + Mapping of package name to installed version string. + + Raises: + BasePrecheckError: On unknown base flavor, docker failure, or empty output. + """ + if base_ref.startswith("alpine:"): + cmd = ["docker", "run"] + if platform: + cmd.extend(["--platform", platform]) + cmd.extend(["--rm", base_ref, "cat", "/lib/apk/db/installed"]) + parser = _parse_apk_installed + elif base_ref.startswith("debian:"): + cmd = ["docker", "run"] + if platform: + cmd.extend(["--platform", platform]) + cmd.extend([ + "--rm", base_ref, + "dpkg-query", "-W", "-f", "${Package} ${Version}\n", + ]) + parser = _parse_dpkg_query + else: + raise BasePrecheckError( + f"Unknown base image flavor: {base_ref!r}. " + f"Expected prefix 'alpine:' or 'debian:'." + ) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=_DOCKER_TIMEOUT, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise BasePrecheckError( + f"Timed out reading package database from {base_ref} " + f"(timeout={_DOCKER_TIMEOUT}s)." + ) from exc + + if result.returncode != 0: + raise BasePrecheckError( + f"docker run failed for {base_ref} (exit {result.returncode}): " + f"{result.stderr.strip()}" + ) + + if not result.stdout.strip(): + raise BasePrecheckError( + f"Empty package database output from {base_ref}." + ) + + return parser(result.stdout) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def verify_fixable_in_base( + fixable: list[Classification], + base_map: dict[str, str], +) -> tuple[list[Classification], list[Classification]]: + """Verify fixable findings against their base images' package databases. + + Reads each distinct (base image, platform) package list once (cached per + invocation). Findings where the base is still vulnerable or comparison is + ambiguous are downgraded (fail-closed). + + Args: + fixable: Classifications previously marked as rebuild-fixable. + base_map: Mapping of derived image -> base image reference. + + Returns: + Tuple of (confirmed, downgraded) classification lists. + + Raises: + BasePrecheckError: If a base image package database cannot be read. + """ + if not fixable: + return [], [] + + # Cache: (base_ref, platform) -> {package: version} + base_pkg_cache: dict[tuple[str, str], dict[str, str]] = {} + + confirmed: list[Classification] = [] + downgraded: list[Classification] = [] + + for classification in fixable: + finding = classification.finding + base_ref = base_map.get(finding.image) + + if base_ref is None: + logger.warning( + "No base image mapping for %s; downgrading %s/%s conservatively.", + finding.image, + finding.cve_id, + finding.package, + ) + downgraded.append(Classification( + finding=finding, + fixable=False, + rationale=( + f"No base image mapping for {finding.image}; cannot verify " + f"fix presence. Downgrading conservatively (fail-closed)." + ), + )) + continue + + # Read base package database (cached per base_ref + platform) + cache_key = (base_ref, finding.platform) + if cache_key not in base_pkg_cache: + logger.info( + "Reading base image package database: %s (platform=%s) ...", + base_ref, finding.platform or "native", + ) + base_pkg_cache[cache_key] = get_base_packages(base_ref, platform=finding.platform) + + base_packages = base_pkg_cache[cache_key] + + base_version = base_packages.get(finding.package) + + if base_version is None: + # Package not in base image (installed at build time) + rationale = ( + f"{classification.rationale} " + f"Verified: package {finding.package} not in base image " + f"{base_ref} (installed at build time from the package " + f"repository); rebuild will fetch the latest version." + ) + confirmed.append(Classification( + finding=finding, + fixable=True, + rationale=rationale, + )) + continue + + # Native dpkg/apk comparison; None -> fail-closed (downgrade) + if finding.fixed_version is None: + # Should not happen for fixable findings, but be safe + confirmed.append(classification) + continue + + if base_ref.startswith("alpine:"): + flavor = "alpine" + elif base_ref.startswith("debian:"): + flavor = "debian" + else: + flavor = "debian" # unknown flavor: conservative default + + cmp = _native_compare(base_version, finding.fixed_version, flavor, base_ref) + + if cmp is None: + # Ambiguous comparison: fail closed + rationale = ( + f"Fix for {finding.cve_id} in {finding.package}: version " + f"comparison between base version {base_version} and fix " + f"version {finding.fixed_version} is ambiguous. " + f"Downgrading conservatively. Re-check next scan." + ) + downgraded.append(Classification( + finding=finding, + fixable=False, + rationale=rationale, + )) + elif cmp < 0: + # Base still ships an older version: stale base + rationale = ( + f"Fix for {finding.cve_id} in {finding.package} is published " + f"upstream but base image {base_ref} still ships " + f"{base_version} (< {finding.fixed_version}); a rebuild " + f"would not pick it up. Re-check next scan." + ) + downgraded.append(Classification( + finding=finding, + fixable=False, + rationale=rationale, + )) + else: + # Base ships the fix + rationale = ( + f"{classification.rationale} " + f"Verified: base {base_ref} ships {base_version}." + ) + confirmed.append(Classification( + finding=finding, + fixable=True, + rationale=rationale, + )) + + return confirmed, downgraded diff --git a/scripts/cve_scan/config.py b/scripts/cve_scan/config.py new file mode 100644 index 00000000..dbcccfff --- /dev/null +++ b/scripts/cve_scan/config.py @@ -0,0 +1,110 @@ +"""Settings loader for the CVE scan workflow. + +Driven by CVE_SCAN_* env vars with defaults (repo house style; no config +file since valkey-container is the only target). Invalid values raise +immediately: an env typo must not silently scan nothing. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from scripts.cve_scan.models import Severity + +_VALID_SCANNERS = frozenset({"trivy"}) +_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) +_FALSY_STRINGS = frozenset({"0", "false", "no", "off", ""}) + +_DEFAULT_VERSIONS_URL = ( + "https://raw.githubusercontent.com/valkey-io/valkey-container" + "/mainline/versions.json" +) +_DEFAULT_REPOSITORY = "valkey/valkey" +_DEFAULT_SCANNER = "trivy" +_DEFAULT_SEVERITY_THRESHOLD = "HIGH" + +# Verified published platforms for valkey images (via buildx imagetools inspect). +# linux/386 is NOT published; linux/ppc64le IS. +DEFAULT_PLATFORMS: list[str] = [ + "linux/amd64", + "linux/arm64", + "linux/arm/v7", + "linux/ppc64le", +] + +_DEFAULT_PLATFORMS_STR = ",".join(DEFAULT_PLATFORMS) + + +class CveScanConfigError(Exception): + """Raised when CVE scan settings are missing or invalid.""" + + +@dataclass(frozen=True) +class CveScanSettings: + """Typed, immutable settings for the CVE scan workflow.""" + + versions_url: str + repository: str + include_unstable: bool + scanner: str + severity_threshold: Severity + images: list[str] = field(default_factory=list) + platforms: list[str] = field(default_factory=list) + + +def load_settings() -> CveScanSettings: + """Build CveScanSettings from CVE_SCAN_* env vars with defaults. + + Raises CveScanConfigError on invalid values. + """ + versions_url = os.environ.get("CVE_SCAN_VERSIONS_URL", _DEFAULT_VERSIONS_URL) + repository = os.environ.get("CVE_SCAN_REPOSITORY", _DEFAULT_REPOSITORY) + + include_unstable_raw = os.environ.get("CVE_SCAN_INCLUDE_UNSTABLE", "false").strip().lower() + if include_unstable_raw in _TRUTHY_STRINGS: + include_unstable = True + elif include_unstable_raw in _FALSY_STRINGS: + include_unstable = False + else: + raise CveScanConfigError( + f"Invalid CVE_SCAN_INCLUDE_UNSTABLE: {include_unstable_raw!r}. " + f"Must be one of (case-insensitive): " + f"truthy {sorted(_TRUTHY_STRINGS)}, falsy {sorted(_FALSY_STRINGS)}" + ) + + scanner = os.environ.get("CVE_SCAN_SCANNER", _DEFAULT_SCANNER).strip().lower() + if scanner not in _VALID_SCANNERS: + raise CveScanConfigError( + f"Invalid CVE_SCAN_SCANNER: {scanner!r}. Must be 'trivy'." + ) + + severity_raw = os.environ.get( + "CVE_SCAN_SEVERITY_THRESHOLD", _DEFAULT_SEVERITY_THRESHOLD + ).strip() + try: + severity_threshold = Severity.from_str(severity_raw) + except ValueError as exc: + raise CveScanConfigError( + f"Invalid CVE_SCAN_SEVERITY_THRESHOLD: {exc}" + ) from exc + + images_raw = os.environ.get("CVE_SCAN_IMAGES", "") + images = [img.strip() for img in images_raw.split(",") if img.strip()] + + platforms_raw = os.environ.get("CVE_SCAN_PLATFORMS", _DEFAULT_PLATFORMS_STR) + platforms = [p.strip() for p in platforms_raw.split(",") if p.strip()] + if not platforms: + raise CveScanConfigError( + "CVE_SCAN_PLATFORMS must contain at least one non-empty platform" + ) + + return CveScanSettings( + versions_url=versions_url, + repository=repository, + include_unstable=include_unstable, + scanner=scanner, + severity_threshold=severity_threshold, + images=images, + platforms=platforms, + ) diff --git a/scripts/cve_scan/image_matrix.py b/scripts/cve_scan/image_matrix.py new file mode 100644 index 00000000..a443b396 --- /dev/null +++ b/scripts/cve_scan/image_matrix.py @@ -0,0 +1,142 @@ +"""Dynamic image matrix resolver for the CVE scan workflow. + +Static override (settings.images) or dynamic derivation from versions.json. +Fail-closed: any fetch, parse, or derivation failure raises rather than +silently falling back to a stale or incomplete list. +""" + +from __future__ import annotations + +import json +import logging +import urllib.request +from typing import TYPE_CHECKING +from urllib.error import URLError + +if TYPE_CHECKING: + from scripts.cve_scan.config import CveScanSettings + +logger = logging.getLogger(__name__) + +#: Default HTTP timeout for fetching the versions manifest (seconds). +_FETCH_TIMEOUT_SECONDS = 15 + + +class MatrixResolutionError(Exception): + """Raised when dynamic image matrix resolution fails.""" + + +def _fetch_versions_json(url: str) -> dict: + """Fetch and parse versions.json from the given URL. + + Raises MatrixResolutionError on network failure, non-200 status, or invalid JSON. + """ + try: + req = urllib.request.Request(url, headers={"User-Agent": "valkey-ci-agent/cve-scan"}) + with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT_SECONDS) as resp: + if resp.status != 200: + raise MatrixResolutionError( + f"Failed to fetch versions manifest: HTTP {resp.status} from {url}" + ) + body = resp.read().decode("utf-8") + except (URLError, OSError, TimeoutError) as exc: + raise MatrixResolutionError( + f"Failed to fetch versions manifest from {url}: {exc}" + ) from exc + + try: + data = json.loads(body) + except json.JSONDecodeError as exc: + raise MatrixResolutionError( + f"Invalid JSON in versions manifest from {url}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise MatrixResolutionError( + f"Versions manifest must be a JSON object, got {type(data).__name__}" + ) + return data + + +def _derive_images( + versions: dict, + repository: str, + include_unstable: bool, +) -> list[str]: + """Derive the sorted image tag list (keys of _derive_base_map, single source of truth). + + Raises MatrixResolutionError if derivation produces an empty list. + """ + images = sorted(_derive_base_map(versions, repository, include_unstable)) + if not images: + raise MatrixResolutionError( + "Dynamic resolution produced zero images from versions manifest" + ) + return images + + +def _derive_base_map( + versions: dict, + repository: str, + include_unstable: bool, +) -> dict[str, str]: + """Map each derived image tag to its base image reference. + + Base conventions (valkey-container Dockerfiles): alpine: for + -alpine tags, debian:-slim otherwise. + """ + base_map: dict[str, str] = {} + for version_key, value in versions.items(): + if version_key == "unstable" and not include_unstable: + continue + if not isinstance(value, dict): + continue + if "alpine" in value: + alpine_ver = value["alpine"].get("version", "") + image_ref = f"{repository}:{version_key}-alpine" + base_map[image_ref] = f"alpine:{alpine_ver}" + if "debian" in value: + debian_ver = value["debian"].get("version", "") + image_ref = f"{repository}:{version_key}" + base_map[image_ref] = f"debian:{debian_ver}-slim" + return base_map + + +def resolve_matrix(settings: CveScanSettings) -> tuple[list[str], dict[str, str]]: + """Resolve the image list and base-image mapping from a single fetch. + + Args: + settings: Loaded CveScanSettings instance. + + Returns: + (images, base_map): sorted image refs and image_ref -> base_ref + mapping (base_map is empty in static override mode). + + Raises: + MatrixResolutionError: On any dynamic resolution failure. + """ + if settings.images: + logger.info( + "Using static image override (%d image(s)): %s", + len(settings.images), + ", ".join(settings.images), + ) + return settings.images, {} + + logger.info( + "Resolving dynamic image matrix from %s (repository=%s, include_unstable=%s)", + settings.versions_url, + settings.repository, + settings.include_unstable, + ) + versions = _fetch_versions_json(settings.versions_url) + base_map = _derive_base_map(versions, settings.repository, settings.include_unstable) + images = sorted(base_map.keys()) + + if not images: + raise MatrixResolutionError( + "Dynamic resolution produced zero images from versions manifest" + ) + + logger.info("Resolved %d image(s): %s", len(images), ", ".join(images)) + return images, base_map diff --git a/scripts/cve_scan/models.py b/scripts/cve_scan/models.py new file mode 100644 index 00000000..5f58769a --- /dev/null +++ b/scripts/cve_scan/models.py @@ -0,0 +1,49 @@ +"""Data models for the CVE scan workflow.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + + +class Severity(IntEnum): + """CVE severity levels, ordered low to high for threshold comparison.""" + + UNKNOWN = 0 + LOW = 1 + MEDIUM = 2 + HIGH = 3 + CRITICAL = 4 + + @classmethod + def from_str(cls, value: str) -> Severity: + """Parse a case-insensitive severity string; raises ValueError on unknown input.""" + try: + return cls[value.upper()] + except KeyError: + raise ValueError(f"Unknown severity: {value!r}. Valid values: {[s.name for s in cls]}") + + +@dataclass +class Finding: + """A single CVE finding from a scanner.""" + + image: str + package: str + installed_version: str + cve_id: str + severity: Severity + fixed_version: str | None + platform: str = "" + + +@dataclass +class Classification: + """Rebuild-fixability classification for a finding.""" + + finding: Finding + fixable: bool + rationale: str + + + diff --git a/scripts/cve_scan/rebuild_decider.py b/scripts/cve_scan/rebuild_decider.py new file mode 100644 index 00000000..18026d61 --- /dev/null +++ b/scripts/cve_scan/rebuild_decider.py @@ -0,0 +1,42 @@ +"""Rebuild-fixability classification for CVE scan findings. + +Two-rule classification: a finding without a published fixed_version is not +fixable; a finding with one is a rebuild CANDIDATE. Candidacy trusts Trivy's +distro-aware matching (Trivy only reports a finding when its matcher +determined the installed version is below the fix). The authoritative +version comparison is the native base pre-check (base_precheck.py, dpkg/apk +via docker). Deterministic: pure code, no network, no subprocess, no AI. +""" + +from __future__ import annotations + +from scripts.cve_scan.models import Classification, Finding + + +def classify(finding: Finding) -> Classification: + """Classify a finding as a rebuild candidate or not fixable. + + Rules: no fixed_version -> not fixable; fixed_version present -> + candidate fixable, pending base pre-check verification. + """ + if not finding.fixed_version: + return Classification( + finding=finding, + fixable=False, + rationale="No upstream fix yet.", + ) + + return Classification( + finding=finding, + fixable=True, + rationale=( + f"Fix {finding.fixed_version} published (Trivy matched installed " + f"{finding.installed_version} as affected); pending base " + f"verification." + ), + ) + + +def classify_all(findings: list[Finding]) -> list[Classification]: + """Classify a list of findings. Returns one Classification per Finding.""" + return [classify(f) for f in findings] diff --git a/scripts/cve_scan/scanner.py b/scripts/cve_scan/scanner.py new file mode 100644 index 00000000..44067c7b --- /dev/null +++ b/scripts/cve_scan/scanner.py @@ -0,0 +1,167 @@ +"""Invoke Trivy as a subprocess and parse findings. + +Each image is scanned per platform (``--platform``); findings are merged and +deduplicated by (image, package, cve_id, installed_version, platform) so +cross-platform findings stay distinct for per-platform base verification. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from typing import Any + +from scripts.cve_scan.config import DEFAULT_PLATFORMS +from scripts.cve_scan.models import Finding, Severity +from scripts.parsers.cve_findings_parser import filter_by_threshold, parse_findings + +logger = logging.getLogger(__name__) + +#: Per-scan subprocess timeout in seconds (cached-DB scans take tens of seconds). +_SCAN_TIMEOUT_SECONDS = 180 + + +class ScanError(Exception): + """Raised when a scanner subprocess fails or produces unparseable output.""" + + +def _build_command(scanner: str, image: str, platform: str | None = None) -> list[str]: + """Build the scanner command as an argument list (no shell interpolation).""" + if scanner == "trivy": + cmd = [ + "trivy", "image", "--format", "json", "--quiet", + "--scanners", "vuln", "--pkg-types", "os", + ] + if platform: + cmd.extend(["--platform", platform]) + cmd.append(image) + return cmd + raise ValueError(f"Unsupported scanner: {scanner!r}. Must be 'trivy'.") + + +def _run_scanner(command: list[str], timeout: int = _SCAN_TIMEOUT_SECONDS) -> dict[str, Any]: + """Run the scanner subprocess and return parsed JSON. + + Raises ScanError on non-zero exit, timeout, or invalid JSON. + """ + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise ScanError( + f"Scanner timed out after {timeout}s: {' '.join(command)}" + ) from exc + except OSError as exc: + raise ScanError( + f"Failed to execute scanner: {' '.join(command)}: {exc}" + ) from exc + + if result.returncode != 0: + stderr_snippet = result.stderr[:500] if result.stderr else "(no stderr)" + raise ScanError( + f"Scanner exited with code {result.returncode}: {' '.join(command)}\n" + f"stderr: {stderr_snippet}" + ) + + if not result.stdout.strip(): + raise ScanError(f"Scanner produced empty output: {' '.join(command)}") + + try: + return json.loads(result.stdout) # type: ignore[no-any-return] + except json.JSONDecodeError as exc: + raise ScanError( + f"Scanner output is not valid JSON: {' '.join(command)}: {exc}" + ) from exc + + +def scan_image(image: str, scanner: str, platform: str | None = None) -> list[Finding]: + """Scan a single image (optionally per platform) and return all findings. + + Args: + image: Container image reference (e.g. "valkey/valkey:8.0-alpine"). + scanner: Scanner to use ("trivy"). + platform: Optional platform (e.g. "linux/amd64") passed as ``--platform``. + + Returns: + List of Finding objects from the scan. + + Raises: + ScanError: If the scanner fails or produces invalid output. + ValueError: If the scanner name is not recognized. + """ + command = _build_command(scanner, image, platform=platform) + json_obj = _run_scanner(command) + return parse_findings(scanner, json_obj, image, platform=platform or "") + + +def _dedup_findings(findings: list[Finding]) -> list[Finding]: + """Collapse same-platform duplicates; keep cross-platform findings distinct. + + First occurrence wins. Cross-platform findings stay separate so base + verification can check each platform's base image independently. + """ + seen: set[tuple[str, str, str, str, str]] = set() + deduped: list[Finding] = [] + for f in findings: + key = (f.image, f.package, f.cve_id, f.installed_version, f.platform) + if key not in seen: + seen.add(key) + deduped.append(f) + return deduped + + +def scan_images( + images: list[str], + scanner: str, + threshold: Severity, + platforms: list[str] | None = None, +) -> list[Finding]: + """Scan multiple images per platform; return deduplicated findings at or above threshold. + + Args: + images: List of container image references to scan. + scanner: Scanner to use ("trivy"). + threshold: Minimum severity level; findings below this are excluded. + platforms: Platforms to scan per image. Defaults to DEFAULT_PLATFORMS. + + Returns: + Combined deduplicated findings from all images and platforms. + + Raises: + ScanError: If any scanner invocation fails. + ValueError: If the scanner name is not recognized. + """ + if platforms is None: + platforms = DEFAULT_PLATFORMS + + all_findings: list[Finding] = [] + total = len(images) + for idx, image in enumerate(images, start=1): + image_findings: list[Finding] = [] + for platform in platforms: + logger.info( + "Scanning image %d/%d: %s (platform=%s)", + idx, total, image, platform, + ) + platform_findings = scan_image(image, scanner, platform=platform) + logger.info( + " %s [%s]: %d finding(s) total", + image, platform, len(platform_findings), + ) + image_findings.extend(platform_findings) + + deduped = _dedup_findings(image_findings) + above = [f for f in deduped if f.severity >= threshold] + logger.info( + " %s: %d unique finding(s) across %d platform(s), %d at or above %s", + image, len(deduped), len(platforms), len(above), threshold.name, + ) + all_findings.extend(deduped) + + return filter_by_threshold(all_findings, threshold) diff --git a/scripts/cve_scan/summary.py b/scripts/cve_scan/summary.py new file mode 100644 index 00000000..363e60c4 --- /dev/null +++ b/scripts/cve_scan/summary.py @@ -0,0 +1,68 @@ +"""Findings table renderer for CVE scan job summaries. + +Renders a grouped one-row-per-CVE markdown table for GitHub Actions job +summaries. Deterministic: no AI, no network, no subprocess. +""" + +from __future__ import annotations + +from scripts.cve_scan.models import Classification + + +def _strip_repo_prefix(image: str) -> str: + """Return the tag part of an image reference ('valkey/valkey:8.0-alpine' -> '8.0-alpine').""" + return image.rsplit(":", 1)[-1] if ":" in image else image + + +def _short_platform(platform: str) -> str: + """Return the short platform name ('linux/arm64' -> 'arm64').""" + return platform.removeprefix("linux/") + + +def render_findings_table( + classifications: list[Classification], +) -> str: + """Render a grouped findings table (markdown) for job summaries. + + Rows grouped by (cve_id, severity, rationale); packages, versions, + images, and platforms aggregated per group. Sorted severity desc, then + CVE ID asc. + + Args: + classifications: List of classifications to render. + + Returns: + Markdown table string. + """ + groups: dict[tuple[str, int, str], list[Classification]] = {} + for c in classifications: + key = (c.finding.cve_id, c.finding.severity.value, c.rationale) + groups.setdefault(key, []).append(c) + + lines: list[str] = [ + "### Findings", + "", + "| CVE | Severity | Packages | Installed | Fixed | Images | Platforms | Rationale |", + "|-----|----------|----------|-----------|-------|--------|-----------|-----------|", + ] + + # Severity descending, then cve_id ascending + for key in sorted(groups, key=lambda k: (-k[1], k[0])): + cve_id, _sev_val, rationale = key + items = groups[key] + severity_name = items[0].finding.severity.name + packages = ", ".join(sorted({c.finding.package for c in items})) + installed = ", ".join(sorted({c.finding.installed_version for c in items})) + fixed_versions = sorted({c.finding.fixed_version or "N/A" for c in items}) + fixed = ", ".join(fixed_versions) + images = ", ".join(sorted({_strip_repo_prefix(c.finding.image) for c in items})) + platforms = ", ".join( + sorted({_short_platform(c.finding.platform) for c in items if c.finding.platform}) + ) or "-" + lines.append( + f"| {cve_id} | {severity_name} | {packages} | {installed} " + f"| {fixed} | {images} | {platforms} | {rationale} |" + ) + + lines.append("") + return "\n".join(lines) diff --git a/scripts/cve_scan/sweep.py b/scripts/cve_scan/sweep.py new file mode 100644 index 00000000..050568e7 --- /dev/null +++ b/scripts/cve_scan/sweep.py @@ -0,0 +1,296 @@ +"""CVE scan sweep: scheduled scan + decision + job summary reporting. + +Entry point for the CVE scan workflow. Scans images across configured +platforms, classifies findings, verifies fixes against base images +(dynamic mode), and reports everything in the job summary. Emits job +outputs ``fixable`` and ``versions`` for the rebuild job. Static override +mode always emits fixable=false to prevent unverified rebuilds. + +Usage: python -m scripts.cve_scan.sweep --repo valkey-io/valkey-container [--dry-run] +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.cve_scan.base_precheck import verify_fixable_in_base +from scripts.cve_scan.config import CveScanSettings, load_settings +from scripts.cve_scan.image_matrix import resolve_matrix +from scripts.cve_scan.models import Classification +from scripts.cve_scan.rebuild_decider import classify_all +from scripts.cve_scan.scanner import scan_images +from scripts.cve_scan.summary import render_findings_table + +logger = logging.getLogger(__name__) + + +def _split_classifications( + classifications: list[Classification], +) -> tuple[list[Classification], list[Classification]]: + """Split classifications into fixable and not-fixable groups.""" + fixable = [c for c in classifications if c.fixable] + not_fixable = [c for c in classifications if not c.fixable] + return fixable, not_fixable + + +def _fixable_versions(fixable: list[Classification]) -> list[str]: + """Derive sorted deduplicated version lines from confirmed-fixable images. + + 'valkey/valkey:8.0-alpine' -> '8.0' (for --field version= to ci.yml). + """ + versions: set[str] = set() + for c in fixable: + tag = c.finding.image.rsplit(":", 1)[-1] if ":" in c.finding.image else c.finding.image + line = tag.replace("-alpine", "") + if line: + versions.add(line) + return sorted(versions) + + +def _emit_outputs(fixable: bool, versions: list[str] | None = None) -> None: + """Emit GitHub Actions job outputs (fixable, versions) to $GITHUB_OUTPUT. + + When GITHUB_OUTPUT is unset (local/dry-run), prints the values instead. + """ + versions_str = " ".join(versions or []) + fixable_str = "true" if fixable else "false" + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + f.write(f"fixable={fixable_str}\n") + f.write(f"versions={versions_str}\n") + logger.info( + "Wrote outputs to GITHUB_OUTPUT: fixable=%s versions=%s", + fixable_str, versions_str or "(empty)", + ) + else: + print(f"fixable={fixable_str}") + print(f"versions={versions_str}") + + +def _print_dry_run( + fixable: list[Classification], + not_fixable: list[Classification], +) -> None: + """Print what WOULD happen to stdout for dry-run mode.""" + if not_fixable: + table = render_findings_table(not_fixable) + print("=" * 72) + print("[DRY RUN] NOT-FIXABLE FINDINGS (reported in job summary)") + print("=" * 72) + print(table) + print() + + if fixable: + versions = _fixable_versions(fixable) + table = render_findings_table(fixable) + print("=" * 72) + print("[DRY RUN] DISPATCH BEHAVIOR") + print("=" * 72) + print(f"Would dispatch rebuild for versions: {' '.join(versions)}") + print("-" * 72) + print(table) + print() + + if not fixable and not not_fixable: + print("[DRY RUN] No findings above threshold. No rebuild needed.") + + +def _emit_run_summary( + *, + images: list[str], + findings_count: int, + fixable: list[Classification], + not_fixable: list[Classification], + threshold_name: str, + dry_run: bool, + static_mode: bool, + dispatched_versions: list[str] | None = None, +) -> None: + """Write a run summary to the GitHub Actions job summary page.""" + from scripts.common.job_summary import emit_job_summary + + lines = ["## CVE Scan Summary", ""] + mode_bits = [] + if dry_run: + mode_bits.append("dry run") + if static_mode: + mode_bits.append("static image override") + if mode_bits: + lines.append(f"Mode: {', '.join(mode_bits)}") + lines.append("") + lines.append(f"| Images scanned | Findings ({threshold_name}+) | Confirmed fixable | Not fixable |") + lines.append("|---|---|---|---|") + lines.append(f"| {len(images)} | {findings_count} | {len(fixable)} | {len(not_fixable)} |") + lines.append("") + + if fixable: + versions = dispatched_versions or [] + if dry_run: + lines.append(f"### Confirmed fixable (rebuild would be dispatched for versions: {' '.join(versions) or '(none)'})") + else: + lines.append(f"### Confirmed fixable (rebuild will be dispatched for versions: {' '.join(versions) or '(none)'})") + lines.append("") + lines.append(render_findings_table(fixable)) + lines.append("") + + if not_fixable: + lines.append("### Unresolved findings (no rebuild)") + lines.append("") + lines.append(render_findings_table(not_fixable)) + lines.append("") + + if not fixable and not not_fixable: + if findings_count > 0: + lines.append("No rebuild dispatched: no finding has a fix verified present in the base image.") + else: + lines.append("No findings at or above the severity threshold.") + lines.append("") + + emit_job_summary("\n".join(lines)) + + +def run_sweep( + *, + repo_full_name: str, + settings: CveScanSettings, + dry_run: bool = False, +) -> None: + """Execute the CVE scan sweep pipeline. + + Args: + repo_full_name: Target repo (e.g. "valkey-io/valkey-container"). + settings: Loaded CveScanSettings instance. + dry_run: If True, print findings and skip dispatch. + """ + logger.info( + "Loaded settings: scanner=%s, threshold=%s, platforms=%s", + settings.scanner, + settings.severity_threshold.name, + ",".join(settings.platforms), + ) + + # Resolve image matrix (static override or dynamic from versions manifest) + images, base_map = resolve_matrix(settings) + static_mode = bool(settings.images) + logger.info("Resolved %d image(s) to scan: %s", len(images), ", ".join(images)) + + logger.info( + "Scanning %d image(s) x %d platform(s) with %s...", + len(images), len(settings.platforms), settings.scanner, + ) + findings = scan_images( + images, settings.scanner, settings.severity_threshold, + platforms=settings.platforms, + ) + logger.info("Found %d finding(s) above %s threshold.", len(findings), settings.severity_threshold.name) + + if not findings: + logger.info("No findings above threshold. Exiting cleanly.") + _emit_outputs(False) + if dry_run: + print("[DRY RUN] No findings. No rebuild needed.") + _emit_run_summary( + images=images, findings_count=0, fixable=[], not_fixable=[], + threshold_name=settings.severity_threshold.name, + dry_run=dry_run, static_mode=static_mode, + ) + return + + classifications = classify_all(findings) + fixable, not_fixable = _split_classifications(classifications) + logger.info( + "Classification: %d fixable, %d not fixable.", + len(fixable), + len(not_fixable), + ) + + # Base pre-check (dynamic mode only): verify fixes are present in base + if fixable and not static_mode: + logger.info("Running base package check for %d fixable finding(s)...", len(fixable)) + confirmed, downgraded = verify_fixable_in_base(fixable, base_map) + logger.info( + "Base pre-check: %d confirmed, %d downgraded (base not yet updated).", + len(confirmed), + len(downgraded), + ) + fixable = confirmed + not_fixable = not_fixable + downgraded + elif fixable and static_mode: + logger.info( + "Static mode: rebuild dispatch disabled, findings not verified against base." + ) + else: + logger.info("Skipping base pre-check (no fixable findings).") + + versions = _fixable_versions(fixable) if fixable and not static_mode else [] + + # Static mode always emits fixable=false (no unverified rebuild) + if static_mode: + _emit_outputs(False) + else: + _emit_outputs(len(fixable) > 0, versions) + + if dry_run: + _print_dry_run(fixable, not_fixable) + _emit_run_summary( + images=images, findings_count=len(findings), fixable=fixable, + not_fixable=not_fixable, threshold_name=settings.severity_threshold.name, + dry_run=True, static_mode=static_mode, dispatched_versions=versions, + ) + return + + # Live mode: rebuild dispatch happens in the workflow YAML + _emit_run_summary( + images=images, findings_count=len(findings), fixable=fixable, + not_fixable=not_fixable, threshold_name=settings.severity_threshold.name, + dry_run=False, static_mode=static_mode, dispatched_versions=versions, + ) + + +def main() -> None: + """CLI entry point for the CVE scan sweep.""" + parser = argparse.ArgumentParser( + description="CVE Scan Sweep: scan images, classify findings, report in job summary.", + ) + parser.add_argument( + "--repo", + required=True, + help="Target repository (owner/repo), e.g. valkey-io/valkey-container", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print findings to stdout; skip dispatch.", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable debug logging.", + ) + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + settings = load_settings() + + run_sweep( + repo_full_name=args.repo, + settings=settings, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/cve_scan/version_compare.py b/scripts/cve_scan/version_compare.py new file mode 100644 index 00000000..ed6d8c8d --- /dev/null +++ b/scripts/cve_scan/version_compare.py @@ -0,0 +1,145 @@ +"""Native package-manager version comparison for the CVE safety gate. + +Delegates to dpkg (Debian) or apk (Alpine) via ``docker run --rm`` for +semantically correct ordering. Any subprocess failure or unparseable output +returns None (fail-closed: caller must treat as not fixable). Deterministic, +no AI, no shell interpolation. +""" + +from __future__ import annotations + +import logging +import subprocess +from typing import Optional + +logger = logging.getLogger(__name__) + +#: Docker run timeout in seconds for a single comparison command. +_COMPARE_TIMEOUT = 60 + +# Canonical public images hosting the comparison tool (dpkg / apk) when no +# specific base image is given. +# Pinned by digest for the deterministic safety gate; refresh via +# `docker buildx imagetools inspect `. +_DEBIAN_COMPARATOR_IMAGE = ( + "public.ecr.aws/docker/library/debian:stable-slim" + "@sha256:328d16499860ae6cb9b345e2e4cebca08c2a36e4f7278482c7bd1f39d71e5bfd" +) +_ALPINE_COMPARATOR_IMAGE = ( + "public.ecr.aws/docker/library/alpine:3.21" + "@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d" +) + + +def compare_versions( + a: str, + b: str, + flavor: str, + base_image: Optional[str] = None, +) -> Optional[int]: + """Compare two package version strings using the native package manager. + + Args: + a: First version string (e.g. installed version). + b: Second version string (e.g. fixed version). + flavor: Package manager flavor: ``'debian'`` or ``'alpine'``. + base_image: Optional override for the container hosting the comparison tool. + + Returns: + -1/0/1 ordering of a vs b, or None on any error (fail-closed + sentinel: callers must treat it as not-fixable). + """ + if flavor == "debian": + return _compare_debian(a, b, base_image or _DEBIAN_COMPARATOR_IMAGE) + elif flavor == "alpine": + return _compare_alpine(a, b, base_image or _ALPINE_COMPARATOR_IMAGE) + else: + logger.warning("compare_versions: unknown flavor %r; returning None (fail-closed)", flavor) + return None + + +def _run_docker(cmd: list[str]) -> "tuple[int, str, str]": + """Run a docker command and return (returncode, stdout, stderr).""" + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=_COMPARE_TIMEOUT, + check=False, + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + logger.warning("compare_versions: docker command timed out: %s", " ".join(cmd)) + return -1, "", "timeout" + except OSError as exc: + logger.warning("compare_versions: failed to run docker: %s", exc) + return -1, "", str(exc) + + +def _compare_debian(a: str, b: str, image: str) -> Optional[int]: + """Compare via dpkg --compare-versions (lt then eq). + + dpkg legitimately exits 0 (true) or 1 (false); any other exit code is a + docker-level failure and returns None (fail-closed). + """ + rc_lt, _, stderr_lt = _run_docker([ + "docker", "run", "--rm", image, + "dpkg", "--compare-versions", a, "lt", b, + ]) + if rc_lt not in (0, 1): + logger.warning( + "compare_versions(debian): unexpected exit code %d for lt check; stderr=%r", + rc_lt, stderr_lt, + ) + return None + + if rc_lt == 0: + return -1 # a < b + + rc_eq, _, stderr_eq = _run_docker([ + "docker", "run", "--rm", image, + "dpkg", "--compare-versions", a, "eq", b, + ]) + if rc_eq not in (0, 1): + logger.warning( + "compare_versions(debian): unexpected exit code %d for eq check; stderr=%r", + rc_eq, stderr_eq, + ) + return None + + if rc_eq == 0: + return 0 # a == b + + return 1 # neither lt nor eq -> a > b + + +def _compare_alpine(a: str, b: str, image: str) -> Optional[int]: + """Compare via ``apk version -t`` (prints '<', '=', or '>'). Argv list, no shell.""" + rc, stdout, stderr = _run_docker([ + "docker", "run", "--rm", image, + "apk", "version", "-t", a, b, + ]) + if rc == -1: + # rc == -1 signals timeout/OSError from _run_docker + return None + if rc != 0: + logger.warning( + "compare_versions(alpine): apk version -t exited %d stderr=%r", + rc, stderr, + ) + return None + + symbol = stdout.strip() + if symbol == "<": + return -1 + elif symbol == "=": + return 0 + elif symbol == ">": + return 1 + else: + logger.warning( + "compare_versions(alpine): unexpected apk output %r for %r vs %r", + symbol, a, b, + ) + return None diff --git a/scripts/parsers/__init__.py b/scripts/parsers/__init__.py new file mode 100644 index 00000000..b2a3af93 --- /dev/null +++ b/scripts/parsers/__init__.py @@ -0,0 +1 @@ +"""Scanner output parsers.""" diff --git a/scripts/parsers/cve_findings_parser.py b/scripts/parsers/cve_findings_parser.py new file mode 100644 index 00000000..a94d5714 --- /dev/null +++ b/scripts/parsers/cve_findings_parser.py @@ -0,0 +1,55 @@ +"""Parse CVE scanner JSON output into structured Finding objects. + +Supports Trivy output format. All functions are pure (no I/O, +no side effects) and deterministic. +""" + +from __future__ import annotations + +from typing import Any + +from scripts.cve_scan.models import Finding, Severity + + +def parse_trivy(json_obj: dict[str, Any], image: str, platform: str = "") -> list[Finding]: + """Parse Trivy JSON output into Finding objects. + + Reads Results[].Vulnerabilities[] (VulnerabilityID, PkgName, + InstalledVersion, FixedVersion, Severity); FixedVersion may be + absent or empty (mapped to None). + """ + findings: list[Finding] = [] + results = json_obj.get("Results") + if not isinstance(results, list): + return findings + + for result in results: + vulns = result.get("Vulnerabilities") + if not isinstance(vulns, list): + continue + for vuln in vulns: + fixed = vuln.get("FixedVersion", "") + findings.append( + Finding( + image=image, + package=vuln["PkgName"], + installed_version=vuln["InstalledVersion"], + cve_id=vuln["VulnerabilityID"], + severity=Severity.from_str(vuln["Severity"]), + fixed_version=fixed if fixed else None, + platform=platform, + ) + ) + return findings + + +def parse_findings(scanner: str, json_obj: dict[str, Any], image: str, platform: str = "") -> list[Finding]: + """Dispatch to the correct parser based on scanner name; raises ValueError if unrecognized.""" + if scanner == "trivy": + return parse_trivy(json_obj, image, platform=platform) + raise ValueError(f"Unsupported scanner: {scanner!r}. Must be 'trivy'.") + + +def filter_by_threshold(findings: list[Finding], threshold: Severity) -> list[Finding]: + """Return findings at or above the given severity threshold (inclusive).""" + return [f for f in findings if f.severity >= threshold] diff --git a/tests/test_base_precheck.py b/tests/test_base_precheck.py new file mode 100644 index 00000000..7bd4a2f8 --- /dev/null +++ b/tests/test_base_precheck.py @@ -0,0 +1,538 @@ +"""Tests for scripts/cve_scan/base_precheck.py. + +Covers confirm/downgrade paths, fail-closed behavior, per-(base, platform) +caching, docker failure handling, and real-format apk/dpkg parsing. The +docker-based native comparator is patched with a deterministic local stub +(autouse fixture) to avoid real Docker calls; the real comparator is tested +in test_version_compare.py. +""" + +from __future__ import annotations + +import subprocess +from unittest.mock import patch + +import pytest + +from scripts.cve_scan.base_precheck import ( + BasePrecheckError, + _parse_apk_installed, + _parse_dpkg_query, + get_base_packages, + verify_fixable_in_base, +) +from scripts.cve_scan.models import Classification, Finding, Severity + + +def _stub_compare(a: str, b: str, flavor: str, base_image: str | None = None) -> int | None: + """Deterministic test stand-in for the native comparator. + + Parses only the 'X.Y.Z[-rN]' shapes used in these tests as tuples of + ints; anything else returns None (ambiguous), mirroring the native + comparator's fail-closed contract. + """ + def parse(version: str) -> "tuple[int, ...] | None": + nums, _, rev = version.partition("-r") + try: + return tuple(int(p) for p in nums.split(".")) + (int(rev) if rev else 0,) + except ValueError: + return None + + pa, pb = parse(a), parse(b) + if pa is None or pb is None: + return None + return (pa > pb) - (pa < pb) + + +@pytest.fixture(autouse=True) +def _mock_native_compare(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch the docker-based native comparator with a deterministic stub (no real Docker).""" + monkeypatch.setattr( + "scripts.cve_scan.base_precheck._native_compare", + _stub_compare, + ) + + +def _make_finding( + image: str = "valkey/valkey:9.1-alpine", + package: str = "openssl", + installed: str = "3.0.12-r0", + cve_id: str = "CVE-2024-1234", + fixed: str | None = "3.0.13-r0", +) -> Finding: + return Finding( + image=image, + package=package, + installed_version=installed, + cve_id=cve_id, + severity=Severity.HIGH, + fixed_version=fixed, + ) + + +def _make_classification(finding: Finding) -> Classification: + return Classification( + finding=finding, + fixable=True, + rationale=f"A rebuild would upgrade {finding.package}.", + ) + + +# Canned Alpine apk db content (real format from /lib/apk/db/installed) +SAMPLE_APK_DB = """\ +C:Q1abc123= +P:musl +V:1.2.5-r0 +A:x86_64 +S:383152 +I:622592 +T:the musl c library (libc) implementation +U:https://musl.libc.org/ +L:MIT +o:musl +m:Maintainer +t:1700000000 +c:abc123 +D: +p:so:libc.musl-x86_64.so.1=1 + +C:Q1def456= +P:openssl +V:3.0.13-r0 +A:x86_64 +S:2800000 +I:7200000 +T:toolkit for TLS +U:https://www.openssl.org/ +L:Apache-2.0 +o:openssl +m:Maintainer +t:1700000001 +c:def456 +D:so:libc.musl-x86_64.so.1 +p:so:libcrypto.so.3=3 + +C:Q1ghi789= +P:zlib +V:1.3.1-r0 +A:x86_64 +S:53248 +I:114688 +T:zlib compression library +U:https://zlib.net/ +L:Zlib +o:zlib +m:Maintainer +t:1700000002 +c:ghi789 +D:so:libc.musl-x86_64.so.1 +p:so:libz.so.1=1 + +""" + +# Canned Debian dpkg-query output +SAMPLE_DPKG_OUTPUT = """\ +adduser 3.134 +apt 2.7.14 +base-files 13.5 +bash 5.2.21-2+deb12u1 +coreutils 9.1-1 +dpkg 1.22.6 +libc6 2.36-9+deb12u9 +libssl3 3.0.13-1~deb12u1 +openssl 3.0.13-1~deb12u1 +zlib1g 1:1.2.13.dfsg-1 +""" + + +class TestBaseOlderThanFix: + """Base has an older version than the fix -> downgrade.""" + + def test_downgraded_when_base_has_old_version(self) -> None: + finding = _make_finding() + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + # Base has openssl 3.0.12-r0 (older than fix 3.0.13-r0) + mock_get.return_value = {"openssl": "3.0.12-r0", "musl": "1.2.5-r0"} + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert len(confirmed) == 0 + assert len(downgraded) == 1 + assert downgraded[0].fixable is False + assert "still ships" in downgraded[0].rationale + assert "3.0.12-r0" in downgraded[0].rationale + assert "alpine:3.23" in downgraded[0].rationale + assert "CVE-2024-1234" in downgraded[0].rationale + + def test_downgraded_rationale_mentions_package(self) -> None: + finding = _make_finding(package="zlib") + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + mock_get.return_value = {"zlib": "1.2.13-r0"} + _, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert "zlib" in downgraded[0].rationale + + +class TestBaseHasFix: + """Base has the fix (installed >= fixed) -> confirmed.""" + + def test_confirmed_when_base_has_newer_version(self) -> None: + finding = _make_finding(installed="3.0.12-r0", fixed="3.0.13-r0") + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + mock_get.return_value = {"openssl": "3.0.14-r0"} + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert len(confirmed) == 1 + assert len(downgraded) == 0 + assert "Verified: base alpine:3.23 ships 3.0.14-r0" in confirmed[0].rationale + + def test_confirmed_when_base_equals_fixed(self) -> None: + finding = _make_finding(installed="3.0.12-r0", fixed="3.0.13-r0") + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + mock_get.return_value = {"openssl": "3.0.13-r0"} + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert len(confirmed) == 1 + assert confirmed[0].fixable is True + assert "Verified: base alpine:3.23 ships 3.0.13-r0" in confirmed[0].rationale + + +class TestPackageAbsentFromBase: + """Package not in base image db -> confirmed (installed at build time).""" + + def test_confirmed_when_package_absent(self) -> None: + finding = _make_finding(package="libfoo") + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + mock_get.return_value = {"openssl": "3.0.13-r0", "musl": "1.2.5-r0"} + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert len(confirmed) == 1 + assert len(downgraded) == 0 + assert confirmed[0].fixable is True + assert "not in base image" in confirmed[0].rationale + assert "installed at build time" in confirmed[0].rationale + + +class TestAmbiguousComparison: + """Ambiguous version comparison (None) -> downgrade conservatively.""" + + def test_downgraded_on_ambiguous_comparison(self) -> None: + # Use versions that produce ambiguous comparison (mixed int/alpha at same position) + finding = _make_finding( + package="weird-pkg", + installed="1.0.0", + fixed="1.0.0beta1", + ) + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + # Non-numeric version: the stub comparator returns None (ambiguous) + mock_get.return_value = {"weird-pkg": "1.0.0alpha2"} + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + # Ambiguous comparison downgrades (fail-closed) + assert len(confirmed) == 0 + assert len(downgraded) == 1 + assert downgraded[0].fixable is False + + def test_downgraded_with_mocked_ambiguous_comparison(self) -> None: + """Direct test with mocked compare_versions returning None (native comparator).""" + finding = _make_finding(package="libcurl") + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get, patch( + "scripts.cve_scan.base_precheck._native_compare" + ) as mock_cmp: + mock_get.return_value = {"libcurl": "7.88.0"} + mock_cmp.return_value = None # Ambiguous / error -> fail-closed + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + assert len(confirmed) == 0 + assert len(downgraded) == 1 + assert downgraded[0].fixable is False + assert "ambiguous" in downgraded[0].rationale + + +class TestBaseCaching: + """Multiple images sharing a base image -> single get_base_packages call.""" + + def test_shared_base_queried_once(self) -> None: + finding1 = _make_finding( + image="valkey/valkey:9.1-alpine", + cve_id="CVE-2024-1111", + ) + finding2 = _make_finding( + image="valkey/valkey:9.0-alpine", + cve_id="CVE-2024-2222", + ) + classifications = [ + _make_classification(finding1), + _make_classification(finding2), + ] + base_map = { + "valkey/valkey:9.1-alpine": "alpine:3.23", + "valkey/valkey:9.0-alpine": "alpine:3.23", + } + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + mock_get.return_value = {"openssl": "3.0.13-r0"} + confirmed, downgraded = verify_fixable_in_base( + classifications, base_map + ) + + # Both findings have platform="" so cache key is ("alpine:3.23", "") + assert mock_get.call_count == 1 + mock_get.assert_called_once_with("alpine:3.23", platform="") + assert len(confirmed) == 2 + assert len(downgraded) == 0 + + def test_same_base_different_platforms_queried_separately(self) -> None: + """Same base ref on different platforms -> separate get_base_packages calls.""" + finding_amd64 = Finding( + image="valkey/valkey:9.1-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1111", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + platform="linux/amd64", + ) + finding_arm64 = Finding( + image="valkey/valkey:9.1-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1111", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + platform="linux/arm64", + ) + classifications = [ + _make_classification(finding_amd64), + _make_classification(finding_arm64), + ] + base_map = {"valkey/valkey:9.1-alpine": "alpine:3.23"} + + call_args: list[tuple[str, str]] = [] + + def mock_get(base_ref, platform=""): + call_args.append((base_ref, platform)) + if platform == "linux/amd64": + return {"openssl": "3.0.13-r0"} # amd64 has the fix + else: + return {"openssl": "3.0.12-r0"} # arm64 base is stale + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages", + side_effect=mock_get, + ): + confirmed, downgraded = verify_fixable_in_base( + classifications, base_map + ) + + # Two calls: one per (base_ref, platform) pair + assert len(call_args) == 2 + assert ("alpine:3.23", "linux/amd64") in call_args + assert ("alpine:3.23", "linux/arm64") in call_args + # amd64 confirmed, arm64 downgraded + assert len(confirmed) == 1 + assert confirmed[0].finding.platform == "linux/amd64" + assert len(downgraded) == 1 + assert downgraded[0].finding.platform == "linux/arm64" + assert "still ships" in downgraded[0].rationale + + +class TestMissingBaseMap: + """Image not in base_map -> downgraded conservatively (fail-closed).""" + + def test_missing_base_map_entry_downgrades(self) -> None: + finding = _make_finding(image="custom/image:latest") + classification = _make_classification(finding) + base_map: dict[str, str] = {} + + with patch( + "scripts.cve_scan.base_precheck.get_base_packages" + ) as mock_get: + confirmed, downgraded = verify_fixable_in_base( + [classification], base_map + ) + + mock_get.assert_not_called() + assert len(confirmed) == 0 + assert len(downgraded) == 1 + assert downgraded[0].fixable is False + assert "No base image mapping" in downgraded[0].rationale + assert "fail-closed" in downgraded[0].rationale + + +class TestUnknownBaseFlavor: + """Unknown base image prefix -> raises BasePrecheckError.""" + + def test_unknown_base_raises(self) -> None: + with pytest.raises(BasePrecheckError, match="Unknown base image flavor"): + get_base_packages("ubuntu:22.04") + + def test_unknown_base_in_verify_raises(self) -> None: + finding = _make_finding() + classification = _make_classification(finding) + base_map = {"valkey/valkey:9.1-alpine": "ubuntu:22.04"} + + with pytest.raises(BasePrecheckError, match="Unknown base image flavor"): + verify_fixable_in_base([classification], base_map) + + +class TestSubprocessFailure: + """Docker failure or timeout -> raises BasePrecheckError.""" + + def test_nonzero_exit_raises(self) -> None: + with patch("scripts.cve_scan.base_precheck.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["docker", "run", "--rm", "alpine:3.23", "cat", "/lib/apk/db/installed"], + returncode=1, + stdout="", + stderr="Error: image not found", + ) + with pytest.raises(BasePrecheckError, match="docker run failed"): + get_base_packages("alpine:3.23") + + def test_timeout_raises(self) -> None: + with patch("scripts.cve_scan.base_precheck.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired( + cmd=["docker", "run"], timeout=300 + ) + with pytest.raises(BasePrecheckError, match="Timed out"): + get_base_packages("alpine:3.23") + + def test_empty_output_raises(self) -> None: + with patch("scripts.cve_scan.base_precheck.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["docker", "run", "--rm", "alpine:3.23", "cat", "/lib/apk/db/installed"], + returncode=0, + stdout="", + stderr="", + ) + with pytest.raises(BasePrecheckError, match="Empty package database"): + get_base_packages("alpine:3.23") + + +class TestApkDbParsing: + """Parse real-format /lib/apk/db/installed content.""" + + def test_parse_real_apk_db(self) -> None: + packages = _parse_apk_installed(SAMPLE_APK_DB) + assert packages["musl"] == "1.2.5-r0" + assert packages["openssl"] == "3.0.13-r0" + assert packages["zlib"] == "1.3.1-r0" + assert len(packages) == 3 + + def test_get_base_packages_alpine_with_real_format(self) -> None: + """get_base_packages parses real apk db from docker output.""" + with patch("scripts.cve_scan.base_precheck.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["docker", "run", "--rm", "alpine:3.23", "cat", "/lib/apk/db/installed"], + returncode=0, + stdout=SAMPLE_APK_DB, + stderr="", + ) + result = get_base_packages("alpine:3.23") + + assert result["musl"] == "1.2.5-r0" + assert result["openssl"] == "3.0.13-r0" + assert result["zlib"] == "1.3.1-r0" + assert len(result) == 3 + + def test_parse_apk_db_no_trailing_blank_line(self) -> None: + """Last stanza without trailing blank line is still captured.""" + raw = "P:busybox\nV:1.36.1-r0\n" + packages = _parse_apk_installed(raw) + assert packages["busybox"] == "1.36.1-r0" + + def test_parse_apk_db_empty(self) -> None: + assert _parse_apk_installed("") == {} + + def test_parse_apk_db_only_blanks(self) -> None: + assert _parse_apk_installed("\n\n\n") == {} + + +class TestDpkgQueryParsing: + """Parse real-format dpkg-query output.""" + + def test_parse_real_dpkg_output(self) -> None: + packages = _parse_dpkg_query(SAMPLE_DPKG_OUTPUT) + assert packages["bash"] == "5.2.21-2+deb12u1" + assert packages["openssl"] == "3.0.13-1~deb12u1" + assert packages["zlib1g"] == "1:1.2.13.dfsg-1" + assert packages["dpkg"] == "1.22.6" + assert len(packages) == 10 + + def test_get_base_packages_debian_with_real_format(self) -> None: + """get_base_packages parses real dpkg-query output from docker.""" + with patch("scripts.cve_scan.base_precheck.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[ + "docker", "run", "--rm", "debian:trixie-slim", + "dpkg-query", "-W", "-f", "${Package} ${Version}\n", + ], + returncode=0, + stdout=SAMPLE_DPKG_OUTPUT, + stderr="", + ) + result = get_base_packages("debian:trixie-slim") + + assert result["openssl"] == "3.0.13-1~deb12u1" + assert result["bash"] == "5.2.21-2+deb12u1" + assert len(result) == 10 + + def test_parse_dpkg_empty(self) -> None: + assert _parse_dpkg_query("") == {} + + def test_parse_dpkg_trailing_whitespace(self) -> None: + raw = " bash 5.2.21 \n dpkg 1.22.6 \n" + packages = _parse_dpkg_query(raw) + assert packages["bash"] == "5.2.21" + assert packages["dpkg"] == "1.22.6" diff --git a/tests/test_cve_config.py b/tests/test_cve_config.py new file mode 100644 index 00000000..2a1bb69d --- /dev/null +++ b/tests/test_cve_config.py @@ -0,0 +1,133 @@ +"""Tests for scripts/cve_scan/config.py -- env-var settings validation. + +Invalid settings must not silently allow unintended rebuilds. The loader +raises CveScanConfigError on invalid env-var values. +""" + +from __future__ import annotations + +import pytest + +from scripts.cve_scan.config import ( + CveScanConfigError, + CveScanSettings, + load_settings, +) +from scripts.cve_scan.models import Severity + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove all CVE_SCAN_* env vars before each test.""" + import os + + for key in list(os.environ): + if key.startswith("CVE_SCAN_"): + monkeypatch.delenv(key, raising=False) + + +class TestDefaults: + def test_defaults_when_no_env_set(self) -> None: + settings = load_settings() + assert isinstance(settings, CveScanSettings) + assert settings.versions_url == ( + "https://raw.githubusercontent.com/valkey-io/valkey-container" + "/mainline/versions.json" + ) + assert settings.repository == "valkey/valkey" + assert settings.include_unstable is False + assert settings.scanner == "trivy" + assert settings.severity_threshold == Severity.HIGH + assert settings.images == [] + + def test_settings_is_frozen(self) -> None: + settings = load_settings() + with pytest.raises(Exception): # noqa: B017 - FrozenInstanceError + settings.scanner = "invalid" # type: ignore[misc] + + +class TestEnvOverrides: + def test_versions_url_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_VERSIONS_URL", "https://example.com/v.json") + settings = load_settings() + assert settings.versions_url == "https://example.com/v.json" + + def test_repository_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_REPOSITORY", "ghcr.io/valkey-io/valkey") + settings = load_settings() + assert settings.repository == "ghcr.io/valkey-io/valkey" + + def test_include_unstable_true_variants(self, monkeypatch: pytest.MonkeyPatch) -> None: + for truthy in ("1", "true", "yes", "on", "True", "YES", "TRUE", "ON"): + monkeypatch.setenv("CVE_SCAN_INCLUDE_UNSTABLE", truthy) + settings = load_settings() + assert settings.include_unstable is True, f"Failed for {truthy!r}" + + def test_include_unstable_false_variants(self, monkeypatch: pytest.MonkeyPatch) -> None: + for falsy in ("0", "false", "no", "off", "False", "NO", "OFF", ""): + monkeypatch.setenv("CVE_SCAN_INCLUDE_UNSTABLE", falsy) + settings = load_settings() + assert settings.include_unstable is False, f"Failed for {falsy!r}" + + def test_scanner_grype_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SCANNER", "grype") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SCANNER"): + load_settings() + + def test_scanner_case_insensitive(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SCANNER", "TRIVY") + settings = load_settings() + assert settings.scanner == "trivy" + + def test_severity_threshold_all_levels(self, monkeypatch: pytest.MonkeyPatch) -> None: + for level in ("UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"): + monkeypatch.setenv("CVE_SCAN_SEVERITY_THRESHOLD", level) + settings = load_settings() + assert settings.severity_threshold == Severity[level] + + def test_severity_threshold_case_insensitive(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SEVERITY_THRESHOLD", "critical") + settings = load_settings() + assert settings.severity_threshold == Severity.CRITICAL + + def test_images_static_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_IMAGES", "img:1, img:2 ,img:3") + settings = load_settings() + assert settings.images == ["img:1", "img:2", "img:3"] + + def test_images_empty_means_dynamic(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_IMAGES", "") + settings = load_settings() + assert settings.images == [] + + def test_images_whitespace_only_means_dynamic(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_IMAGES", " , , ") + settings = load_settings() + assert settings.images == [] + + +class TestStrictRejection: + def test_invalid_scanner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SCANNER", "nessus") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SCANNER"): + load_settings() + + def test_grype_scanner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SCANNER", "grype") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SCANNER"): + load_settings() + + def test_invalid_severity_threshold_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_SEVERITY_THRESHOLD", "APOCALYPTIC") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SEVERITY_THRESHOLD"): + load_settings() + + def test_garbage_include_unstable_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_INCLUDE_UNSTABLE", "maybe") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_INCLUDE_UNSTABLE"): + load_settings() + + def test_garbage_include_unstable_typo_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CVE_SCAN_INCLUDE_UNSTABLE", "treu") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_INCLUDE_UNSTABLE"): + load_settings() diff --git a/tests/test_cve_parser.py b/tests/test_cve_parser.py new file mode 100644 index 00000000..06b1c983 --- /dev/null +++ b/tests/test_cve_parser.py @@ -0,0 +1,266 @@ +"""Tests for scripts/parsers/cve_findings_parser.py (Req 1.4/1.5). + +Covers parse_trivy, parse_findings dispatcher, and +filter_by_threshold. All use small inline JSON fixtures. +""" + +from __future__ import annotations + +import pytest + +from scripts.cve_scan.models import Finding, Severity +from scripts.parsers.cve_findings_parser import ( + filter_by_threshold, + parse_findings, + parse_trivy, +) + +IMAGE = "valkey/valkey:7.2" + + +class TestParseTrivy: + def test_basic_finding(self) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-1234", + "PkgName": "openssl", + "InstalledVersion": "3.0.12-r0", + "FixedVersion": "3.0.13-r0", + "Severity": "HIGH", + } + ] + } + ] + } + findings = parse_trivy(trivy_json, IMAGE) + assert len(findings) == 1 + f = findings[0] + assert f.image == IMAGE + assert f.package == "openssl" + assert f.installed_version == "3.0.12-r0" + assert f.cve_id == "CVE-2024-1234" + assert f.severity == Severity.HIGH + assert f.fixed_version == "3.0.13-r0" + + def test_missing_fixed_version_becomes_none(self) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-9999", + "PkgName": "libcurl", + "InstalledVersion": "8.0.0", + "Severity": "CRITICAL", + } + ] + } + ] + } + findings = parse_trivy(trivy_json, IMAGE) + assert findings[0].fixed_version is None + + def test_empty_fixed_version_becomes_none(self) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-5555", + "PkgName": "zlib", + "InstalledVersion": "1.2.13", + "FixedVersion": "", + "Severity": "MEDIUM", + } + ] + } + ] + } + findings = parse_trivy(trivy_json, IMAGE) + assert findings[0].fixed_version is None + + def test_multiple_results_and_vulns(self) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0001", + "PkgName": "pkg-a", + "InstalledVersion": "1.0", + "FixedVersion": "1.1", + "Severity": "LOW", + }, + { + "VulnerabilityID": "CVE-2024-0002", + "PkgName": "pkg-b", + "InstalledVersion": "2.0", + "FixedVersion": "2.1", + "Severity": "HIGH", + }, + ] + }, + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0003", + "PkgName": "pkg-c", + "InstalledVersion": "3.0", + "Severity": "CRITICAL", + } + ] + }, + ] + } + findings = parse_trivy(trivy_json, IMAGE) + assert len(findings) == 3 + assert findings[0].cve_id == "CVE-2024-0001" + assert findings[2].fixed_version is None + + def test_empty_results_returns_empty_list(self) -> None: + assert parse_trivy({"Results": []}, IMAGE) == [] + + def test_missing_results_key_returns_empty_list(self) -> None: + assert parse_trivy({}, IMAGE) == [] + + def test_results_not_a_list_returns_empty(self) -> None: + assert parse_trivy({"Results": "invalid"}, IMAGE) == [] + + def test_vulnerabilities_not_a_list_skipped(self) -> None: + trivy_json = {"Results": [{"Vulnerabilities": "not-a-list"}]} + assert parse_trivy(trivy_json, IMAGE) == [] + + @pytest.mark.parametrize("sev", ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"]) + def test_severity_mapping(self, sev: str) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0000", + "PkgName": "test", + "InstalledVersion": "1.0", + "FixedVersion": "1.1", + "Severity": sev, + } + ] + } + ] + } + findings = parse_trivy(trivy_json, IMAGE) + assert findings[0].severity == Severity[sev] + + +class TestParseFindings: + def test_dispatches_trivy(self) -> None: + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0001", + "PkgName": "pkg", + "InstalledVersion": "1.0", + "FixedVersion": "1.1", + "Severity": "HIGH", + } + ] + } + ] + } + findings = parse_findings("trivy", trivy_json, IMAGE) + assert len(findings) == 1 + assert findings[0].cve_id == "CVE-2024-0001" + + def test_unknown_scanner_raises_valueerror(self) -> None: + with pytest.raises(ValueError, match="Unsupported scanner"): + parse_findings("nessus", {}, IMAGE) + + def test_grype_raises_valueerror(self) -> None: + with pytest.raises(ValueError, match="Unsupported scanner"): + parse_findings("grype", {}, IMAGE) + + def test_platform_stamped_on_findings(self) -> None: + """Platform argument is stamped on each Finding.""" + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0001", + "PkgName": "pkg", + "InstalledVersion": "1.0", + "FixedVersion": "1.1", + "Severity": "HIGH", + } + ] + } + ] + } + findings = parse_findings("trivy", trivy_json, IMAGE, platform="linux/arm64") + assert len(findings) == 1 + assert findings[0].platform == "linux/arm64" + + def test_platform_defaults_to_empty(self) -> None: + """Platform defaults to empty string when not provided.""" + trivy_json = { + "Results": [ + { + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-0001", + "PkgName": "pkg", + "InstalledVersion": "1.0", + "FixedVersion": "1.1", + "Severity": "HIGH", + } + ] + } + ] + } + findings = parse_findings("trivy", trivy_json, IMAGE) + assert findings[0].platform == "" + + +class TestFilterByThreshold: + @pytest.fixture + def mixed_findings(self) -> list[Finding]: + """Findings spanning all severity levels.""" + return [ + Finding("img", "a", "1.0", "CVE-1", Severity.LOW, "1.1"), + Finding("img", "b", "1.0", "CVE-2", Severity.MEDIUM, "1.1"), + Finding("img", "c", "1.0", "CVE-3", Severity.HIGH, "1.1"), + Finding("img", "d", "1.0", "CVE-4", Severity.CRITICAL, "1.1"), + Finding("img", "e", "1.0", "CVE-5", Severity.UNKNOWN, None), + ] + + def test_threshold_high_keeps_high_and_critical(self, mixed_findings) -> None: + result = filter_by_threshold(mixed_findings, Severity.HIGH) + assert len(result) == 2 + severities = {f.severity for f in result} + assert severities == {Severity.HIGH, Severity.CRITICAL} + + def test_threshold_critical_keeps_only_critical(self, mixed_findings) -> None: + result = filter_by_threshold(mixed_findings, Severity.CRITICAL) + assert len(result) == 1 + assert result[0].severity == Severity.CRITICAL + + def test_threshold_low_keeps_all_except_unknown(self, mixed_findings) -> None: + result = filter_by_threshold(mixed_findings, Severity.LOW) + assert len(result) == 4 + assert all(f.severity >= Severity.LOW for f in result) + + def test_threshold_unknown_keeps_all(self, mixed_findings) -> None: + result = filter_by_threshold(mixed_findings, Severity.UNKNOWN) + assert len(result) == 5 + + def test_empty_list_returns_empty(self) -> None: + assert filter_by_threshold([], Severity.HIGH) == [] + + def test_below_threshold_excluded(self) -> None: + low_only = [Finding("img", "pkg", "1.0", "CVE-X", Severity.LOW, "1.1")] + result = filter_by_threshold(low_only, Severity.HIGH) + assert result == [] diff --git a/tests/test_cve_rebuild_decider.py b/tests/test_cve_rebuild_decider.py new file mode 100644 index 00000000..ba20444b --- /dev/null +++ b/tests/test_cve_rebuild_decider.py @@ -0,0 +1,98 @@ +"""Tests for scripts/cve_scan/rebuild_decider.py. + +Two-rule contract: no fixed_version -> not fixable; fixed_version present -> +candidate fixable, pending base pre-check verification. Version ordering +semantics live only in version_compare.py (native dpkg/apk), which has its +own tests. +""" + +from __future__ import annotations + +from scripts.cve_scan.models import Classification, Finding, Severity +from scripts.cve_scan.rebuild_decider import classify, classify_all + + +def _make_finding( + installed: str = "1.0.0", + fixed: str | None = "1.0.1", + cve_id: str = "CVE-2024-0001", + package: str = "openssl", +) -> Finding: + """Helper to create a Finding with sensible defaults.""" + return Finding( + image="valkey/valkey:7.2", + package=package, + installed_version=installed, + cve_id=cve_id, + severity=Severity.HIGH, + fixed_version=fixed, + ) + + +class TestNoFixAvailable: + def test_none_fixed_version_not_fixable(self) -> None: + result = classify(_make_finding(fixed=None)) + assert result.fixable is False + + def test_empty_fixed_version_not_fixable(self) -> None: + result = classify(_make_finding(fixed="")) + assert result.fixable is False + + def test_rationale_mentions_no_upstream_fix(self) -> None: + result = classify(_make_finding(fixed=None)) + assert "no upstream fix" in result.rationale.lower() + + def test_result_preserves_finding(self) -> None: + finding = _make_finding(fixed=None) + result = classify(finding) + assert result.finding is finding + + +class TestCandidateFixable: + def test_fix_present_is_candidate(self) -> None: + result = classify(_make_finding(installed="1.0.0", fixed="1.0.1")) + assert result.fixable is True + + def test_rationale_mentions_pending_base_verification(self) -> None: + result = classify(_make_finding()) + assert "pending base verification" in result.rationale + + def test_rationale_mentions_versions(self) -> None: + result = classify(_make_finding(installed="3.0.12-r0", fixed="3.0.13-r0")) + assert "3.0.13-r0" in result.rationale + assert "3.0.12-r0" in result.rationale + + def test_candidacy_does_not_compare_versions(self) -> None: + """Trivy's matching is trusted: even installed >= fixed is a candidate here.""" + result = classify(_make_finding(installed="2.0.0", fixed="1.9.9")) + assert result.fixable is True + + def test_result_preserves_finding(self) -> None: + finding = _make_finding() + result = classify(finding) + assert result.finding is finding + + +class TestClassifyAll: + def test_returns_one_per_finding(self) -> None: + findings = [ + _make_finding(installed="1.0", fixed="2.0"), + _make_finding(installed="3.0", fixed=None), + ] + results = classify_all(findings) + assert len(results) == 2 + assert all(isinstance(r, Classification) for r in results) + assert results[0].fixable is True + assert results[1].fixable is False + + def test_preserves_order(self) -> None: + findings = [ + _make_finding(cve_id="CVE-A", installed="1.0", fixed="2.0"), + _make_finding(cve_id="CVE-B", installed="2.0", fixed=None), + ] + results = classify_all(findings) + assert results[0].finding.cve_id == "CVE-A" + assert results[1].finding.cve_id == "CVE-B" + + def test_empty_input_returns_empty(self) -> None: + assert classify_all([]) == [] diff --git a/tests/test_cve_summary.py b/tests/test_cve_summary.py new file mode 100644 index 00000000..7a02f393 --- /dev/null +++ b/tests/test_cve_summary.py @@ -0,0 +1,241 @@ +"""Tests for scripts/cve_scan/summary.py -- grouped findings table renderer.""" + +from __future__ import annotations + +from scripts.cve_scan.models import Classification, Finding, Severity +from scripts.cve_scan.summary import _strip_repo_prefix, render_findings_table + + +def _make_classification( + cve_id: str = "CVE-2024-1234", + package: str = "openssl", + image: str = "valkey/valkey:8.0-alpine", + fixed_version: str | None = None, + fixable: bool = False, + rationale: str = "Test rationale", + platform: str = "", +) -> Classification: + """Build a Classification with sensible defaults.""" + return Classification( + finding=Finding( + image=image, + package=package, + installed_version="3.0.12-r0", + cve_id=cve_id, + severity=Severity.HIGH, + fixed_version=fixed_version, + platform=platform, + ), + fixable=fixable, + rationale=rationale, + ) + + +class TestStripRepoPrefix: + """Unit tests for _strip_repo_prefix.""" + + def test_strips_prefix(self) -> None: + assert _strip_repo_prefix("valkey/valkey:8.0-alpine") == "8.0-alpine" + + def test_no_colon_returns_as_is(self) -> None: + assert _strip_repo_prefix("nocolon") == "nocolon" + + def test_multiple_colons_strips_after_last(self) -> None: + assert _strip_repo_prefix("registry.io:5000/repo:tag") == "tag" + + +class TestRenderFindingsTable: + """render_findings_table outputs correct grouped markdown table.""" + + def test_basic_table_structure(self) -> None: + classifications = [ + _make_classification(cve_id="CVE-2024-9999", package="busybox"), + ] + table = render_findings_table(classifications) + assert "### Findings" in table + assert "CVE-2024-9999" in table + assert "busybox" in table + assert "| CVE | Severity | Packages | Installed | Fixed | Images | Platforms | Rationale |" in table + + def test_multiple_images_grouped_into_single_row(self) -> None: + """2 images sharing 1 CVE+severity+rationale -> 1 row with both tags.""" + classifications = [ + _make_classification( + cve_id="CVE-2024-1234", + package="openssl", + image="valkey/valkey:8.0-alpine", + ), + _make_classification( + cve_id="CVE-2024-1234", + package="openssl", + image="valkey/valkey:9.1-alpine", + ), + ] + table = render_findings_table(classifications) + # Exactly one data row (header + separator + 1 row + trailing blank) + data_rows = [ + line for line in table.splitlines() + if line.startswith("| CVE-") + ] + assert len(data_rows) == 1 + assert "8.0-alpine" in data_rows[0] + assert "9.1-alpine" in data_rows[0] + + def test_grouping_multiple_packages_same_cve(self) -> None: + """2 images x 2 packages sharing one CVE -> ONE data row with both packages and both tags.""" + classifications = [ + _make_classification(cve_id="CVE-2024-1000", package="zlib", image="valkey/valkey:8.0"), + _make_classification(cve_id="CVE-2024-1000", package="openssl", image="valkey/valkey:8.0"), + _make_classification(cve_id="CVE-2024-1000", package="zlib", image="valkey/valkey:9.1"), + _make_classification(cve_id="CVE-2024-1000", package="openssl", image="valkey/valkey:9.1"), + ] + table = render_findings_table(classifications) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 1 + row = data_rows[0] + assert "openssl" in row + assert "zlib" in row + assert "8.0" in row + assert "9.1" in row + + def test_mixed_rationale_same_cve_produces_two_rows(self) -> None: + """Same CVE with different rationale -> two data rows.""" + c1 = Classification( + finding=Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-5000", + severity=Severity.HIGH, + fixed_version=None, + ), + fixable=False, + rationale="No upstream fix yet.", + ) + c2 = Classification( + finding=Finding( + image="valkey/valkey:9.1", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-5000", + severity=Severity.HIGH, + fixed_version="3.0.13", + ), + fixable=False, + rationale="Base image still ships old version.", + ) + table = render_findings_table([c1, c2]) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 2 + + def test_repo_prefix_stripped_from_images(self) -> None: + """Image tags have repo prefix stripped (e.g. 'valkey/valkey:8.0' -> '8.0').""" + classifications = [ + _make_classification(image="valkey/valkey:8.0-alpine"), + ] + table = render_findings_table(classifications) + assert "8.0-alpine" in table + assert "valkey/valkey:" not in table + + def test_no_affected_images_section(self) -> None: + """The old '### Affected Images' section is removed.""" + classifications = [ + _make_classification(cve_id="CVE-2024-9999", package="busybox"), + ] + table = render_findings_table(classifications) + assert "### Affected Images" not in table + + def test_severity_sort_descending(self) -> None: + """Higher severity rows appear first.""" + c_high = Classification( + finding=Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-2000", + severity=Severity.HIGH, + fixed_version=None, + ), + fixable=False, + rationale="No fix.", + ) + c_crit = Classification( + finding=Finding( + image="valkey/valkey:8.0", + package="zlib", + installed_version="1.2.13", + cve_id="CVE-2024-1000", + severity=Severity.CRITICAL, + fixed_version=None, + ), + fixable=False, + rationale="No fix.", + ) + table = render_findings_table([c_high, c_crit]) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 2 + # CRITICAL first + assert "CVE-2024-1000" in data_rows[0] + assert "CVE-2024-2000" in data_rows[1] + + def test_no_urgency_column_when_map_is_none(self) -> None: + """No 'Distro severity' column appears in the table.""" + classifications = [ + _make_classification(cve_id="CVE-2024-9999", package="busybox"), + ] + table = render_findings_table(classifications) + assert "Distro severity" not in table + + def test_platforms_column_aggregates_sorted_short_names(self) -> None: + """Per-platform findings for one CVE -> one row with sorted short platform names.""" + classifications = [ + _make_classification(cve_id="CVE-2024-3000", platform="linux/arm64"), + _make_classification(cve_id="CVE-2024-3000", platform="linux/amd64"), + _make_classification(cve_id="CVE-2024-3000", platform="linux/arm64"), + ] + table = render_findings_table(classifications) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 1 + assert "| amd64, arm64 |" in data_rows[0] + assert "linux/" not in data_rows[0] + + def test_platforms_column_dash_when_platform_empty(self) -> None: + """Static-mode findings (empty platform) render '-' in the Platforms column.""" + classifications = [ + _make_classification(cve_id="CVE-2024-4000"), + ] + table = render_findings_table(classifications) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 1 + assert "| - |" in data_rows[0] + + def test_inline_render_2_cves_5_images_produces_2_rows(self) -> None: + """Verify: 10 classifications (2 CVEs x 5 images) -> exactly 2 data rows.""" + images = [ + "valkey/valkey:7.2-alpine", + "valkey/valkey:8.0-alpine", + "valkey/valkey:8.1-alpine", + "valkey/valkey:9.0-alpine", + "valkey/valkey:9.1-alpine", + ] + classifications = [] + for img in images: + classifications.append( + _make_classification( + cve_id="CVE-2024-1111", + package="openssl", + image=img, + rationale="No upstream fix yet.", + ) + ) + classifications.append( + _make_classification( + cve_id="CVE-2024-2222", + package="zlib", + image=img, + rationale="Base stale.", + ) + ) + table = render_findings_table(classifications) + data_rows = [line for line in table.splitlines() if line.startswith("| CVE-")] + assert len(data_rows) == 2 diff --git a/tests/test_cve_sweep_integration.py b/tests/test_cve_sweep_integration.py new file mode 100644 index 00000000..1538d171 --- /dev/null +++ b/tests/test_cve_sweep_integration.py @@ -0,0 +1,661 @@ +"""Integration tests for the CVE scan sweep: real settings loading + real output emission. + +load_settings and _emit_outputs are NOT mocked; only scan_images, the HTTP +fetch, and base package reads are patched. Covers fixable/not-fixable output +emission, config-error regression, dynamic resolution, base pre-check +downgrades, static-mode dispatch disable, and job summary content. +""" + +from __future__ import annotations + +import json +import os +from io import BytesIO +from pathlib import Path + +import pytest + +from scripts.cve_scan.config import CveScanConfigError, load_settings +from scripts.cve_scan.models import Finding, Severity +from scripts.cve_scan.sweep import run_sweep + + +@pytest.fixture(autouse=True) +def _clean_cve_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove all CVE_SCAN_* env vars for a clean slate.""" + for key in list(os.environ): + if key.startswith("CVE_SCAN_"): + monkeypatch.delenv(key, raising=False) + + +@pytest.fixture(autouse=True) +def _mock_native_compare(monkeypatch: pytest.MonkeyPatch) -> None: + """Patch _native_compare with a deterministic stub (no real Docker). + + Parses the 'X.Y.Z[-rN]' shapes used in these tests as tuples of ints; + anything else returns None (fail-closed, like the native comparator). + """ + def parse(version: str) -> "tuple[int, ...] | None": + nums, _, rev = version.partition("-r") + try: + return tuple(int(p) for p in nums.split(".")) + (int(rev) if rev else 0,) + except ValueError: + return None + + def stub_compare(a: str, b: str, flavor: str, base_image: str | None = None) -> int | None: + pa, pb = parse(a), parse(b) + if pa is None or pb is None: + return None + return (pa > pb) - (pa < pb) + + monkeypatch.setattr( + "scripts.cve_scan.base_precheck._native_compare", + stub_compare, + ) + + +@pytest.fixture() +def github_output_file(tmp_path: Path) -> Path: + """Create a temp file to act as GITHUB_OUTPUT.""" + output_file = tmp_path / "github_output" + output_file.write_text("") + return output_file + + +def _mock_urlopen_response(data: dict) -> BytesIO: + """Create a mock response object for urllib.request.urlopen.""" + body = json.dumps(data).encode("utf-8") + resp = BytesIO(body) + resp.status = 200 # type: ignore[attr-defined] + resp.__enter__ = lambda self: self # type: ignore[attr-defined] + resp.__exit__ = lambda self, *a: None # type: ignore[attr-defined] + return resp + + +SAMPLE_VERSIONS = { + "7.2": {"version": "7.2.13", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "8.0": {"version": "8.0.9", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "8.1": {"version": "8.1.8", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "9.0": {"version": "9.0.4", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "9.1": {"version": "9.1.0", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "unstable": {"version": "unstable", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, +} + + +class TestIntegrationFixable: + """Real load_settings + real _emit_outputs with a fixable finding.""" + + def test_fixable_finding_emits_true( + self, + monkeypatch: pytest.MonkeyPatch, + github_output_file: Path, + ) -> None: + """A finding with installed < fixed_version produces fixable=true.""" + fixable_findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + ] + + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: fixable_findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + # Base pre-check: base has the fix (package at fixed version) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.13-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output_file.read_text() + lines = output.strip().splitlines() + assert "fixable=true" in lines + + def test_multiple_fixable_images_emits_true( + self, + monkeypatch: pytest.MonkeyPatch, + github_output_file: Path, + ) -> None: + """Multiple fixable images still just emit fixable=true.""" + findings = [ + Finding( + image="valkey/valkey:9.0-alpine", + package="zlib", + installed_version="1.2.13-r0", + cve_id="CVE-2024-5678", + severity=Severity.CRITICAL, + fixed_version="1.2.14-r0", + ), + Finding( + image="valkey/valkey:7.2-alpine", + package="openssl", + installed_version="3.0.10-r0", + cve_id="CVE-2024-1111", + severity=Severity.HIGH, + fixed_version="3.0.11-r0", + ), + ] + + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.11-r0", "zlib": "1.2.14-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output_file.read_text() + assert "fixable=true" in output.strip().splitlines() + + +class TestIntegrationNotFixable: + """Real load_settings + real _emit_outputs with only non-fixable findings.""" + + def test_no_fix_available_emits_false( + self, + monkeypatch: pytest.MonkeyPatch, + github_output_file: Path, + ) -> None: + """Findings with fixed_version=None produce fixable=false.""" + not_fixable_findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="busybox", + installed_version="1.36.1-r0", + cve_id="CVE-2024-9999", + severity=Severity.HIGH, + fixed_version=None, + ), + ] + + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: not_fixable_findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output_file.read_text() + lines = output.strip().splitlines() + assert "fixable=false" in lines + + def test_zero_findings_emits_false( + self, + monkeypatch: pytest.MonkeyPatch, + github_output_file: Path, + ) -> None: + """Zero findings from scanner produces fixable=false.""" + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: [], + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output_file.read_text() + lines = output.strip().splitlines() + assert "fixable=false" in lines + + +class TestIntegrationConfigError: + """Proves the REAL load path is exercised (not mocked away).""" + + def test_invalid_scanner_raises_cve_scan_config_error( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("CVE_SCAN_SCANNER", "unknown-scanner") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SCANNER"): + load_settings() + + def test_invalid_severity_raises_cve_scan_config_error( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("CVE_SCAN_SEVERITY_THRESHOLD", "INVALID") + with pytest.raises(CveScanConfigError, match="Invalid CVE_SCAN_SEVERITY_THRESHOLD"): + load_settings() + + +class TestIntegrationDynamic: + """Dynamic settings with mocked HTTP fetch. Only scan_images and urlopen mocked.""" + + def test_dynamic_settings_resolves_and_scans( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Dynamic settings fetches versions.json, resolves images, passes to scanner.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + # Track what images were passed to scan_images + scanned_images: list[str] = [] + + def mock_scan(images, scanner, threshold, **_kw): + scanned_images.extend(images) + return [ + Finding( + image="valkey/valkey:8.0-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + ] + + monkeypatch.setattr("scripts.cve_scan.sweep.scan_images", mock_scan) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.13-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + # Verify images were resolved (10 stable: 5 alpine + 5 bare) + assert len(scanned_images) == 10 + assert "valkey/valkey:7.2-alpine" in scanned_images + assert "valkey/valkey:9.1" in scanned_images + # Unstable excluded + assert "valkey/valkey:unstable-alpine" not in scanned_images + assert "valkey/valkey:unstable" not in scanned_images + + # Verify outputs emitted + output = github_output.read_text() + assert "fixable=true" in output + + +class TestIntegrationBasePrecheck: + """Integration: dynamic settings with base pre-check wired into sweep.""" + + def test_stale_base_downgrades_fixable_to_not_fixable( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Fixable finding whose base is stale -> fixable=false.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + fixable_finding = Finding( + image="valkey/valkey:9.1-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-5555", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: [fixable_finding], + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.12-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output.read_text() + assert "fixable=false" in output + + def test_stale_base_finding_appears_in_dry_run( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Downgraded finding appears in dry-run not-fixable output.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + fixable_finding = Finding( + image="valkey/valkey:9.1-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-5555", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: [fixable_finding], + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.12-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + captured = capsys.readouterr() + assert "NOT-FIXABLE" in captured.out + assert "CVE-2024-5555" in captured.out + assert "still ships" in captured.out + + def test_confirmed_base_keeps_fixable_true( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Fixable finding confirmed by base pre-check -> fixable=true.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + fixable_finding = Finding( + image="valkey/valkey:9.1-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-5555", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: [fixable_finding], + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.13-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output.read_text() + assert "fixable=true" in output + + +class TestStaticModeDispatchDisabled: + """Static mode always emits fixable=false regardless of findings.""" + + def test_static_mode_fixable_finding_emits_false( + self, + monkeypatch: pytest.MonkeyPatch, + github_output_file: Path, + ) -> None: + """Static mode: fixable finding still produces fixable=false.""" + fixable_findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + ] + + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output_file)) + monkeypatch.setenv("CVE_SCAN_IMAGES", "valkey/valkey:8.0-alpine,valkey/valkey:7.2-alpine") + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: fixable_findings, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + output = github_output_file.read_text() + lines = output.strip().splitlines() + assert "fixable=false" in lines + + +class TestSweepOutputNoEnvVar: + """When GITHUB_OUTPUT is unset, run_sweep prints and does not raise.""" + + def test_no_github_output_env_prints_fixable( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + fixable_findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + ] + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: fixable_findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.13-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + captured = capsys.readouterr() + assert "fixable=true" in captured.out + + def test_no_github_output_env_no_findings( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Zero findings + no GITHUB_OUTPUT: prints fixable=false, no exception.""" + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: [], + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=True, + ) + + captured = capsys.readouterr() + assert "fixable=false" in captured.out + + +class TestJobSummaryContent: + """Verify job summary includes findings tables.""" + + def test_fixable_findings_appear_in_job_summary( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Fixable findings render in the job summary.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + summary_file = tmp_path / "step_summary" + summary_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_file)) + + findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="openssl", + installed_version="3.0.12-r0", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + ] + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + monkeypatch.setattr( + "scripts.cve_scan.base_precheck.get_base_packages", + lambda base_ref, platform="": {"openssl": "3.0.13-r0"}, + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=False, + ) + + summary = summary_file.read_text() + assert "CVE Scan Summary" in summary + assert "CVE-2024-1234" in summary + assert "Confirmed fixable (rebuild will be dispatched" in summary + + def test_not_fixable_findings_appear_in_job_summary( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Not-fixable findings render in the job summary.""" + github_output = tmp_path / "github_output" + github_output.write_text("") + summary_file = tmp_path / "step_summary" + summary_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_file)) + + findings = [ + Finding( + image="valkey/valkey:8.0-alpine", + package="busybox", + installed_version="1.36.1-r0", + cve_id="CVE-2024-9999", + severity=Severity.HIGH, + fixed_version=None, + ), + ] + monkeypatch.setattr( + "scripts.cve_scan.sweep.scan_images", + lambda images, scanner, threshold, **_kw: findings, + ) + monkeypatch.setattr( + "scripts.cve_scan.image_matrix.urllib.request.urlopen", + lambda *a, **kw: _mock_urlopen_response(SAMPLE_VERSIONS), + ) + + settings = load_settings() + run_sweep( + repo_full_name="valkey-io/valkey-container", + settings=settings, + dry_run=False, + ) + + summary = summary_file.read_text() + assert "CVE Scan Summary" in summary + assert "CVE-2024-9999" in summary + assert "Unresolved findings" in summary diff --git a/tests/test_cve_versions_and_platforms.py b/tests/test_cve_versions_and_platforms.py new file mode 100644 index 00000000..e9cdb700 --- /dev/null +++ b/tests/test_cve_versions_and_platforms.py @@ -0,0 +1,368 @@ +"""Tests for B6 multi-arch scanner and B1 versions output. + +B6 (scanner.py): multi-arch scan + dedup +B1 (sweep.py): versions output derivation +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.cve_scan.config import CveScanSettings +from scripts.cve_scan.models import Classification, Finding, Severity + + +def _make_settings(severity: Severity = Severity.HIGH) -> CveScanSettings: + return CveScanSettings( + versions_url="https://example.com/versions.json", + repository="valkey/valkey", + include_unstable=False, + scanner="trivy", + severity_threshold=severity, + platforms=["linux/amd64", "linux/arm64"], + ) + + +def _make_classification( + cve_id: str = "CVE-2024-1234", + package: str = "openssl", + image: str = "valkey/valkey:8.0-alpine", + fixable: bool = False, +) -> Classification: + return Classification( + finding=Finding( + image=image, + package=package, + installed_version="3.0.12-r0", + cve_id=cve_id, + severity=Severity.HIGH, + fixed_version="3.0.13-r0", + ), + fixable=fixable, + rationale="Test rationale", + ) + + +class TestMultiArchScanAndDedup: + """B6: scanner scans per platform and deduplicates findings.""" + + def test_single_platform_no_dedup_needed(self) -> None: + """Single platform: no dedup needed.""" + from scripts.cve_scan.scanner import scan_images + + finding = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/amd64", + ) + with patch("scripts.cve_scan.scanner.scan_image", return_value=[finding]): + results = scan_images( + ["valkey/valkey:8.0"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64"], + ) + assert len(results) == 1 + assert results[0].cve_id == "CVE-2024-1234" + assert results[0].platform == "linux/amd64" + + def test_two_platforms_same_findings_deduped_to_one(self) -> None: + """Same finding on two platforms -> kept distinct (per-platform verification).""" + from scripts.cve_scan.scanner import scan_images + + finding_amd64 = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/amd64", + ) + finding_arm64 = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/arm64", + ) + + call_count = [0] + + def mock_scan_image(image, scanner, platform=None): + call_count[0] += 1 + if platform == "linux/amd64": + return [finding_amd64] + elif platform == "linux/arm64": + return [finding_arm64] + return [] + + with patch("scripts.cve_scan.scanner.scan_image", side_effect=mock_scan_image): + results = scan_images( + ["valkey/valkey:8.0"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64", "linux/arm64"], + ) + # Per-platform findings kept distinct for base verification + assert len(results) == 2 + platforms_found = {r.platform for r in results} + assert platforms_found == {"linux/amd64", "linux/arm64"} + + def test_two_platforms_unique_findings_both_kept(self) -> None: + """Different CVEs on different platforms -> both kept.""" + from scripts.cve_scan.scanner import scan_images + + finding_cve1 = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1111", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/amd64", + ) + finding_cve2 = Finding( + image="valkey/valkey:8.0", + package="zlib", + installed_version="1.2.13", + cve_id="CVE-2024-2222", + severity=Severity.HIGH, + fixed_version="1.2.14", + platform="linux/arm64", + ) + call_count = [0] + + def mock_scan_image(image, scanner, platform=None): + call_count[0] += 1 + # amd64 returns cve1, arm64 returns cve2 + if platform == "linux/amd64": + return [finding_cve1] + elif platform == "linux/arm64": + return [finding_cve2] + return [] + + with patch("scripts.cve_scan.scanner.scan_image", side_effect=mock_scan_image): + results = scan_images( + ["valkey/valkey:8.0"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64", "linux/arm64"], + ) + + assert len(results) == 2 + assert call_count[0] == 2 # one call per platform + + def test_four_platforms_same_finding_deduped(self) -> None: + """Same finding across 4 platforms -> four results (per-platform).""" + from scripts.cve_scan.scanner import scan_images + + def mock_scan_image(image, scanner, platform=None): + return [Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-9999", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform=platform or "", + )] + + with patch("scripts.cve_scan.scanner.scan_image", side_effect=mock_scan_image): + results = scan_images( + ["valkey/valkey:8.0"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/ppc64le"], + ) + # Per-platform findings kept distinct + assert len(results) == 4 + platforms_found = {r.platform for r in results} + assert platforms_found == {"linux/amd64", "linux/arm64", "linux/arm/v7", "linux/ppc64le"} + + def test_platform_passed_to_trivy_command(self) -> None: + """Trivy --platform flag is passed for each platform.""" + from scripts.cve_scan.scanner import _build_command + + cmd_amd64 = _build_command("trivy", "valkey/valkey:8.0", "linux/amd64") + assert "--platform" in cmd_amd64 + assert "linux/amd64" in cmd_amd64 + + cmd_arm64 = _build_command("trivy", "valkey/valkey:8.0", "linux/arm64") + assert "--platform" in cmd_arm64 + assert "linux/arm64" in cmd_arm64 + + def test_platform_omitted_when_none(self) -> None: + """Trivy --platform flag is NOT added when platform is None.""" + from scripts.cve_scan.scanner import _build_command + + cmd = _build_command("trivy", "valkey/valkey:8.0", platform=None) + assert "--platform" not in cmd + + def test_default_platforms_are_four(self) -> None: + """Default platform list has exactly 4 entries (verified published set).""" + from scripts.cve_scan.config import DEFAULT_PLATFORMS + + assert len(DEFAULT_PLATFORMS) == 4 + assert "linux/amd64" in DEFAULT_PLATFORMS + assert "linux/arm64" in DEFAULT_PLATFORMS + assert "linux/arm/v7" in DEFAULT_PLATFORMS + assert "linux/ppc64le" in DEFAULT_PLATFORMS + assert "linux/386" not in DEFAULT_PLATFORMS + + def test_cve_scan_platforms_env_var(self) -> None: + """CVE_SCAN_PLATFORMS env var is parsed into platforms list.""" + import os + from unittest.mock import patch as _patch + + with _patch.dict(os.environ, { + "CVE_SCAN_PLATFORMS": "linux/amd64,linux/arm64", + }, clear=False): + from scripts.cve_scan.config import load_settings + settings = load_settings() + + assert settings.platforms == ["linux/amd64", "linux/arm64"] + + def test_multiple_images_multiple_platforms(self) -> None: + """2 images x 2 platforms = 4 scanner invocations.""" + from scripts.cve_scan.scanner import scan_images + + call_args = [] + + def mock_scan_image(image, scanner, platform=None): + call_args.append((image, platform)) + if image == "valkey/valkey:8.0": + return [Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform=platform or "", + )] + return [] + + with patch("scripts.cve_scan.scanner.scan_image", side_effect=mock_scan_image): + results = scan_images( + ["valkey/valkey:8.0", "valkey/valkey:9.1"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64", "linux/arm64"], + ) + + # 2 images x 2 platforms = 4 calls + assert len(call_args) == 4 + assert ("valkey/valkey:8.0", "linux/amd64") in call_args + assert ("valkey/valkey:8.0", "linux/arm64") in call_args + assert ("valkey/valkey:9.1", "linux/amd64") in call_args + assert ("valkey/valkey:9.1", "linux/arm64") in call_args + # 8.0 has findings on 2 platforms (kept distinct), 9.1 has none + assert len(results) == 2 + + def test_below_threshold_finding_excluded(self) -> None: + """Findings below threshold are excluded even in multi-arch mode.""" + from scripts.cve_scan.scanner import scan_images + + low_finding = Finding( + image="valkey/valkey:8.0", + package="curl", + installed_version="7.88.0", + cve_id="CVE-2024-LOW", + severity=Severity.LOW, + fixed_version="7.89.0", + platform="linux/amd64", + ) + with patch("scripts.cve_scan.scanner.scan_image", return_value=[low_finding]): + results = scan_images( + ["valkey/valkey:8.0"], + "trivy", + Severity.HIGH, + platforms=["linux/amd64"], + ) + assert len(results) == 0 + + def test_same_platform_duplicates_collapsed(self) -> None: + """Exact duplicate findings on same platform are still collapsed.""" + from scripts.cve_scan.scanner import _dedup_findings + + f1 = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/amd64", + ) + f2 = Finding( + image="valkey/valkey:8.0", + package="openssl", + installed_version="3.0.12", + cve_id="CVE-2024-1234", + severity=Severity.HIGH, + fixed_version="3.0.13", + platform="linux/amd64", + ) + results = _dedup_findings([f1, f2]) + assert len(results) == 1 + + +class TestVersionsOutput: + """B1: sweep emits versions output derived from fixable images.""" + + def test_fixable_versions_derive_correctly(self) -> None: + """Image tags are correctly mapped to version lines.""" + from scripts.cve_scan.sweep import _fixable_versions + + fixable = [ + _make_classification(image="valkey/valkey:8.0-alpine", fixable=True), + _make_classification(image="valkey/valkey:8.0", fixable=True), + _make_classification(image="valkey/valkey:9.1-alpine", fixable=True), + ] + versions = _fixable_versions(fixable) + assert versions == ["8.0", "9.1"] + + def test_versions_deduplicated_and_sorted(self) -> None: + """Duplicate version lines from multiple images are deduplicated.""" + from scripts.cve_scan.sweep import _fixable_versions + + fixable = [ + _make_classification(image="valkey/valkey:8.0-alpine", fixable=True), + _make_classification(image="valkey/valkey:8.0-alpine", fixable=True), # dup + _make_classification(image="valkey/valkey:7.2", fixable=True), + ] + versions = _fixable_versions(fixable) + assert versions == ["7.2", "8.0"] + + def test_empty_fixable_returns_empty(self) -> None: + """Empty fixable list -> empty versions list.""" + from scripts.cve_scan.sweep import _fixable_versions + + assert _fixable_versions([]) == [] + + def test_versions_output_written_to_github_output( + self, tmp_path: Path, monkeypatch + ) -> None: + """versions= is written alongside fixable= to GITHUB_OUTPUT.""" + from scripts.cve_scan.sweep import _emit_outputs + + output_file = tmp_path / "github_output" + output_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + _emit_outputs(True, versions=["8.0", "9.1"]) + + content = output_file.read_text() + assert "fixable=true" in content + assert "versions=8.0 9.1" in content diff --git a/tests/test_image_matrix.py b/tests/test_image_matrix.py new file mode 100644 index 00000000..206ce799 --- /dev/null +++ b/tests/test_image_matrix.py @@ -0,0 +1,333 @@ +"""Tests for scripts/cve_scan/image_matrix.py. + +Covers static passthrough, dynamic derivation (mocked fetch), unstable +handling, single-variant versions, error cases, and single-fetch behavior. +""" + +from __future__ import annotations + +import json +from io import BytesIO +from typing import Any +from unittest.mock import patch + +import pytest + +from scripts.cve_scan.config import CveScanSettings +from scripts.cve_scan.image_matrix import ( + MatrixResolutionError, + _derive_images, + _fetch_versions_json, + resolve_matrix, +) +from scripts.cve_scan.models import Severity + +SAMPLE_VERSIONS: dict[str, Any] = { + "7.2": { + "version": "7.2.13", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, + "8.0": { + "version": "8.0.9", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, + "8.1": { + "version": "8.1.8", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, + "9.0": { + "version": "9.0.4", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, + "9.1": { + "version": "9.1.0", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, + "unstable": { + "version": "unstable", + "debian": {"version": "trixie"}, + "alpine": {"version": "3.23"}, + }, +} + +#: A version with only alpine (no debian) +SAMPLE_ALPINE_ONLY: dict[str, Any] = { + "10.0": { + "version": "10.0.0", + "alpine": {"version": "3.23"}, + }, +} + +#: A version with only debian (no alpine) +SAMPLE_DEBIAN_ONLY: dict[str, Any] = { + "10.1": { + "version": "10.1.0", + "debian": {"version": "trixie"}, + }, +} + + +def _make_settings( + *, + images: list[str] | None = None, + versions_url: str = "https://example.com/versions.json", + repository: str = "valkey/valkey", + include_unstable: bool = False, +) -> CveScanSettings: + """Helper to build a CveScanSettings with test defaults.""" + return CveScanSettings( + versions_url=versions_url, + repository=repository, + include_unstable=include_unstable, + scanner="trivy", + severity_threshold=Severity.HIGH, + images=images or [], + ) + + +def _mock_urlopen(data: Any, status: int = 200): + """Create a mock context manager for urllib.request.urlopen.""" + body = json.dumps(data).encode("utf-8") if not isinstance(data, bytes) else data + resp = BytesIO(body) + resp.status = status # type: ignore[attr-defined] + resp.__enter__ = lambda self: self # type: ignore[attr-defined] + resp.__exit__ = lambda self, *a: None # type: ignore[attr-defined] + return resp + + +class TestStaticPassthrough: + """Static override mode: settings.images is non-empty, return as-is.""" + + def test_returns_static_list_with_empty_base_map(self) -> None: + settings = _make_settings(images=["img:1", "img:2"]) + images, base_map = resolve_matrix(settings) + assert images == ["img:1", "img:2"] + assert base_map == {} + + def test_single_image_static(self) -> None: + settings = _make_settings(images=["valkey/valkey:8.0-alpine"]) + images, base_map = resolve_matrix(settings) + assert images == ["valkey/valkey:8.0-alpine"] + assert base_map == {} + + +class TestDynamicDerivation: + """Dynamic mode: fetch versions.json and derive images + base_map.""" + + def test_derives_correct_sorted_tags(self) -> None: + """All stable versions produce both alpine and bare tags, sorted.""" + settings = _make_settings(include_unstable=False) + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_VERSIONS) + images, base_map = resolve_matrix(settings) + + expected = sorted([ + "valkey/valkey:7.2-alpine", + "valkey/valkey:7.2", + "valkey/valkey:8.0-alpine", + "valkey/valkey:8.0", + "valkey/valkey:8.1-alpine", + "valkey/valkey:8.1", + "valkey/valkey:9.0-alpine", + "valkey/valkey:9.0", + "valkey/valkey:9.1-alpine", + "valkey/valkey:9.1", + ]) + assert images == expected + + def test_base_map_correct_for_dynamic(self) -> None: + """Dynamic settings returns alpine -> alpine:X.Y, debian -> debian:Z-slim.""" + settings = _make_settings(include_unstable=False) + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_VERSIONS) + images, base_map = resolve_matrix(settings) + + # Check alpine variant mapping + assert base_map["valkey/valkey:9.1-alpine"] == "alpine:3.23" + assert base_map["valkey/valkey:7.2-alpine"] == "alpine:3.23" + # Check debian variant mapping (bare tag) + assert base_map["valkey/valkey:9.1"] == "debian:trixie-slim" + assert base_map["valkey/valkey:7.2"] == "debian:trixie-slim" + # Should have 10 entries (5 versions x 2 variants, unstable excluded) + assert len(base_map) == 10 + + def test_unstable_skipped_by_default(self) -> None: + """Unstable version is excluded when include_unstable=False.""" + settings = _make_settings(include_unstable=False) + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_VERSIONS) + images, base_map = resolve_matrix(settings) + + assert "valkey/valkey:unstable-alpine" not in images + assert "valkey/valkey:unstable" not in images + assert "valkey/valkey:unstable-alpine" not in base_map + assert "valkey/valkey:unstable" not in base_map + + def test_unstable_included_when_requested(self) -> None: + """Unstable version is included when include_unstable=True.""" + settings = _make_settings(include_unstable=True) + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_VERSIONS) + images, base_map = resolve_matrix(settings) + + assert "valkey/valkey:unstable-alpine" in images + assert "valkey/valkey:unstable" in images + assert base_map["valkey/valkey:unstable-alpine"] == "alpine:3.23" + assert base_map["valkey/valkey:unstable"] == "debian:trixie-slim" + + def test_single_variant_alpine_only(self) -> None: + """Version with only alpine variant produces only alpine tag.""" + settings = _make_settings(repository="myrepo/myimg") + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_ALPINE_ONLY) + images, base_map = resolve_matrix(settings) + + assert images == ["myrepo/myimg:10.0-alpine"] + assert "myrepo/myimg:10.0-alpine" in base_map + + def test_single_variant_debian_only(self) -> None: + """Version with only debian variant produces only bare tag.""" + settings = _make_settings(repository="myrepo/myimg") + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_DEBIAN_ONLY) + images, base_map = resolve_matrix(settings) + + assert images == ["myrepo/myimg:10.1"] + assert "myrepo/myimg:10.1" in base_map + + def test_custom_repository(self) -> None: + """Custom repository prefix is applied to all derived tags.""" + settings = _make_settings(repository="ghcr.io/valkey-io/valkey") + + payload = {"1.0": {"debian": {"version": "bookworm"}, "alpine": {"version": "3.20"}}} + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(payload) + images, base_map = resolve_matrix(settings) + + assert "ghcr.io/valkey-io/valkey:1.0-alpine" in images + assert "ghcr.io/valkey-io/valkey:1.0" in images + + def test_single_fetch_for_both_images_and_base_map(self) -> None: + """resolve_matrix makes exactly one HTTP fetch.""" + settings = _make_settings(include_unstable=False) + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(SAMPLE_VERSIONS) + resolve_matrix(settings) + + assert mock_open.call_count == 1 + + +class TestDynamicErrors: + """Dynamic mode failure cases raise MatrixResolutionError.""" + + def test_network_error_raises(self) -> None: + """URLError from fetch raises MatrixResolutionError.""" + from urllib.error import URLError + + settings = _make_settings() + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.side_effect = URLError("connection refused") + with pytest.raises(MatrixResolutionError, match="Failed to fetch"): + resolve_matrix(settings) + + def test_invalid_json_raises(self) -> None: + """Malformed JSON raises MatrixResolutionError.""" + settings = _make_settings() + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(b"not json {{{{") + with pytest.raises(MatrixResolutionError, match="Invalid JSON"): + resolve_matrix(settings) + + def test_empty_derivation_raises(self) -> None: + """Manifest with no valid versions raises MatrixResolutionError.""" + settings = _make_settings(include_unstable=False) + + # Only unstable, and include_unstable=False -> zero images + payload = {"unstable": {"debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}} + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen(payload) + with pytest.raises(MatrixResolutionError, match="zero images"): + resolve_matrix(settings) + + def test_non_dict_manifest_raises(self) -> None: + """Manifest that is not a JSON object raises MatrixResolutionError.""" + settings = _make_settings() + + with patch("scripts.cve_scan.image_matrix.urllib.request.urlopen") as mock_open: + mock_open.return_value = _mock_urlopen([1, 2, 3]) + with pytest.raises(MatrixResolutionError, match="must be a JSON object"): + resolve_matrix(settings) + + +class TestDeriveImages: + """Direct tests for the _derive_images helper.""" + + def test_deterministic_sort(self) -> None: + """Output is always sorted regardless of input ordering.""" + versions = { + "9.0": {"debian": {}, "alpine": {}}, + "7.2": {"debian": {}, "alpine": {}}, + } + result = _derive_images(versions, "r", include_unstable=False) + assert result == ["r:7.2", "r:7.2-alpine", "r:9.0", "r:9.0-alpine"] + + def test_non_dict_version_value_skipped(self) -> None: + """Non-dict values in the manifest are silently skipped.""" + versions = {"7.2": {"debian": {}}, "meta": "not a dict"} + result = _derive_images(versions, "r", include_unstable=False) + assert result == ["r:7.2"] + + +# RC-era snapshot: derivation must key off version-line keys, not full versions. +RC_ERA_VERSIONS: dict[str, Any] = { + "7.2": {"version": "7.2.12", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "8.0": {"version": "8.0.7", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "8.1": {"version": "8.1.6", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "9.0": {"version": "9.0.3", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "9.1": {"version": "9.1.0-rc2", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, + "unstable": {"version": "unstable", "debian": {"version": "trixie"}, "alpine": {"version": "3.23"}}, +} + + +class TestRcEraDerivation: + """During an RC window, derivation must still yield the version-line tags.""" + + def test_rc_line_derives_bare_and_alpine_line_tags(self) -> None: + images = _derive_images(RC_ERA_VERSIONS, "valkey/valkey", include_unstable=False) + assert "valkey/valkey:9.1" in images + assert "valkey/valkey:9.1-alpine" in images + + def test_rc_full_version_never_used_in_tags(self) -> None: + images = _derive_images(RC_ERA_VERSIONS, "valkey/valkey", include_unstable=False) + assert not any("rc" in img for img in images) + + def test_rc_era_matrix_is_complete(self) -> None: + images = _derive_images(RC_ERA_VERSIONS, "valkey/valkey", include_unstable=False) + assert images == sorted( + [ + "valkey/valkey:7.2", + "valkey/valkey:7.2-alpine", + "valkey/valkey:8.0", + "valkey/valkey:8.0-alpine", + "valkey/valkey:8.1", + "valkey/valkey:8.1-alpine", + "valkey/valkey:9.0", + "valkey/valkey:9.0-alpine", + "valkey/valkey:9.1", + "valkey/valkey:9.1-alpine", + ] + ) diff --git a/tests/test_version_compare.py b/tests/test_version_compare.py new file mode 100644 index 00000000..d81e4db7 --- /dev/null +++ b/tests/test_version_compare.py @@ -0,0 +1,208 @@ +"""Tests for scripts/cve_scan/version_compare.py. + +Covers mocked dpkg/apk comparisons, fail-closed error handling, and the B3 +regression (Debian '+' suffix ordering: 1.0-1 < 1.0+deb12u1, via the native +dpkg path with mocked docker). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.cve_scan.version_compare import _ALPINE_COMPARATOR_IMAGE, compare_versions + + +def _docker_result(returncode: int = 0, stdout: str = "", stderr: str = ""): + """Build a mock subprocess.CompletedProcess.""" + result = MagicMock() + result.returncode = returncode + result.stdout = stdout + result.stderr = stderr + return result + + +class TestCompareVersionsDebian: + """compare_versions with flavor='debian' uses dpkg semantics.""" + + def test_a_less_than_b(self) -> None: + """a < b: dpkg lt exits 0.""" + def fake_run(cmd, **kwargs): + # First call: dpkg a lt b -> exit 0 (a < b) + return _docker_result(returncode=0) + + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=[ + _docker_result(returncode=0), # lt check: True + ]): + result = compare_versions("1.0", "1.1", "debian") + assert result == -1 + + def test_a_equals_b(self) -> None: + """a == b: dpkg lt exits 1, dpkg eq exits 0.""" + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=[ + _docker_result(returncode=1), # lt check: False + _docker_result(returncode=0), # eq check: True + ]): + result = compare_versions("1.0", "1.0", "debian") + assert result == 0 + + def test_a_greater_than_b(self) -> None: + """a > b: dpkg lt exits 1, dpkg eq exits 1.""" + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=[ + _docker_result(returncode=1), # lt check: False + _docker_result(returncode=1), # eq check: False -> a > b + ]): + result = compare_versions("1.1", "1.0", "debian") + assert result == 1 + + def test_docker_failure_returns_none(self) -> None: + """Docker failure (unexpected exit code 125) -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare.subprocess.run", return_value= + _docker_result(returncode=125, stderr="docker: Error response from daemon"), + ): + result = compare_versions("1.0", "1.1", "debian") + assert result is None + + def test_unexpected_exit_code_127_returns_none(self) -> None: + """Unexpected exit code 127 (command not found) -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare.subprocess.run", return_value= + _docker_result(returncode=127, stderr="docker: command not found"), + ): + result = compare_versions("1.0", "1.1", "debian") + assert result is None + + def test_unexpected_exit_code_126_on_eq_returns_none(self) -> None: + """Unexpected exit code on the eq check -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=[ + _docker_result(returncode=1), # lt check: False (legitimate) + _docker_result(returncode=126), # eq check: unexpected + ]): + result = compare_versions("1.0", "1.1", "debian") + assert result is None + + def test_timeout_returns_none(self) -> None: + """TimeoutExpired -> None (fail-closed).""" + import subprocess + with patch("scripts.cve_scan.version_compare.subprocess.run", + side_effect=subprocess.TimeoutExpired("docker", 60)): + result = compare_versions("1.0", "1.1", "debian") + assert result is None + + def test_oserror_returns_none(self) -> None: + """OSError (docker not found) -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare.subprocess.run", + side_effect=OSError("No such file")): + result = compare_versions("1.0", "1.1", "debian") + assert result is None + + def test_plus_suffix_ordering(self) -> None: + """1.0-1 < 1.0+deb12u1 per Debian rules: dpkg lt exits 0.""" + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=[ + _docker_result(returncode=0), # lt: True -> -1 + ]): + result = compare_versions("1.0-1", "1.0+deb12u1", "debian") + assert result == -1 + + def test_uses_provided_base_image(self) -> None: + """base_image parameter is passed to docker run.""" + calls = [] + + def capture_run(cmd, **kwargs): + calls.append(cmd) + return _docker_result(returncode=0) + + with patch("scripts.cve_scan.version_compare.subprocess.run", side_effect=capture_run): + compare_versions("1.0", "1.1", "debian", base_image="debian:bookworm-slim") + + assert any("debian:bookworm-slim" in " ".join(c) for c in calls) + + +class TestCompareVersionsAlpine: + """compare_versions with flavor='alpine' uses apk semantics.""" + + def test_a_less_than_b(self) -> None: + """apk version -t prints '<' -> -1.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, "<", "")): + result = compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + assert result == -1 + + def test_a_equals_b(self) -> None: + """apk version -t prints '=' -> 0.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, "=", "")): + result = compare_versions("3.0.13-r0", "3.0.13-r0", "alpine") + assert result == 0 + + def test_a_greater_than_b(self) -> None: + """apk version -t prints '>' -> 1.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, ">", "")): + result = compare_versions("3.0.14-r0", "3.0.13-r0", "alpine") + assert result == 1 + + def test_unexpected_output_returns_none(self) -> None: + """Unexpected apk output -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, "UNKNOWN", "")): + result = compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + assert result is None + + def test_docker_failure_returns_none(self) -> None: + """Non-zero exit -> None.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(1, "", "error")): + result = compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + assert result is None + + def test_timeout_returns_none(self) -> None: + """rc == -1 (timeout/OSError from _run_docker) -> None.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(-1, "", "timeout")): + result = compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + assert result is None + + def test_argv_no_shell(self) -> None: + """Alpine path invokes argv directly: no 'sh' or '-c' in command.""" + calls: list[list[str]] = [] + + def capture(cmd: list[str]) -> "tuple[int, str, str]": + calls.append(cmd) + return (0, "<", "") + + with patch("scripts.cve_scan.version_compare._run_docker", side_effect=capture): + compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + + assert len(calls) == 1 + cmd = calls[0] + assert "sh" not in cmd, f"Shell invocation found in command: {cmd}" + assert "-c" not in cmd, f"Shell flag found in command: {cmd}" + # Verify argv structure + assert cmd == [ + "docker", "run", "--rm", + _ALPINE_COMPARATOR_IMAGE, + "apk", "version", "-t", "3.0.12-r0", "3.0.13-r0", + ] + + def test_stdout_whitespace_stripped(self) -> None: + """Stdout with trailing newline/space is stripped before comparison.""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, " > \n", "")): + result = compare_versions("3.0.14-r0", "3.0.13-r0", "alpine") + assert result == 1 + + def test_empty_stdout_returns_none(self) -> None: + """Empty stdout (rc=0 but no output) -> None (fail-closed).""" + with patch("scripts.cve_scan.version_compare._run_docker", + return_value=(0, "", "")): + result = compare_versions("3.0.12-r0", "3.0.13-r0", "alpine") + assert result is None + + +class TestCompareVersionsUnknownFlavor: + """Unknown flavor returns None (fail-closed).""" + + def test_unknown_flavor_returns_none(self) -> None: + result = compare_versions("1.0", "1.1", "rpm") + assert result is None