diff --git a/.github/scripts/v2/npm-audit.sh b/.github/scripts/v2/npm-audit.sh index 0bca8297..c794a63b 100755 --- a/.github/scripts/v2/npm-audit.sh +++ b/.github/scripts/v2/npm-audit.sh @@ -1,97 +1,123 @@ #!/usr/bin/env bash -# Run `npm audit --audit-level=high` against every committed package-lock.json in the v2 line, -# with per-lockfile exceptions from v2/audit-ignore. +# Run `npm audit` on every package-lock.json under v2/, failing on advisories at or above +# AUDIT_LEVEL (default: high), with exceptions from v2/npm-audit-ignore. # -# Fails (exit 1) when: -# - any high or critical advisory remains that v2/audit-ignore doesn't list for that lockfile; -# - npm audit returns an error body (e.g. registry timeout) instead of a report: fail closed; -# - no lockfile is found, or an exception no longer matches anything (stale ignore). +# The ignore file lists one GHSA id per line; anything after '#' is a comment. Packages that +# are vulnerable only through an ignored advisory (npm reports them with a string "via") are +# covered by that ignore. # -# v2/audit-ignore format, one exception per line: -# # advisory, path, reason, exit condition -# -# Usage: .github/scripts/v2/npm-audit.sh +# Fails closed: if npm returns no usable report for a lockfile (registry error, timeout, +# unparsable output), that lockfile counts as failed. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -IGNORE_FILE="${REPO_ROOT}/v2/audit-ignore" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +IGNORE_FILE="$REPO_ROOT/v2/npm-audit-ignore" +AUDIT_LEVEL="${AUDIT_LEVEL:-high}" -command -v npm >/dev/null || { echo "npm not found" >&2; exit 2; } -command -v jq >/dev/null || { echo "jq not found" >&2; exit 2; } +case "$AUDIT_LEVEL" in + low | moderate | high | critical) ;; + *) + echo "::error::AUDIT_LEVEL must be low, moderate, high or critical (got '$AUDIT_LEVEL')" + exit 2 + ;; +esac -# " " pairs from the ignore file -ignores="" -if [[ -f "${IGNORE_FILE}" ]]; then - while IFS= read -r line || [[ -n "${line}" ]]; do +IGNORED_IDS=() +if [[ -f "$IGNORE_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do line="${line%%#*}" - read -r ghsa dir _ <<<"${line}" || true - [[ -z "${ghsa:-}" ]] && continue - if [[ -z "${dir:-}" ]]; then - echo "::error::${IGNORE_FILE}: '${ghsa}' has no lockfile directory" >&2 - exit 2 - fi - ignores+="${ghsa} ${dir}"$'\n' - done <"${IGNORE_FILE}" + line="$(echo "$line" | xargs)" + [[ -z "$line" ]] && continue + IGNORED_IDS+=("$line") + done < "$IGNORE_FILE" fi -cd "${REPO_ROOT}" -lockfiles=$(git ls-files -- '*package-lock.json' | grep -v '/node_modules/' || true) -if [[ -z "${lockfiles}" ]]; then - echo "::error::no package-lock.json found; nothing was audited" >&2 - exit 1 +if [[ ${#IGNORED_IDS[@]} -gt 0 ]]; then + IGNORE_JSON="$(printf '%s\n' "${IGNORED_IDS[@]}" | jq -R . | jq -cs .)" + echo "Ignoring ${#IGNORED_IDS[@]} advisory exception(s) from ${IGNORE_FILE#"$REPO_ROOT"/}:" + printf ' - %s\n' "${IGNORED_IDS[@]}" +else + IGNORE_JSON='[]' fi -status=0 -errored=0 -used="" +cd "$REPO_ROOT" + +LOCKFILES=() +lock_list="$(git ls-files -- ':(glob)v2/**/package-lock.json')" while IFS= read -r lock; do - dir=$(dirname "${lock}") - echo "== npm audit --audit-level=high: ${dir}" - report=$(cd "${dir}" && npm audit --json --package-lock-only 2>/dev/null || true) - - if ! jq -e 'type == "object" and (.error | not) and (.metadata.vulnerabilities | type == "object")' \ - >/dev/null 2>&1 <<<"${report}"; then - echo "::error::npm audit returned no report for ${dir} (registry error?); failing closed" >&2 - jq -c '.error // .' <<<"${report}" 2>/dev/null | head -c 2000 >&2 || printf '%s\n' "${report}" | head -c 2000 >&2 - echo >&2 - status=1 - errored=1 + [[ -n "$lock" ]] && LOCKFILES+=("$lock") +done <<< "$lock_list" + +if [[ ${#LOCKFILES[@]} -eq 0 ]]; then + echo "::error::No package-lock.json found under v2/" + exit 1 +fi + +failed=0 +incomplete=0 +seen_ignored='[]' + +for lock in "${LOCKFILES[@]}"; do + dir="$(dirname "$lock")" + echo "" + echo "=== npm audit --audit-level=$AUDIT_LEVEL: $lock" + + # npm audit exits non-zero whenever it finds anything, so judge by the JSON report. + report="$(cd "$dir" && npm audit --json --package-lock-only 2>/dev/null || true)" + + if ! jq -e 'type == "object" and (has("error") | not) and has("vulnerabilities") and has("metadata")' \ + > /dev/null 2>&1 <<< "$report"; then + echo "::error file=$lock::npm audit returned no usable report (registry error or bad output)" + jq -r '.error // empty | " \(.code // "") \(.summary // "")"' 2> /dev/null <<< "$report" || true + failed=1 + incomplete=1 continue fi - jq -r '.metadata.vulnerabilities | " counts: critical=\(.critical) high=\(.high) moderate=\(.moderate) low=\(.low) total=\(.total)"' <<<"${report}" + jq -r '.metadata.vulnerabilities | " totals: critical=\(.critical) high=\(.high) moderate=\(.moderate) low=\(.low)"' <<< "$report" - # Every advisory object (not "via another package" strings) at high or critical. - findings=$(jq -r ' - [.vulnerabilities[] | .via[] | objects | select(.severity == "high" or .severity == "critical") - | {ghsa: ((.url // "" | (capture("(?GHSA-[0-9a-z-]+)") | .id)?) // "npm-advisory-\(.source)"), - name, severity, title}] - | unique_by(.ghsa) | .[] | "\(.ghsa)\t\(.name)\t\(.severity)\t\(.title)"' <<<"${report}") + # Advisory-level view: one row per (package, advisory) at or above the level. + rows="$(jq -r --arg level "$AUDIT_LEVEL" --argjson ignore "$IGNORE_JSON" ' + def rank: {"info": 0, "low": 1, "moderate": 2, "high": 3, "critical": 4}[.] // 0; + [ .vulnerabilities[] | .via[] | objects + | select((.severity | rank) >= ($level | rank)) + | (.url | split("/") | last) as $id + | { id: $id, name, severity, range, title, + ignored: ($ignore | index($id) != null) } ] + | unique_by([.name, .id]) | .[] + | [ (if .ignored then "IGNORED" else "FAIL" end), .id, .severity, .name, .range, .title ] + | @tsv' <<< "$report")" - while IFS=$'\t' read -r ghsa name severity title; do - [[ -z "${ghsa}" ]] && continue - if grep -qxF "${ghsa} ${dir}" <<<"${ignores}"; then - echo " ignored ${ghsa} ${name} (${severity}): ${title}" - used+="${ghsa} ${dir}"$'\n' + if [[ -z "$rows" ]]; then + echo " ok: no advisories at or above $AUDIT_LEVEL" + continue + fi + + while IFS=$'\t' read -r status id severity name range title; do + echo " $status $id ($severity) $name $range: $title" + if [[ "$status" == "FAIL" ]]; then + echo "::error file=$lock::$id ($severity) $name $range: $title" + failed=1 else - echo "::error::${dir}: ${ghsa} ${name} (${severity}): ${title}" - status=1 + seen_ignored="$(jq -c --arg id "$id" '. + [$id] | unique' <<< "$seen_ignored")" fi - done <<<"${findings}" -done <<<"${lockfiles}" - -# An exception that matched nothing is stale: remove it from v2/audit-ignore. -# (Skipped when a report was missing, since nothing could match then.) -while IFS= read -r entry; do - [[ -z "${entry}" || "${errored}" -eq 1 ]] && continue - if ! grep -qxF "${entry}" <<<"${used}"; then - echo "::error::stale exception in v2/audit-ignore (no longer reported): ${entry}" >&2 - status=1 - fi -done <<<"${ignores}" + done <<< "$rows" +done -if [[ "${status}" -eq 0 ]]; then - echo "npm audit: no unignored high or critical advisories" +# Flag exceptions that no longer match anything, so they get removed (only when every +# lockfile produced a report; otherwise a missing match proves nothing). +if [[ ${#IGNORED_IDS[@]} -gt 0 && $incomplete -eq 0 ]]; then + for id in "${IGNORED_IDS[@]}"; do + if ! jq -e --arg id "$id" 'index($id) != null' > /dev/null <<< "$seen_ignored"; then + echo "::warning file=v2/npm-audit-ignore::$id no longer matches any finding; remove it" + fi + done +fi + +echo "" +if [[ $failed -ne 0 ]]; then + echo "npm audit gate FAILED" + exit 1 fi -exit "${status}" +echo "npm audit gate passed (${#LOCKFILES[@]} lockfile(s))" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 115f304d..640dd99f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: "v2: CI (shim)" # This file exists so GitHub triggers the centralized ci-v2.yml # workflow that lives on the main branch. The actual CI logic is on main — -# changes to it should be made there, not here. The one exception is the -# branch-local npm-audit job below, which audits this branch's lockfiles. +# changes to it should be made there, not here. That includes the +# Dependency Audit job, which runs this branch's .github/scripts/v2/npm-audit.sh +# with exceptions from v2/npm-audit-ignore. on: push: @@ -46,21 +47,3 @@ jobs: name: CI uses: pacphi/sindri/.github/workflows/ci-v2.yml@main secrets: inherit - - # Dependency audit for the v2 line (SECURITY.md: each supported branch's CI runs one). - # Runs on the branch itself, so it needs no change to the centralized ci-v2.yml on main. - # Exceptions live in v2/audit-ignore; see .github/scripts/v2/npm-audit.sh. - npm-audit: - name: npm audit (high) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6.0.2 - - - uses: actions/setup-node@v6 - with: - node-version: "24" - - - name: npm audit --audit-level=high (every package-lock.json) - run: .github/scripts/v2/npm-audit.sh diff --git a/v2/audit-ignore b/v2/npm-audit-ignore similarity index 72% rename from v2/audit-ignore rename to v2/npm-audit-ignore index 99121fdb..18dbcdb9 100644 --- a/v2/audit-ignore +++ b/v2/npm-audit-ignore @@ -1,11 +1,14 @@ # npm audit exceptions for the v2 line, read by .github/scripts/v2/npm-audit.sh. -# Format: # justification +# One GHSA id per line; anything after '#' is a comment. # Only for advisories with no fix inside the current major. Each entry names the advisory, the -# dependency path, why it can't be fixed now, reachability, and the exit condition. The script -# fails on an entry that no longer matches anything, so remove entries once they're fixed. +# dependency path (with its lockfile directory), why it can't be fixed now, reachability, and the +# exit condition. The script warns on an entry that no longer matches anything, so remove entries +# once they're fixed. # GHSA-83w8-p2f5-377r (high): @fastify/static <=10.1.0 route-guard bypass via non-leading ".." / # "%2E%2E" segments; first patched 10.1.1 (sibling GHSA-8pvw-jcv7-9cmj, medium, fixed in 10.1.2). +# https://github.com/advisories/GHSA-83w8-p2f5-377r +# Lockfile: v2/docker/lib/extensions/vf-management-api/resources/package-lock.json # Path: @fastify/swagger-ui@3.1.0 > @fastify/static@7.0.4 (swagger-ui 3.x pins ^7.0.0; no 7.x fix). # Why not fixed: needs @fastify/swagger-ui 6.1.2 (3 -> 6) and @fastify/static 10, both majors. # Reachability: swagger-ui uses @fastify/static only to serve its own bundled assets under /docs, @@ -14,4 +17,4 @@ # fastify-4 major and fails registration under fastify 5 (FST_ERR_PLUGIN_VERSION_MISMATCH). # Exit condition: the fastify-5 plugin majors (cors 11, rate-limit 10, swagger 9, swagger-ui # >=6.1.2, websocket 11) are taken in a deliberate v2 change; then delete this entry. -GHSA-83w8-p2f5-377r v2/docker/lib/extensions/vf-management-api/resources +GHSA-83w8-p2f5-377r