diff --git a/.github/workflows/trivy-changes.yml b/.github/workflows/trivy-changes.yml new file mode 100644 index 0000000000..612ce891cc --- /dev/null +++ b/.github/workflows/trivy-changes.yml @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Trivy Changes + +on: + pull_request: + merge_group: + types: [checks_requested] + workflow_dispatch: + inputs: + base_sha: + description: Base commit SHA to compare + required: true + type: string + head_sha: + description: Candidate commit SHA to compare + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Detect deployment configuration changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.default.outputs.should_run || steps.changed.outputs.any_modified }} + steps: + - id: default + if: github.event_name != 'pull_request' + run: echo "should_run=true" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name == 'pull_request' + with: + persist-credentials: false + + - id: changed + if: github.event_name == 'pull_request' + uses: tj-actions/changed-files@aa08304bd477b800d468db44fe10f6c61f7f7b11 # v42.1.0 + with: + # `any_modified` covers deletions, which `any_changed` omits, and a + # failed diff has to fail the job: both otherwise report no relevant + # change, and removing the scanner or a value fixture would skip the + # scan behind a green status. + fail_on_initial_diff_error: true + files: | + deploy/docker/** + deploy/helm/** + deploy/kube/** + .trivyignore.yaml + flake.nix + flake.lock + tasks/scripts/trivy-scan.sh + tasks/scripts/trivy-scan-test.sh + tasks/scripts/trivy-config-report.jq + .github/workflows/trivy-scan.yml + .github/workflows/trivy-changes.yml + + scan: + name: Scan changed deployment configuration + needs: changes + if: needs.changes.outputs.should_run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || inputs.base_sha }} + HEAD_REF: ${{ inputs.head_sha || github.sha }} + defaults: + run: + shell: nix develop --command bash -euo pipefail {0} + steps: + - name: Check out candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.HEAD_REF }} + persist-credentials: false + + - name: Check out baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BASE_REF }} + path: .trivy-base + persist-credentials: false + + - name: Set up Nix + uses: ./.github/actions/setup-nix + + - name: Test report comparison + run: tasks/scripts/trivy-scan-test.sh + + - name: Validate candidate ignore policy + run: tasks/scripts/trivy-scan.sh validate-ignore + + # Ignore-policy changes take effect only after merge. Applying the + # baseline policy to both scans prevents a candidate from exempting a new + # finding in the same change that introduces it. + - name: Prepare baseline ignore policy + run: | + if [ -f .trivy-base/.trivyignore.yaml ]; then + cp .trivy-base/.trivyignore.yaml "$RUNNER_TEMP/trivy-baseline-ignore.yaml" + else + printf 'misconfigurations: []\n' >"$RUNNER_TEMP/trivy-baseline-ignore.yaml" + fi + + - name: Scan baseline + env: + TRIVY_SOURCE_ROOT: ${{ github.workspace }}/.trivy-base + TRIVY_IGNORE_FILE: ${{ runner.temp }}/trivy-baseline-ignore.yaml + TRIVY_REPORT_DIR: ${{ runner.temp }}/trivy-base + run: | + mkdir -p "$TRIVY_REPORT_DIR" + "$GITHUB_WORKSPACE/tasks/scripts/trivy-scan.sh" config + + - name: Scan candidate + env: + TRIVY_IGNORE_FILE: ${{ runner.temp }}/trivy-baseline-ignore.yaml + TRIVY_REPORT_DIR: ${{ runner.temp }}/trivy-head + run: | + mkdir -p "$TRIVY_REPORT_DIR" + tasks/scripts/trivy-scan.sh config + + - name: Reject new high or critical findings + run: | + tasks/scripts/trivy-scan.sh gate-config-diff \ + "$RUNNER_TEMP/trivy-base" "$RUNNER_TEMP/trivy-head" + + - name: Upload reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: trivy-changes-${{ github.run_id }} + path: | + ${{ runner.temp }}/trivy-base + ${{ runner.temp }}/trivy-head + if-no-files-found: ignore + retention-days: 14 + + result: + name: OpenShell / Trivy Changes + needs: [changes, scan] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check scan result + env: + CHANGES_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.should_run }} + SCAN_RESULT: ${{ needs.scan.result }} + run: | + set -euo pipefail + if [ "$CHANGES_RESULT" != "success" ]; then + echo "::error::Change detection concluded $CHANGES_RESULT." + exit 1 + fi + if [ "$SHOULD_RUN" = "true" ] && [ "$SCAN_RESULT" != "success" ]; then + echo "::error::Trivy scan concluded $SCAN_RESULT." + exit 1 + fi + if [ "$SHOULD_RUN" != "true" ]; then + echo "No Helm or Dockerfile changes to scan." + fi diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 0000000000..ccfe80bd69 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Trivy Scan + +# Manual or reusable scan of deployment configuration and supplied OCI +# artifacts. Findings are informational by default; scanner errors remain fatal. + +on: + workflow_call: + inputs: + images: + description: Newline-separated image references to scan + type: string + default: "" + charts: + description: Newline-separated packaged Helm chart OCI references + type: string + default: "" + severity: + description: Severities that fail the workflow + type: string + default: HIGH,CRITICAL + ignore-unfixed: + description: Ignore image vulnerabilities with no upstream fix + type: boolean + default: true + fail-on-findings: + description: Fail the run on findings instead of warning + type: boolean + default: false + upload-sarif: + description: Upload results to GitHub Code Scanning + type: boolean + default: true + secrets: + CACHIX_AUTH_TOKEN: + description: Token used to write Nix build outputs to Cachix + + workflow_dispatch: + inputs: + images: + description: Newline-separated image references to scan + type: string + default: "" + charts: + description: Newline-separated packaged Helm chart OCI references to scan + type: string + default: "" + severity: + description: Severities that fail the workflow + type: string + default: HIGH,CRITICAL + ignore-unfixed: + description: Ignore image vulnerabilities with no upstream fix + type: boolean + default: true + fail-on-findings: + description: Fail the run on findings instead of warning + type: boolean + default: false + upload-sarif: + description: Upload results to GitHub Code Scanning + type: boolean + default: true + +permissions: + contents: read + +defaults: + run: + shell: nix develop --command bash -euo pipefail {0} + +env: + TRIVY_SEVERITY: ${{ inputs.severity || 'HIGH,CRITICAL' }} + TRIVY_REPORT_DIR: reports/trivy + +jobs: + scan: + name: OpenShell / Trivy (informational) + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + sarif-batches: ${{ steps.sarif.outputs.batches }} + artifact-id: ${{ steps.reports.outputs.artifact-id }} + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up Nix + uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ github.event_name != 'pull_request' && secrets.CACHIX_AUTH_TOKEN || '' }} + + - name: Test scan reporting + run: tasks/scripts/trivy-scan-test.sh + + - name: Scan configuration + id: config + env: + CHARTS: ${{ inputs.charts }} + run: | + args=() + while IFS= read -r ref; do + [ -n "$ref" ] || continue + args+=(--chart-ref "$ref") + done <<<"$CHARTS" + tasks/scripts/trivy-scan.sh config "${args[@]}" + + - name: Scan images + id: images + if: ${{ !cancelled() }} + env: + IMAGES: ${{ inputs.images }} + TRIVY_IGNORE_UNFIXED: ${{ inputs.ignore-unfixed }} + run: | + refs=() + while IFS= read -r ref; do + [ -n "$ref" ] || continue + refs+=("$ref") + done <<<"$IMAGES" + if [ "${#refs[@]}" -gt 0 ]; then + tasks/scripts/trivy-scan.sh images "${refs[@]}" + fi + + - name: Prepare consolidated SARIF uploads + id: sarif + if: >- + ${{ + !cancelled() + && steps.config.conclusion == 'success' + && steps.images.conclusion == 'success' + && inputs.upload-sarif + }} + run: tasks/scripts/trivy-scan.sh prepare-sarif + + - name: Upload reports + id: reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: trivy-${{ github.run_id }} + path: reports/trivy + if-no-files-found: ignore + retention-days: 14 + + - name: Report findings + if: >- + ${{ + !cancelled() + && steps.config.conclusion == 'success' + && steps.images.conclusion == 'success' + }} + env: + FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + run: | + set +e + tasks/scripts/trivy-scan.sh gate + status=$? + set -e + case "$status" in + 0) echo "No findings at ${TRIVY_SEVERITY}." ;; + 10) + if [ "$FAIL_ON_FINDINGS" = "true" ]; then + echo "::error::Trivy findings at ${TRIVY_SEVERITY}." + exit 1 + fi + echo "::warning::Trivy findings at ${TRIVY_SEVERITY}; this check is informational." + ;; + *) echo "::error::Trivy could not evaluate the reports (exit $status)."; exit "$status" ;; + esac + + upload-sarif: + name: Publish Trivy SARIF (${{ matrix.batch }}) + needs: scan + # Findings may fail the scan job after reports are prepared. Publish those + # complete results too, but never publish an incomplete/failed scan. + if: >- + ${{ + !cancelled() + && inputs.upload-sarif + && needs.scan.outputs.sarif-batches != '' + && needs.scan.outputs.artifact-id != '' + }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + batch: ${{ fromJSON(needs.scan.outputs.sarif-batches) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.scan.outputs.artifact-id }} + merge-multiple: true + path: reports/trivy + + # One configuration run, plus one run per image/platform or packaged + # chart. Each batch holds at most GitHub's limit of 20 SARIF runs. + - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: reports/trivy/code-scanning/uploads/${{ matrix.batch }} diff --git a/.gitignore b/.gitignore index 3ef1a37697..a7867fe2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ pip-delete-this-directory.txt coverage.out coverage/ htmlcov/ + +# Trivy scan reports (tasks/scripts/trivy-scan-*.sh) +/reports/ .tox/ .nox/ .coverage diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000000..5e6afc010e --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# False positives only; unimplemented hardening and accepted risks stay visible. +# The script passes this YAML file explicitly and requires each exception to use +# `**/`, which matches source and packaged-chart paths. +# Trivy cannot scope Helm exceptions to one occurrence, so keep IDs and paths +# narrow. + +misconfigurations: + # The namespace comes from `helm install -n`, not the rendered workload. + - id: KSV-0110 + paths: + - "**/statefulset.yaml" + - "**/deployment.yaml" + statement: >- + An artifact of rendering the chart outside a cluster. The namespace is + supplied at install time. + + # The ConfigMap stores an external Secret key name, not a credential. + - id: KSV-01010 + paths: + - "**/gateway-config.yaml" + statement: >- + The ConfigMap holds the name of a key in an external Secret, not a + credential. + + # Trivy cannot add this project's GHCR namespace to its trusted registries. + - id: KSV-0125 + paths: + - "**/statefulset.yaml" + - "**/deployment.yaml" + statement: >- + Images come from ghcr.io/nvidia/openshell, this project's own registry. diff --git a/CI.md b/CI.md index c29abdba0c..a84bd90638 100644 --- a/CI.md +++ b/CI.md @@ -24,7 +24,9 @@ Three opt-in labels enable the long-running E2E suites: When multiple labels are present, `Branch E2E Checks` builds each generic multi-architecture artifact set once and fans out enabled suites in parallel. Runtime-specific reusable workflows define the Docker, Podman, VM, and Kubernetes lanes. Composite actions own the replaceable Podman, KVM, kind, and mise setup. Each lane depends only on the artifact categories it consumes: VM does not wait for container-driver artifacts or supervisor images, and GPU does not wait for the gateway image. Docker, Podman, GPU, Rust, Python, MCP, and VM E2E reuse matching prebuilt gateway and CLI binaries instead of compiling debug binaries in test jobs. Standalone-driver lanes additionally reuse driver-free gateway and compute-driver artifacts. Kubernetes managed-driver lanes consume published gateway and supervisor images, while the standalone-driver lane composes its gateway image from prebuilt binaries. The `OpenShell / E2E` and `OpenShell / GPU E2E` required statuses are evaluated from separate suite result jobs inside that workflow. `test:e2e-kubernetes` is optional while Kubernetes HA and credential-driver behavior are under active iteration: failures are visible in the workflow run but do not publish a required CI gate status. -The GitHub ruleset should require the `OpenShell / ...` statuses published by `Required CI Gates`, not the push-triggered workflow jobs directly. +The GitHub ruleset should require the `OpenShell / ...` statuses published by +`Required CI Gates` plus the direct `OpenShell / Trivy Changes` result, not the +push-triggered workflow jobs themselves. ## Informational security reports @@ -79,6 +81,66 @@ nix develop --command actionlint -shellcheck= -pyflakes= nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes . ``` +## Artifact scanning + +`Trivy Scan` is a self-contained `workflow_dispatch`/`workflow_call` step. It +always scans deployment configuration and optionally scans supplied OCI image +and chart references. It is not wired into a release workflow; a future analysis +orchestrator can call it directly. + +The scan job checks static deployment files (including Dockerfiles) once, both +local charts with default values, the OpenShell `HELM_PROFILES` selected in +`tasks/scripts/trivy-scan.sh`, requested packaged charts, and both Linux +architectures of each requested image. The profile list includes development +and E2E overlays for regression coverage, but excludes `values-spire-stack.yaml`, +which belongs to the external SPIRE chart. A new OpenShell values fixture needs +an explicit entry in this list to receive Trivy coverage. + +Findings are informational by default, but scanner failures still fail the job +and `fail-on-findings` enables enforcement. Reports retain every severity; the +gate uses `HIGH,CRITICAL` by default. + +All detailed JSON reports are retained in one artifact. The workflow summary and +Code Scanning configuration report deduplicate the same rule, target, resource, +and message across profiles, listing the affected profiles in each finding. +Images and packaged charts retain separate analyses, keyed by full reference +and platform so different registries or versions cannot overwrite each other. +SARIF is generated only for publication. The upload job publishes +only this consolidated configuration SARIF and the artifact analyses, in batches +of at most 20 runs to respect GitHub's per-file limit. It also publishes complete +reports when `fail-on-findings` makes the scan job fail; incomplete scans are +retained as artifacts but are not published to Code Scanning. + +`.trivyignore.yaml` is reserved for false positives. Every entry must use at +least one `**/` path; `yq` and `jq` validate this structure +before scanning. + +Run the scanner locally with: + +```shell +nix develop --command tasks/scripts/trivy-scan.sh config +nix develop --command tasks/scripts/trivy-scan.sh images ghcr.io/nvidia/openshell/gateway:dev +nix develop --command tasks/scripts/trivy-scan.sh gate +nix develop --command tasks/scripts/trivy-scan.sh prepare-sarif +``` + +Use a fresh `TRIVY_REPORT_DIR` for each scan session. `prepare-sarif` creates +`code-scanning/uploads//`; only these directories are intended for upload. + +### Pull-request change gate + +`Trivy Changes` scans the base and candidate when a pull request or merge group +changes deployment configuration or scanner inputs. It fails only for new +`HIGH` or `CRITICAL` misconfigurations and retains both report sets. + +The candidate ignore file is validated, but the baseline policy applies to both +scans so a change cannot exempt its own finding. Existing profiles are compared +independently using the detailed reports, before any presentation deduplication; +a new profile reuses the maximum known occurrence count. A selected fixture +missing from the candidate fails the scan; a new fixture absent from the base +has no baseline profile. Invalid Trivy reports fail closed. Image package and +operating-system CVEs remain the responsibility of the standalone scan. + ## Commit signing copy-pr-bot decides whether to mirror a PR automatically based on whether the author is trusted. For org members and collaborators, "trusted" means **all commits in the PR are cryptographically signed**. Unsigned commits, even from an org member, force the bot to wait for a maintainer's `/ok to test `. @@ -158,14 +220,18 @@ GitHub merge queue is required for `main`. Repository administrators must enable - `OpenShell / E2E` - `OpenShell / GPU E2E` - `OpenShell / Helm Lint` +- `OpenShell / Trivy Changes` -Do not require the underlying workflow job names directly. `Required CI Gates` publishes stable commit statuses for both PR-head mirror commits and merge-group commits. +`Required CI Gates` publishes the stable statuses for mirror-based workflows. +`Trivy Changes` runs directly on pull requests and merge groups and publishes +its own stable result status. Merge-group runs use the `merge_group` event. The event is distinct from `pull_request` and `push`, and GitHub will not report required checks for queued PRs unless the workflows include it. In this repository: - `Branch Checks` runs the standard non-E2E gates on the merge-group SHA. - `Branch E2E Checks` runs core E2E and GPU E2E for merge groups. Kubernetes HA E2E remains optional and label-driven on PRs. - `Helm Lint` runs for merge groups without the PR diff optimization, because the merge-group branch is the final integration state. +- `Trivy Changes` compares the merge-group configuration with its base and rejects new High or Critical findings. - `Required CI Gates` posts the same `OpenShell / ...` statuses to the merge-group SHA and does not require a `pull-request/` mirror for merge-group events. Maintainers should add ready PRs to the queue rather than pressing a direct merge button. GitHub removes a PR from the queue if the merge-group checks fail or time out. @@ -204,6 +270,8 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | `.github/workflows/dependency-review.yml` | Reports dependency changes when GitHub Dependency Graph is available; otherwise publishes a neutral warning. | | `.github/workflows/codeql.yml` | Runs nightly informational CodeQL analysis on `main` for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | | `.github/workflows/codex-security.yml` | Scans the cumulative diff from the previous stable release to each pre-release candidate and publishes train-scoped SARIF on `main`. | +| `.github/workflows/trivy-changes.yml` | Blocks pull requests and merge groups that introduce new High or Critical Helm or Dockerfile misconfigurations. | +| `.github/workflows/trivy-scan.yml` | Manual or reusable scan of supplied OCI image/chart references and deployment configuration. Findings are informational by default and can be configured to fail the workflow. | ## Release workflows @@ -223,8 +291,13 @@ Require these statuses in the branch ruleset for PR and merge-queue CI: - `OpenShell / E2E` - `OpenShell / GPU E2E` - `OpenShell / Helm Lint` +- `OpenShell / Trivy Changes` -Do not require the underlying workflow jobs directly. PR workflow jobs only appear after copy-pr-bot mirrors trusted code, and merge-group workflow jobs run on temporary queue branches. The stable `OpenShell / ...` contexts prove the expected workflow completed for the commit that GitHub is about to merge. +For mirror-based workflows, require the statuses published by +`Required CI Gates`, not their underlying jobs. `OpenShell / Trivy Changes` is +the stable result job of the direct pull-request workflow. Together these +contexts prove the expected checks completed for the commit GitHub is about to +merge. Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL jobs to the required status list while they remain in observation mode. diff --git a/architecture/build.md b/architecture/build.md index d45cc25666..3cc5102f4d 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -439,7 +439,34 @@ tags and gating stable promotion on qualification results are part of [RFC 0014](../rfc/0014-release-stability/release-qualification.md) and are not implemented yet. -See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow. +## Artifact Scanning + +Two entry points share `tasks/scripts/trivy-scan.sh`: a standalone analysis +workflow and a pull-request change gate. Nix supplies Trivy, Helm and `yq`. + +The standalone workflow scans deployment configuration and supplied OCI +references independently of release publication. Detailed JSON reports feed the +differential gate; the summary and published SARIF consolidate configuration +findings across profiles while preserving resource identity and affected profiles. +Images and packaged charts retain separate identities based on their full +references. Publication batches respect GitHub's limit of 20 SARIF runs. + +The PR/merge-group gate scans base and candidate with the same scanner and rejects +new `HIGH` or `CRITICAL` configuration findings. Its stable +`OpenShell / Trivy Changes` status succeeds when nothing relevant changed. +Image CVEs need the standalone scan. The reporting and gate invariants are: + +- A structurally invalid Trivy report is an error, not an empty finding set. +- Scanner failures prevent publication of incomplete analyses; findings alone + do not prevent publishing complete reports. +- Findings compare per profile against the same baseline profile, by semantic + identity and count rather than line number; a profile absent from the baseline + falls back to that identity's maximum across all profiles. +- The candidate's ignore file is validated, but the baseline's policy applies to + both scans, so an exemption takes effect only after merge. + +See [CI.md](../CI.md#artifact-scanning) for profiles, report paths, severity +settings, exceptions, and the contributor and maintainer workflows. ## Docs Site diff --git a/flake.nix b/flake.nix index e361fd1597..b6cb70e387 100644 --- a/flake.nix +++ b/flake.nix @@ -57,8 +57,11 @@ pkg-config # Coverage. lcov + kubernetes-helm syft + trivy uv + yq-go zizmor zstd ]; diff --git a/scripts/agents/gator/skills/gator-gate/SKILL.md b/scripts/agents/gator/skills/gator-gate/SKILL.md index d66d5029ea..7768a38d1e 100644 --- a/scripts/agents/gator/skills/gator-gate/SKILL.md +++ b/scripts/agents/gator/skills/gator-gate/SKILL.md @@ -961,6 +961,7 @@ Required gates include at least: - `OpenShell / Branch Checks` - `OpenShell / Helm Lint` +- `OpenShell / Trivy Changes` - `OpenShell / E2E` when `test:e2e` is applied - `OpenShell / GPU E2E` when `test:e2e-gpu` is applied diff --git a/tasks/scripts/trivy-config-report.jq b/tasks/scripts/trivy-config-report.jq new file mode 100644 index 0000000000..c5c5dc613b --- /dev/null +++ b/tasks/scripts/trivy-config-report.jq @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Inputs are raw, repository-relative Trivy config reports. Consolidation is +# presentation only: the gate continues comparing unmodified reports per profile. +if length == 0 or any(.[]; + .SchemaVersion != 2 + or (.ArtifactName | type) != "string" + or (.ArtifactType | type) != "string" + or (.TrivyProfile | type) != "string" + or (.Results != null and (.Results | type) != "array") +) then error("invalid Trivy configuration reports") else . end +| .[0] as $template +| [ + .[] as $report + | $report.Results[]? + | (.Misconfigurations[]?.TrivyProfile) = $report.TrivyProfile + ] as $results +| $template +| .ArtifactName = "deployment configuration (all profiles)" +| del(.TrivyProfile) +| .Results = ( + $results + | group_by([.Target, .Class, .Type]) + | map( + . as $targets + | .[0] + | .Misconfigurations = ( + [$targets[] | .Misconfigurations[]?] + | group_by([ + .ID, .Namespace, .Severity, .Message, + .CauseMetadata.Provider, .CauseMetadata.Service, + .CauseMetadata.Resource + ]) + | map( + . as $findings + | .[0] + | .Message += ("\nProfiles: " + ($findings | map(.TrivyProfile) | unique | join(", "))) + | del(.TrivyProfile) + ) + ) + | .MisconfSummary = { + Successes: 0, + Failures: (.Misconfigurations | length), + Exceptions: 0 + } + ) + ) diff --git a/tasks/scripts/trivy-scan-test.sh b/tasks/scripts/trivy-scan-test.sh new file mode 100755 index 0000000000..8ce49e544b --- /dev/null +++ b/tasks/scripts/trivy-scan-test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCANNER="${REPO_ROOT}/tasks/scripts/trivy-scan.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT +# Synthetic findings must not write to the real Actions summary or outputs. +export GITHUB_OUTPUT="${TMP_DIR}/github-output" +export GITHUB_STEP_SUMMARY="${TMP_DIR}/github-summary" + +make_case() { + BASE="${TMP_DIR}/$1/base" + HEAD="${TMP_DIR}/$1/head" + mkdir -p "${BASE}" "${HEAD}" +} + +write_report() { + local path=$1 count=$2 profile + profile="$(basename "${path}" .json)" + jq -n --argjson count "${count}" --arg profile "${profile}" ' + { + SchemaVersion: 2, + ArtifactName: "deploy", + ArtifactType: "filesystem", + TrivyProfile: $profile, + Results: (if $count == 0 then [] else [{ + Target: "deploy/helm/openshell/templates/clusterrole.yaml", + Class: "config", + Type: "kubernetes", + MisconfSummary: {Successes: 0, Failures: $count, Exceptions: 0}, + Misconfigurations: [ + range(0; $count) | { + ID: "KSV-0041", + Title: "Manage secrets", + Message: "Role permits management of secrets", + Namespace: "builtin.kubernetes.KSV041", + Type: "Kubernetes Security Check", + Status: "FAIL", + Severity: "HIGH", + CauseMetadata: {Provider: "Kubernetes", Service: "RBAC", Resource: "ClusterRole.openshell"} + } + ] + }] end) + } + ' >"${path}" +} + +expect_status() { + local expected=$1 description=$2 + shift 2 + + set +e + "$@" >"${TMP_DIR}/last-command.log" 2>&1 + local actual=$? + set -e + if [ "${actual}" -ne "${expected}" ]; then + echo "FAIL: ${description}: expected exit ${expected}, got ${actual}" >&2 + cat "${TMP_DIR}/last-command.log" >&2 + exit 1 + fi +} + +make_case profile-expansion +write_report "${BASE}/config-defaults.json" 0 +write_report "${BASE}/config-fixture-workspace.json" 2 +write_report "${HEAD}/config-defaults.json" 1 +write_report "${HEAD}/config-fixture-workspace.json" 2 +expect_status 10 "finding newly exposed in an existing profile" \ + "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" + +make_case new-profile +write_report "${BASE}/config-defaults.json" 0 +write_report "${BASE}/config-fixture-workspace.json" 2 +write_report "${HEAD}/config-defaults.json" 0 +write_report "${HEAD}/config-fixture-workspace.json" 2 +write_report "${HEAD}/config-fixture-new.json" 2 +expect_status 0 "new profile repeating known findings" \ + "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" + +make_case occurrence-increase +write_report "${BASE}/config-defaults.json" 1 +write_report "${HEAD}/config-defaults.json" 2 +expect_status 10 "additional occurrence of an existing finding" \ + "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" +expect_status 0 "findings below the requested threshold" \ + env TRIVY_SEVERITY=CRITICAL "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" + +make_case malformed +write_report "${BASE}/config-defaults.json" 1 +printf '{}\n' >"${HEAD}/config-defaults.json" +expect_status 5 "structurally invalid candidate report" \ + "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" + +make_case no-reports +expect_status 2 "missing reports cannot pass the differential gate" \ + "${SCANNER}" gate-config-diff "${BASE}" "${HEAD}" + +cat >"${TMP_DIR}/valid-ignore.yaml" <<'EOF' +misconfigurations: + - id: KSV-0041 + paths: + - "**/clusterrole.yaml" +EOF +expect_status 0 "concretely scoped ignore path" \ + env TRIVY_IGNORE_FILE="${TMP_DIR}/valid-ignore.yaml" \ + "${SCANNER}" validate-ignore + +cat >"${TMP_DIR}/broad-ignore.yaml" <<'EOF' +misconfigurations: +- id: KSV-0041 + paths: + - "**/*" +EOF +expect_status 2 "broad ignore path" \ + env TRIVY_IGNORE_FILE="${TMP_DIR}/broad-ignore.yaml" \ + "${SCANNER}" validate-ignore + +# Consolidate more than 20 Helm profiles without losing resource identity or +# the per-profile input counts needed by the gate. Exercise real Trivy SARIF +# conversion with synthetic JSON; this requires no network or image downloads. +make_case sarif +write_report "${HEAD}/config-static.json" 0 +write_report "${HEAD}/config-defaults.json" 1 +for profile in {1..21}; do + write_report "${HEAD}/config-fixture-${profile}.json" 1 +done +write_report "${TMP_DIR}/resource-source.json" 2 +jq ' + .TrivyProfile = "config-fixture-resources" + | .Results[0].Misconfigurations[0].CauseMetadata.StartLine = 40 + | .Results[0].Misconfigurations[1].CauseMetadata.Resource = "ClusterRole.other" +' "${TMP_DIR}/resource-source.json" >"${HEAD}/config-fixture-resources.json" + +# Image reports must remain separate, even if they contain identical findings. +for report in {1..25}; do + write_report "${HEAD}/image-${report}.json" 0 +done +expect_status 0 "prepare one config analysis and bounded image upload batches" \ + env TRIVY_SEVERITY=CRITICAL TRIVY_REPORT_DIR="${HEAD}" GITHUB_OUTPUT="${TMP_DIR}/outputs" \ + "${SCANNER}" prepare-sarif +jq -e ' + [.Results[].Misconfigurations[]] as $findings + | ($findings | length) == 2 + and all($findings[]; .Message | contains("Profiles: ")) + and any($findings[]; + .CauseMetadata.Resource == "ClusterRole.openshell" + and (.Message | contains("config-defaults")) + and (.Message | contains("config-fixture-resources"))) +' "${HEAD}/consolidated/config.json" >/dev/null +jq -e '.Results[0].Misconfigurations | length == 2' \ + "${HEAD}/config-fixture-resources.json" >/dev/null +jq -e ' + (.runs | length) == 1 + and .runs[0].automationDetails.id == "trivy/config/" + and (.runs[0].results | length) == 2 + and (.runs[0].originalUriBaseIds == null) + and (.runs[0] as $run | all($run.results[]; + .ruleId == $run.tool.driver.rules[.ruleIndex].id + and all(.locations[].physicalLocation.artifactLocation; + .uri == "deploy/helm/openshell/templates/clusterrole.yaml" and .uriBaseId == null))) +' "${HEAD}/code-scanning/uploads/0/config.sarif" >/dev/null +jq -se '[.[].runs[]] | length == 20' "${HEAD}/code-scanning/uploads/0/"*.sarif >/dev/null +jq -se '[.[].runs[]] | length == 6' "${HEAD}/code-scanning/uploads/1/"*.sarif >/dev/null +[[ "$(<"${TMP_DIR}/outputs")" == 'batches=["0","1"]' ]] + +# Report findings and the workflow summary use the same consolidated view. +expect_status 10 "consolidated findings remain blocking" \ + env TRIVY_REPORT_DIR="${HEAD}" GITHUB_STEP_SUMMARY="${TMP_DIR}/summary" \ + "${SCANNER}" gate +test -s "${TMP_DIR}/summary" + +# Empty configuration is a valid clearing analysis, not a skipped upload. +make_case empty-sarif +write_report "${HEAD}/config-static.json" 0 +write_report "${HEAD}/config-defaults.json" 0 +expect_status 0 "empty configuration SARIF" \ + env TRIVY_REPORT_DIR="${HEAD}" "${SCANNER}" prepare-sarif +jq -e '.runs[0].results | length == 0' \ + "${HEAD}/code-scanning/uploads/0/config.sarif" >/dev/null + +make_case malformed-sarif +write_report "${HEAD}/config-static.json" 0 +printf '{}\n' >"${HEAD}/config-defaults.json" +expect_status 5 "reject malformed input during consolidation" \ + env TRIVY_REPORT_DIR="${HEAD}" "${SCANNER}" prepare-sarif + +# Record scan arguments without downloading Trivy's checks database or charts. +# shellcheck disable=SC2329 # Invoked by the scanner subprocess via export -f. +trivy() { + [ "${TRIVY_TEST_FAIL:-false}" = false ] || return 9 + local output="" previous="" arg + printf '%s\n' "$@" | jq -Rs 'split("\n")[:-1]' >>"${TRIVY_TEST_CALLS}" + for arg in "$@"; do + [ "${previous}" = --output ] && output="${arg}" + previous="${arg}" + done + jq -n --arg target "${!#}" \ + '{SchemaVersion: 2, ArtifactName: $target, ArtifactType: "filesystem", Results: []}' >"${output}" +} +# shellcheck disable=SC2329 # Invoked by the scanner subprocess via export -f. +helm() { touch "${!#}/chart.tgz"; } +export -f trivy helm +export TRIVY_TEST_CALLS="${TMP_DIR}/scan-calls" +expect_status 0 "scan selected profiles and retain distinct packaged chart versions/registries" \ + env TRIVY_REPORT_DIR="${TMP_DIR}/scan-reports" "${SCANNER}" config \ + --chart-ref oci://registry-a.example/charts/helm-chart:1.0.0 \ + --chart-ref oci://registry-a.example/charts/helm-chart:1.1.0 \ + --chart-ref oci://registry-b.example/charts/helm-chart:1.0.0 +jq -se ' + length == 21 + and ([.[] | select(.[-1] == "deploy")] | length) == 1 + and ([.[] | select(.[-1] == "deploy/helm")] | length) == 1 + and ([.[] | select(.[-1] == "deploy/helm/openshell")] | length) == 16 + and all(.[]; (join(" ") | contains("values-spire-stack.yaml")) | not) + and all(.[]; index("UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL") != null) +' "${TRIVY_TEST_CALLS}" >/dev/null +jq -se 'length == 3' "${TMP_DIR}/scan-reports/"config-packaged-*.json >/dev/null +test -z "$(find "${TMP_DIR}/scan-reports" -name '*.sarif' -print -quit)" + +expect_status 0 "retain image registry, path and platform identity" \ + env TRIVY_REPORT_DIR="${TMP_DIR}/images" "${SCANNER}" images \ + registry-a.example/ns/image:dev registry-b.example/ns/image:dev \ + registry-a.example/ns-image:dev +jq -se 'length == 6 and (map(.ArtifactName) | unique | length) == 3' \ + "${TMP_DIR}/images/"*.json >/dev/null + +expect_status 9 "scanner failures propagate" \ + env TRIVY_TEST_FAIL=true TRIVY_REPORT_DIR="${TMP_DIR}/scan-error" "${SCANNER}" config + +# Check missing fixture handling without changing the working checkout. +mkdir -p "${TMP_DIR}/candidate/tasks/scripts" "${TMP_DIR}/baseline" +cp "${SCANNER}" "${TMP_DIR}/candidate/tasks/scripts/trivy-scan.sh" +cp "${REPO_ROOT}/.trivyignore.yaml" "${TMP_DIR}/candidate/" +expect_status 2 "a selected candidate fixture cannot silently disappear" \ + env TRIVY_REPORT_DIR="${TMP_DIR}/missing-candidate" \ + "${TMP_DIR}/candidate/tasks/scripts/trivy-scan.sh" config +expect_status 0 "new candidate profiles may be absent from the baseline" \ + env TRIVY_SOURCE_ROOT="${TMP_DIR}/baseline" TRIVY_REPORT_DIR="${TMP_DIR}/missing-baseline" \ + "${TMP_DIR}/candidate/tasks/scripts/trivy-scan.sh" config +unset -f trivy helm + +echo "Trivy scan tests passed." diff --git a/tasks/scripts/trivy-scan.sh b/tasks/scripts/trivy-scan.sh new file mode 100755 index 0000000000..3dd58ebdce --- /dev/null +++ b/tasks/scripts/trivy-scan.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# `config` and `images` write full-severity reports and never fail on findings, +# then `gate` applies TRIVY_SEVERITY (default HIGH,CRITICAL). + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SOURCE_ROOT="${TRIVY_SOURCE_ROOT:-${REPO_ROOT}}" +IGNORE_FILE="${TRIVY_IGNORE_FILE:-${REPO_ROOT}/.trivyignore.yaml}" +cd "${SOURCE_ROOT}" + +SEVERITY="${TRIVY_SEVERITY:-HIGH,CRITICAL}" +REPORT_DIR="${TRIVY_REPORT_DIR:-reports/trivy}" +IGNORE_UNFIXED="${TRIVY_IGNORE_UNFIXED:-true}" +PLATFORMS="${TRIVY_PLATFORMS:-linux/amd64 linux/arm64}" + +# Disable cluster discovery while rendering charts offline. +PREFLIGHT_OFF=(--helm-set agentSandbox.preflight.enabled=false) + +# These Dockerfiles do not produce release runtime images. +SKIP_DOCKERFILES=( + --skip-files 'deploy/docker/Dockerfile.ci' + --skip-files 'deploy/docker/Dockerfile.*-macos' +) + +# Explicit OpenShell variants, including dev/E2E regression coverage. +# spire-stack belongs to the external SPIRE chart and is intentionally absent. +HELM_PROFILES=( + cert-manager credential-driver-kubernetes-secrets credential-driver-vault + gateway gateway-tls high-availability openshift-route-cert-manager spire + tls-disabled workspace-managed workspace-operator + corporate-proxy-e2e keycloak sidecar sidecar-kata skaffold +) + +# Reject ignore entries broader than one concrete basename. +validate_ignore_file() { + [ -f "${IGNORE_FILE}" ] || { + echo "Error: Trivy ignore file not found: ${IGNORE_FILE}" >&2 + return 2 + } + + command -v yq >/dev/null || { + echo "Error: yq not on PATH; run inside 'nix develop'" >&2 + return 2 + } + if ! yq --output-format json '.' "${IGNORE_FILE}" | + jq -e ' + (.misconfigurations // []) as $entries + | (($entries | type) == "array") + and all($entries[]; + . as $entry + | (($entry.id | type) == "string") + and (($entry.paths | type) == "array") + and (($entry.paths | length) > 0) + and all($entry.paths[]; + . as $path + | (($path | type) == "string") + and ($path | startswith("**/")) + and (($path | ltrimstr("**/") | length) > 0) + and (($path | ltrimstr("**/") | test("[/*?\\[\\]]")) | not) + ) + ) + ' >/dev/null; then + echo "Error: every Trivy ignore must use at least one '**/' path" >&2 + return 2 + fi +} + +# Convert a report whose targets already use repository-relative paths. +convert_sarif() { + local report=$1 output=$2 category=$3 + trivy convert --quiet \ + --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL --exit-code 0 \ + --format sarif --output "${output}" "${report}" + # `convert` can set ROOTPATH to the input JSON file. Our targets are relative + # to the checkout, not to the report file or the original scan directory. + jq --arg automation_id "trivy/${category}/" ' + .runs[] |= (.automationDetails.id = $automation_id) + | del(.runs[].originalUriBaseIds) + | (.. | objects | select(has("artifactLocation")) | .artifactLocation) + |= del(.uriBaseId) + ' "${output}" >"${output}.tmp" + mv "${output}.tmp" "${output}" +} + +scan() { + local subcommand=$1 slug=$2 prefix=$3 + shift 3 + + echo "==> ${slug}" + trivy "${subcommand}" --skip-version-check --quiet \ + --ignorefile "${IGNORE_FILE}" \ + --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL --exit-code 0 \ + --format json --output "${REPORT_DIR}/${slug}.json" "$@" + if [ -n "${prefix}" ]; then + jq --arg prefix "${prefix}" --arg profile "${slug}" ' + .TrivyProfile = $profile + | (.Results[]?.Target) |= $prefix + sub("^[^:]*\\.tgz:"; "") + ' "${REPORT_DIR}/${slug}.json" >"${REPORT_DIR}/${slug}.json.tmp" + mv "${REPORT_DIR}/${slug}.json.tmp" "${REPORT_DIR}/${slug}.json" + fi +} + +# Scan static deployment files once, then charts with their applicable values. +scan_config() { + scan config config-static deploy/ "${SKIP_DOCKERFILES[@]}" \ + --skip-dirs deploy/helm deploy + scan config config-defaults deploy/helm/ "${PREFLIGHT_OFF[@]}" deploy/helm + + local values fixture + for fixture in "${HELM_PROFILES[@]}"; do + values="deploy/helm/openshell/ci/values-${fixture}.yaml" + if [ ! -f "${values}" ]; then + # A newly added profile has no baseline report yet. A missing candidate + # fixture is an error: intentional removal must also update HELM_PROFILES. + [ "${SOURCE_ROOT}" != "${REPO_ROOT}" ] && continue + echo "Error: Helm profile not found: ${values}" >&2 + return 2 + fi + scan config "config-fixture-${fixture}" deploy/helm/openshell/ \ + "${PREFLIGHT_OFF[@]}" --helm-values "${values}" deploy/helm/openshell + done +} + +# A readable label plus the full reference hash avoids registry/tag collisions. +artifact_slug() { + local digest + digest="$(printf '%s' "$1" | sha256sum)" + printf '%s-%s' "$(printf '%s' "${1##*/}" | tr -cs 'A-Za-z0-9._-' '-' | cut -c1-80)" "${digest%% *}" +} + +# Trivy needs a local chart archive rather than an OCI reference. +scan_packaged_chart() { + local ref=$1 + if [[ "${ref}" != *:* || "${ref##*/}" != *:* ]]; then + echo "Error: --chart-ref needs a version tag, e.g. oci://host/chart:1.2.3" >&2 + exit 2 + fi + + local repo chart_name chart_dir="" candidate dir + repo="${ref%:*}" + chart_name="${repo##*/}" + for candidate in deploy/helm/*/; do + [ -f "${candidate}Chart.yaml" ] || continue + [ "$(sed -n 's/^name:[[:space:]]*//p' "${candidate}Chart.yaml" | head -1)" \ + = "${chart_name}" ] || continue + chart_dir="${candidate}" + break + done + if [ -z "${chart_dir}" ]; then + echo "Error: no chart under deploy/helm declares name '${chart_name}'" >&2 + exit 2 + fi + + dir="$(mktemp -d)" + trap 'rm -rf "${dir}"' RETURN + + helm pull "${repo}" --version "${ref##*:}" --destination "${dir}" + scan config "config-packaged-$(artifact_slug "${ref}")" "${chart_dir}" \ + "${PREFLIGHT_OFF[@]}" "$(find "${dir}" -name '*.tgz' -print -quit)" +} + +scan_images() { + local extra=() + [ "${IGNORE_UNFIXED}" = "true" ] && extra+=(--ignore-unfixed) + + local image platform slug + for image in "$@"; do + for platform in ${PLATFORMS}; do + slug="image-$(artifact_slug "${image}")-${platform//\//-}" + scan image "${slug}" "" --platform "${platform}" --scanners vuln \ + "${extra[@]}" "${image}" + done + done +} + +gate() { + local report result findings=0 + + if [ -z "$(find "${REPORT_DIR}" -maxdepth 1 -name '*.json' -print -quit)" ]; then + echo "Error: no reports in ${REPORT_DIR}; run 'config' or 'images' first" >&2 + exit 2 + fi + + local reports=() + if [ -f "${REPORT_DIR}/config-defaults.json" ]; then + consolidate_config + reports+=("${REPORT_DIR}/consolidated/config.json") + fi + for report in "${REPORT_DIR}"/image-*.json "${REPORT_DIR}"/config-packaged-*.json; do + [ -f "${report}" ] && reports+=("${report}") + done + [ "${#reports[@]}" -gt 0 ] || { echo "Error: no complete scan reports" >&2; return 2; } + + for report in "${reports[@]}"; do + set +e + trivy convert --quiet --exit-code 10 --severity "${SEVERITY}" \ + --format table "${report}" + result=$? + set -e + + case "${result}" in + 0) ;; + 10) findings=1 ;; + *) + echo "Error: Trivy could not evaluate ${report} (exit ${result})" >&2 + return "${result}" + ;; + esac + done + + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Trivy (gate: \`${SEVERITY}\`)" + echo '```' + for report in "${reports[@]}"; do + trivy convert --quiet --severity "${SEVERITY}" --format table "${report}" + done + echo '```' + } >>"${GITHUB_STEP_SUMMARY}" + fi + + [ "${findings}" -eq 0 ] || return 10 +} + +consolidate_config() { + local report + local reports=("${REPORT_DIR}/config-static.json" "${REPORT_DIR}/config-defaults.json") + for report in "${REPORT_DIR}"/config-fixture-*.json; do + [ -f "${report}" ] && reports+=("${report}") + done + mkdir -p "${REPORT_DIR}/consolidated" + jq -s -f "${REPO_ROOT}/tasks/scripts/trivy-config-report.jq" \ + "${reports[@]}" >"${REPORT_DIR}/consolidated/config.json" +} + +# Keep detailed reports for the differential gate, but publish one deduplicated +# configuration analysis. Images and packaged charts retain separate identities. +prepare_sarif() { + local staging report slug batch=0 count=0 + staging="${REPORT_DIR}/code-scanning" + if [ -e "${staging}" ]; then + echo "Error: ${staging} already exists; use a fresh report directory" >&2 + return 2 + fi + mkdir -p "${staging}/uploads/0" + consolidate_config + convert_sarif "${REPORT_DIR}/consolidated/config.json" "${staging}/uploads/0/config.sarif" config + count=1 + for report in "${REPORT_DIR}"/image-*.json "${REPORT_DIR}"/config-packaged-*.json; do + [ -f "${report}" ] || continue + # upload-sarif combines a directory into one file; GitHub accepts at most + # 20 runs per file. Every report produced here contains exactly one run. + if [ "${count}" -eq 20 ]; then + batch=$((batch + 1)) + count=0 + mkdir -p "${staging}/uploads/${batch}" + fi + slug="$(basename "${report}" .json)" + convert_sarif "${report}" "${staging}/uploads/${batch}/${slug}.sarif" "${slug}" + count=$((count + 1)) + done + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'batches=%s\n' "$(jq -cn --argjson last "${batch}" '[range(0; $last + 1) | tostring]')" \ + >>"${GITHUB_OUTPUT}" + fi +} + +collect_config_findings() { + # Preserve report/profile identity while counting repeated findings. + # With no reports, the unmatched glob makes jq fail on the missing input. + jq -n --arg severities "${SEVERITY}" ' + [inputs + | (input_filename | split("/")[-1] | rtrimstr(".json")) as $profile + | if .SchemaVersion != 2 + or ((.ArtifactName | type) != "string") + or ((.ArtifactType | type) != "string") + or (.Results != null and ((.Results | type) != "array")) + then + error("invalid Trivy JSON report: " + $profile) + else + { + profile: $profile, + findings: ([ + .Results[]? as $result + | $result.Misconfigurations[]? + | .Severity as $severity + | select(($severities | split(",") | index($severity)) != null) + | ([ + .ID, + $result.Target, + (.Namespace // ""), + (.Message // ""), + (.CauseMetadata.Provider // ""), + (.CauseMetadata.Service // ""), + (.CauseMetadata.Resource // "") + ] | @json) as $semantic_key + | { + key: ([$profile, $semantic_key] | @json), + semantic_key: $semantic_key, + profile: $profile, + severity: .Severity, + id: .ID, + target: $result.Target, + title: .Title + } + ] + | group_by(.key) + | map(.[0] + { count: length })) + } + end + ] | { + profiles: map(.profile), + findings: (map(.findings) | add // []) + } + ' "$1"/*.json +} + +# Compare semantic identities and occurrence counts, excluding line numbers. +gate_config_diff() ( + set -euo pipefail + + local baseline_dir=$1 candidate_dir=$2 + local inventory_dir baseline candidate new_findings finding_count + inventory_dir="$(mktemp -d)" + trap 'rm -rf "${inventory_dir}"' EXIT + baseline="${inventory_dir}/baseline.json" + candidate="${inventory_dir}/candidate.json" + new_findings="${inventory_dir}/new.json" + + collect_config_findings "${baseline_dir}" >"${baseline}" + collect_config_findings "${candidate_dir}" >"${candidate}" + jq --slurpfile baseline "${baseline}" ' + ($baseline[0].findings | map({ (.key): .count }) | add // {}) as $by_profile + | ($baseline[0].profiles) as $known_profiles + | ($baseline[0].findings + | group_by(.semantic_key) + | map({ + key: .[0].semantic_key, + value: (map(.count) | max) + }) + | from_entries) as $across_profiles + | [ + .findings[] + | . as $finding + | (if ($known_profiles | index($finding.profile)) != null + then (($by_profile[$finding.key]) // 0) + else (($across_profiles[$finding.semantic_key]) // 0) + end) as $before + | select(.count > $before) + | . + { baseline_count: $before, new_count: (.count - $before) } + ] + ' "${candidate}" >"${new_findings}" + + finding_count="$(jq '[.[].new_count] | add // 0' "${new_findings}")" + if [ "${finding_count}" -eq 0 ]; then + echo "No new configuration findings at ${SEVERITY}." + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "No new Trivy configuration findings at \`${SEVERITY}\`." \ + >>"${GITHUB_STEP_SUMMARY}" + fi + exit 0 + fi + + echo "::error::Trivy reported ${finding_count} new configuration finding(s) at ${SEVERITY}." + jq -r '.[] + | "::error::[\(.severity)] \(.id) in \(.profile) (\(.target)): \(.title)" + + " (\(.new_count) new, \(.baseline_count) in baseline)"' \ + "${new_findings}" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### New Trivy configuration findings" + echo + jq -r '.[] + | "- **\(.severity)** `\(.id)` in `\(.profile)`" + + " (`\(.target)`): \(.title)" + + " (\(.new_count) new, \(.baseline_count) in baseline)"' \ + "${new_findings}" + } >>"${GITHUB_STEP_SUMMARY}" + fi + exit 10 +) + +require_trivy() { + command -v trivy >/dev/null || { + echo "Error: trivy not on PATH; run inside 'nix develop'" >&2 + exit 2 + } +} + +case "${1:-}" in + config) + shift + require_trivy + validate_ignore_file + mkdir -p "${REPORT_DIR}" + scan_config + # Reject unparsed arguments so requested charts cannot be silently skipped. + while [ "${1:-}" = "--chart-ref" ]; do + [ -n "${2:-}" ] || { echo "Error: --chart-ref needs a value" >&2; exit 2; } + scan_packaged_chart "$2" + shift 2 + done + [ $# -eq 0 ] || { echo "Error: unexpected argument '$1' after config" >&2; exit 2; } + ;; + images) + shift + [ $# -gt 0 ] || { echo "Error: images needs at least one reference" >&2; exit 2; } + require_trivy + validate_ignore_file + mkdir -p "${REPORT_DIR}" + scan_images "$@" + ;; + gate) + require_trivy + gate + ;; + prepare-sarif) + require_trivy + prepare_sarif + ;; + gate-config-diff) + shift + [ $# -eq 2 ] || { + echo "Error: gate-config-diff needs baseline and candidate report directories" >&2 + exit 2 + } + gate_config_diff "$1" "$2" + ;; + validate-ignore) + validate_ignore_file + ;; + *) + cat >&2 <<'USAGE' +Usage: + trivy-scan.sh config [--chart-ref ]... + trivy-scan.sh images [...] + trivy-scan.sh gate + trivy-scan.sh prepare-sarif + trivy-scan.sh gate-config-diff + trivy-scan.sh validate-ignore +USAGE + exit 2 + ;; +esac