diff --git a/.github/secscan-exclusions/linux-arm.txt b/.github/secscan-exclusions/linux-arm.txt new file mode 100644 index 00000000000..4386d831989 --- /dev/null +++ b/.github/secscan-exclusions/linux-arm.txt @@ -0,0 +1,3 @@ +# CVEs excluded from the secscan gate for this artifact, one per line. +# Every entry needs a justification comment and a link to its draft GHSA. +# High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). diff --git a/.github/secscan-exclusions/linux-arm64.txt b/.github/secscan-exclusions/linux-arm64.txt new file mode 100644 index 00000000000..4386d831989 --- /dev/null +++ b/.github/secscan-exclusions/linux-arm64.txt @@ -0,0 +1,3 @@ +# CVEs excluded from the secscan gate for this artifact, one per line. +# Every entry needs a justification comment and a link to its draft GHSA. +# High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). diff --git a/.github/secscan-exclusions/linux-ppc64le.txt b/.github/secscan-exclusions/linux-ppc64le.txt new file mode 100644 index 00000000000..4386d831989 --- /dev/null +++ b/.github/secscan-exclusions/linux-ppc64le.txt @@ -0,0 +1,3 @@ +# CVEs excluded from the secscan gate for this artifact, one per line. +# Every entry needs a justification comment and a link to its draft GHSA. +# High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). diff --git a/.github/secscan-exclusions/linux-s390x.txt b/.github/secscan-exclusions/linux-s390x.txt new file mode 100644 index 00000000000..4386d831989 --- /dev/null +++ b/.github/secscan-exclusions/linux-s390x.txt @@ -0,0 +1,3 @@ +# CVEs excluded from the secscan gate for this artifact, one per line. +# Every entry needs a justification comment and a link to its draft GHSA. +# High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). diff --git a/.github/secscan-exclusions/linux-x64.txt b/.github/secscan-exclusions/linux-x64.txt new file mode 100644 index 00000000000..4386d831989 --- /dev/null +++ b/.github/secscan-exclusions/linux-x64.txt @@ -0,0 +1,3 @@ +# CVEs excluded from the secscan gate for this artifact, one per line. +# Every entry needs a justification comment and a link to its draft GHSA. +# High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4f4fedb007..ca7cb33f8d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,6 +162,14 @@ jobs: path: | _package + secscan: + needs: build + permissions: + contents: read + uses: ./.github/workflows/secscan.yaml # zizmor: ignore[self-repository] + with: + source: artifacts + release: needs: build runs-on: ubuntu-latest diff --git a/.github/workflows/secscan.yaml b/.github/workflows/secscan.yaml new file mode 100644 index 00000000000..0f4087be4e3 --- /dev/null +++ b/.github/workflows/secscan.yaml @@ -0,0 +1,137 @@ +# Scans the Linux runner release tarballs with Canonical secscan to meet SEC0025. +# Called by release.yml on the freshly built packages (report-only; it does not block the release), and run +# weekly against the latest published release. +name: Secscan + +on: + schedule: + - cron: "29 4 * * 1" + workflow_dispatch: + workflow_call: + inputs: + source: + description: "'artifacts' scans this run's runner-packages-* artifacts; otherwise the latest release." + type: string + default: release + pull_request: + paths: + - .github/workflows/secscan.yaml + - .github/secscan-exclusions/** + +permissions: {} + +env: + SSDLC_CYCLE: "26.10" + +jobs: + secscan: + name: Secscan ${{ matrix.runtime }} + runs-on: [self-hosted-linux-amd64-noble-private-endpoint-medium] + permissions: + contents: read + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + runtime: [linux-x64, linux-arm64, linux-ppc64le, linux-s390x, linux-arm] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install secscan client + env: + RUNTIME: ${{ matrix.runtime }} + run: | + # A concurrent job on the same host may win the install race. + snap list canonical-secscan-client >/dev/null 2>&1 || + sudo snap install canonical-secscan-client || + snap list canonical-secscan-client >/dev/null 2>&1 + sudo snap connect canonical-secscan-client:home system:home + # Per-job dir so concurrent jobs on one host cannot collide on tokens or cleanup. + secscan_dir="$HOME/snap/canonical-secscan-client/common/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$RUNTIME" + mkdir -p "$secscan_dir" + echo "SECSCAN_DIR=$secscan_dir" >> "$GITHUB_ENV" + echo "NO_PROXY=${NO_PROXY:+$NO_PROXY,}.internal" >> "$GITHUB_ENV" + echo "no_proxy=${no_proxy:+$no_proxy,}.internal" >> "$GITHUB_ENV" + - name: Download built package + if: inputs.source == 'artifacts' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: runner-packages-${{ matrix.runtime }} + path: packages + - name: Download latest release package + if: inputs.source != 'artifacts' + env: + RUNTIME: ${{ matrix.runtime }} + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p packages + url="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/releases/latest" \ + | python3 -c 'import json,sys,os; r=os.environ["RUNTIME"]; print(next(a["browser_download_url"] for a in json.load(sys.stdin)["assets"] if a["name"].startswith(f"actions-runner-{r}-") and a["name"].endswith(".tar.gz")))')" + curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -o "packages/${url##*/}" "$url" + - name: Stage package + id: stage + env: + RUNTIME: ${{ matrix.runtime }} + run: | + shopt -s nullglob + files=(packages/actions-runner-"$RUNTIME"-*.tar.gz) + [[ ${#files[@]} -eq 1 ]] || { echo "::error::expected one $RUNTIME tarball, found ${#files[@]}"; exit 1; } + name="${files[0]##*/}" + version="${name#actions-runner-"$RUNTIME"-}"; version="${version%.tar.gz}" + cp "${files[0]}" "$SECSCAN_DIR/$name" + echo "target=$SECSCAN_DIR/$name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Scan + env: + ARTIFACT: ${{ matrix.runtime }} + TARGET: ${{ steps.stage.outputs.target }} + SSDLC_VERSION: ${{ steps.stage.outputs.version }} + SSDLC_CHANNEL: stable + run: | + select_exclusions() { # $1 artifact name, $2 staging dir; prints staged path or nothing + local src=".github/secscan-exclusions/$1.txt" + if [[ -f "$src" ]] && grep -qvE '^[[:space:]]*(#|$)' "$src"; then + cp "$src" "$2/exclusions-$1.txt"; echo "$2/exclusions-$1.txt" + fi + } + classify() { # $1 secscan-client --batch exit code; echoes annotation, returns it + case "$1" in + 0) echo "No CVEs found." ;; + 101) echo "::error::Excluded CVEs are no longer reported; remove them from .github/secscan-exclusions/." ;; + 1[0-9][0-9]) echo "::error::CVEs found; the secscan-report artifact's $ARTIFACT.txt lists them; triage per SECURITY.md." ;; + *) echo "::error::secscan-client failed (exit $1); re-run the job." ;; + esac + return "$1" + } + mkdir -p reports + exclusions=() + excl_file="$(select_exclusions "$ARTIFACT" "$SECSCAN_DIR")" + [[ -n "$excl_file" ]] && exclusions=(--exclusions-filename "$excl_file") + set +e + secscan-client --batch submit \ + --scanner trivy --type package --format archive \ + --ssdlc-product-name github-runner \ + --ssdlc-product-version "$SSDLC_VERSION" \ + --ssdlc-product-channel "$SSDLC_CHANNEL" \ + --ssdlc-cycle "$SSDLC_CYCLE" \ + --token "$SECSCAN_DIR/$ARTIFACT.token" \ + "${exclusions[@]}" \ + --wait-and-print "$TARGET" | tee "reports/$ARTIFACT.txt" + rc=${PIPESTATUS[0]} + if (( rc == 0 || rc >= 100 )); then + secscan-client --batch report --token "$SECSCAN_DIR/$ARTIFACT.token" \ + > "reports/$ARTIFACT.report.html" 2> "reports/$ARTIFACT.report.err" || true + fi + set -e + classify "$rc" + - name: Upload report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: secscan-report-${{ matrix.runtime }} + path: reports/ + if-no-files-found: warn + - name: Clean up secscan staging + if: always() + run: rm -rf "${SECSCAN_DIR:?}" diff --git a/SECURITY.md b/SECURITY.md index fe6468e8d0a..6c80f72edc9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,3 +21,23 @@ assigned and coordinating the release of the fix. The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) contains more information about what you can expect when you contact us, and what we expect from you. + +## Vulnerability scanning + +The `Secscan` workflow scans the Linux runner tarballs with Canonical's secscan service +(Trivy). `Runner CD` runs it on the freshly built packages alongside the release; a +failing scan marks the run as failed but does not block the release. It also runs weekly +against the latest release and on demand. + +Each `secscan-report-` artifact contains `.txt`, the authoritative +verdict plus the CVE IDs secscan reported, and `.report.html`, Trivy's detailed +report, which may not list every CVE secscan reports for archive scans. The HTML report is +best-effort: it is absent when the scan fails before completing. + +When it fails, maintainers download the `secscan-report-*` artifacts and either fix the +issue (usually a .NET or bundled Node.js update) or open a draft +[GitHub security advisory](../../security/advisories/new) recording the CVE, its severity +and the remediation plan. A CVE accepted as a false positive or acceptable risk is added +to `.github/secscan-exclusions/.txt` with a justification comment and a link to +its advisory. High or critical exclusions need security team approval. The SSDLC cycle is +set by `SSDLC_CYCLE` in the workflow and must be bumped each cycle.