diff --git a/.agents/workflows/cve-fix.md b/.agents/workflows/cve-fix.md index 2bae872f4..6ff4dfd56 100644 --- a/.agents/workflows/cve-fix.md +++ b/.agents/workflows/cve-fix.md @@ -1,10 +1,10 @@ #### Fixing CVEs -**Automated:** Use `/cve-fix` in Claude Code or `make cve-fix` from shipyard: +**Automated:** Use `/cve-fix` in Claude Code or `make -C skills/cve-fix` from shipyard: ```bash -/cve-fix release-0.23 ../submariner-operator -make cve-fix REPO=../submariner BRANCH=release-0.23 +/cve-fix submariner-operator 0.23 +make -C skills/cve-fix REPO=submariner BRANCH=0.23 ``` The steps below are the manual process for reference. @@ -182,7 +182,16 @@ If fix requires breaking changes (Go version, K8s major version, incompatible AP - CVE severity (Low/Medium vs High/Critical) - Cost/risk of breaking stable branch dependencies -Add to `.grype.yaml`: +**No fix available** — use `fix-state: not-fixed` so the entry auto-expires when a fix is published: +```yaml +# No fix available for [package]. [context]. +- vulnerability: GHSA-xxxx-xxxx-xxxx + fix-state: not-fixed + package: + name: package.name/path +``` + +**Fix exists but would break the branch** — permanent ignore (no fix-state): ```yaml # Update requires [incompatibility]. [Severity] doesn't justify breaking changes. - vulnerability: GHSA-xxxx-xxxx-xxxx diff --git a/Makefile b/Makefile index 10cad39ed..d9763d970 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ LOCAL_COMPONENTS := submariner-metrics-proxy MULTIARCH_IMAGES ?= $(IMAGES) EXTRA_PRELOAD_IMAGES := $(PRELOAD_IMAGES) PLATFORMS ?= linux/amd64,linux/arm64 -NON_DAPPER_GOALS += images multiarch-images cve-fix cve-clean +NON_DAPPER_GOALS += images multiarch-images PLUGIN ?= export LOCAL_COMPONENTS @@ -65,13 +65,6 @@ deploy deploy-latest e2e upgrade-e2e: package/.image.nettest include Makefile.dapper -# CVE fix scripts -cve-fix: - ./scripts/cve/fix-all.sh "$(or $(REPO),.)" "$(or $(BRANCH),$(shell git branch --show-current))" - -cve-clean: - ./scripts/cve/clean.sh - # Make sure linting goals have up-to-date linting image $(LINTING_GOALS): package/.image.shipyard-linting diff --git a/scripts/cve/fix-package.sh b/scripts/cve/fix-package.sh deleted file mode 100755 index 10c388bf9..000000000 --- a/scripts/cve/fix-package.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/bash -# Fix a single CVE package: update, verify, commit. -# Usage: fix-package.sh STATE_FILE PACKAGE VERSION CVE_ID [CVE_ID...] -# Exit 0: fixed. Exit 2: needs review (breaking change). Exit 3: CVE persists. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=lib.sh -source "$SCRIPT_DIR/lib.sh" - -STATE_FILE="${1:?Usage: fix-package.sh STATE_FILE PACKAGE VERSION CVE_ID [CVE_ID...]}" -PACKAGE="${2:?Missing PACKAGE}" -VERSION="${3:?Missing VERSION}" -shift 3 -CVE_IDS=("$@") -[[ ${#CVE_IDS[@]} -gt 0 ]] || { echo "ERROR: At least one CVE_ID required" >&2; exit 1; } - -load_state "$STATE_FILE" - -trap 'git reset --quiet HEAD -- . 2>/dev/null || true; git checkout -- . 2>/dev/null || true' ERR - -echo "--- Fixing: $PACKAGE -> v$VERSION for ${CVE_IDS[*]} ---" - -# Check for replace directives across all go.mod files (warn, don't block) -while IFS= read -r GOMOD; do - if grep -q "replace.*${PACKAGE}" "$GOMOD" 2>/dev/null; then - echo "WARNING: Replace directive found in $GOMOD for $PACKAGE" - grep "replace.*${PACKAGE}" "$GOMOD" 2>/dev/null || true - fi -done < <(find_gomods) - -# Snapshot go directives before update (for breaking-change detection) -GO_BEFORE="" -while IFS= read -r GOMOD; do - GO_VER=$(grep '^go ' "$GOMOD" 2>/dev/null | awk '{print $2}') - [[ -n "$GO_VER" ]] && GO_BEFORE+="$GOMOD:$GO_VER " -done < <(find_gomods) -# shellcheck disable=SC2046 # word splitting is intentional (multiple file args) -K8S_BEFORE=$(grep -h 'k8s.io/client-go' $(find_gomods) 2>/dev/null | grep -oP 'v0\.\K[0-9]+' | sort -un | tr '\n' ' ') - -# Update in all go.mod files that contain this package -while IFS= read -r GOMOD; do - MODDIR=$(dirname "$GOMOD") - if grep -qF "$PACKAGE" "$GOMOD" 2>/dev/null; then - echo "Updating $PACKAGE in $GOMOD..." - go -C "$MODDIR" get "${PACKAGE}@v${VERSION}" && go -C "$MODDIR" mod tidy - fi -done < <(find_gomods) - -clean_gomod - -# Check for breaking changes (Go or K8s minor version upgrade in any go.mod) -BREAKING="" -while IFS= read -r GOMOD; do - GO_AFTER=$(grep '^go ' "$GOMOD" 2>/dev/null | awk '{print $2}') - [[ -z "$GO_AFTER" ]] && continue - # Find the before version for this go.mod - for PAIR in $GO_BEFORE; do - if [[ "${PAIR%%:*}" == "$GOMOD" ]]; then - GO_WAS="${PAIR#*:}" - if [[ "$(echo "$GO_WAS" | cut -d. -f1-2)" != "$(echo "$GO_AFTER" | cut -d. -f1-2)" ]]; then - BREAKING="${BREAKING:+$BREAKING; }$GOMOD: Go $GO_WAS -> $GO_AFTER" - fi - break - fi - done -done < <(find_gomods) - -# shellcheck disable=SC2046 # word splitting is intentional (multiple file args) -K8S_AFTER=$(grep -h 'k8s.io/client-go' $(find_gomods) 2>/dev/null | grep -oP 'v0\.\K[0-9]+' | sort -un | tr '\n' ' ') -if [[ -n "$K8S_BEFORE" ]] && [[ -n "$K8S_AFTER" ]] && [[ "$K8S_BEFORE" != "$K8S_AFTER" ]]; then - BREAKING="${BREAKING:+$BREAKING; }K8s minor versions changed" -fi - -if [[ -n "$BREAKING" ]]; then - echo "NEEDS_REVIEW: $PACKAGE — would upgrade $BREAKING" - git checkout -- . || echo "ERROR: Could not rollback changes" >&2 - exit 2 -fi - -# Verify fix: check that go.mod has the new version -STILL_VULNERABLE=false -while IFS= read -r GOMOD; do - if grep -q "${PACKAGE}.*v${VERSION}" "$GOMOD" 2>/dev/null; then - : # Updated to new version, good - elif grep -qF "$PACKAGE" "$GOMOD" 2>/dev/null; then - echo "WARNING: $GOMOD still has old version of $PACKAGE" - STILL_VULNERABLE=true - fi -done < <(find_gomods) - -if [[ "$STILL_VULNERABLE" == "true" ]]; then - echo "NEEDS_REVIEW: $PACKAGE — CVE persists after update to v$VERSION" - git checkout -- . || echo "ERROR: Could not rollback changes" >&2 - exit 3 -fi - -# Stage all changed go.mod/go.sum files (some repos have extra modules like coredns/) -git diff --name-only | grep -E 'go\.(mod|sum)$' | xargs -r git add || true - -# Handle generated files -if [[ -n "$GENERATED_FILE" ]] && [[ -n "$DIFF_IGNORE_ARGS" ]]; then - # shellcheck disable=SC2086 # DIFF_IGNORE_ARGS needs word splitting (-I'pattern') - if git diff $DIFF_IGNORE_ARGS "$GENERATED_FILE" 2>/dev/null | grep -q .; then - git add "$GENERATED_FILE" - else - git checkout "$GENERATED_FILE" 2>/dev/null || true - fi -fi - -# Determine if tools-only change (all staged go files under tools/) -TOOLS_ONLY="" -if ! git diff --staged --name-only | grep -qE '^go\.(mod|sum)$' && \ - git diff --staged --name-only | grep -qE '^tools/'; then - TOOLS_ONLY=" in /tools" -fi - -# Format commit message -ABBREV=$(abbreviate_package "$PACKAGE") -if [[ ${#CVE_IDS[@]} -eq 1 ]]; then - SUBJECT="Bump ${ABBREV} for ${CVE_IDS[0]}${TOOLS_ONLY}" - BODY="Full package: $PACKAGE" -else - SUBJECT="Bump ${ABBREV} for CVEs${TOOLS_ONLY}" - FIXES=$(printf '%s, ' "${CVE_IDS[@]}") - BODY="Full package: $PACKAGE -Fixes: ${FIXES%, }" -fi - -git commit -s -m "$(printf '%s\n\n%s' "$SUBJECT" "$BODY")" - -echo "FIXED: $PACKAGE v$VERSION for ${CVE_IDS[*]}" diff --git a/scripts/cve/fix-stdlib.sh b/scripts/cve/fix-stdlib.sh deleted file mode 100755 index d5c515968..000000000 --- a/scripts/cve/fix-stdlib.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# Fix stdlib CVEs by updating the go directive. -# Usage: fix-stdlib.sh STATE_FILE GO_VERSION CVE_ID [CVE_ID...] -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=lib.sh -source "$SCRIPT_DIR/lib.sh" - -STATE_FILE="${1:?Usage: fix-stdlib.sh STATE_FILE GO_VERSION CVE_ID [CVE_ID...]}" -GO_VERSION="${2:?Missing GO_VERSION}" -shift 2 -CVE_IDS=("$@") -[[ ${#CVE_IDS[@]} -gt 0 ]] || { echo "ERROR: At least one CVE_ID required" >&2; exit 1; } - -load_state "$STATE_FILE" - -trap 'git reset --quiet HEAD -- . 2>/dev/null || true; git checkout -- . 2>/dev/null || true' ERR - -echo "--- Fixing stdlib: go $GO_VERSION for ${CVE_IDS[*]} ---" - -OLD_GO=$(grep '^go ' go.mod | awk '{print $2}') - -# Check for Go minor version upgrade (breaking on stable branches) -OLD_MINOR=$(echo "$OLD_GO" | cut -d. -f1-2) -NEW_MINOR=$(echo "$GO_VERSION" | cut -d. -f1-2) -if [[ "$OLD_MINOR" != "$NEW_MINOR" ]]; then - echo "NEEDS_REVIEW: stdlib — would upgrade Go $OLD_GO -> $GO_VERSION (minor version change)" - exit 2 -fi - -# Check host Go version is sufficient -HOST_GO=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+\.[0-9]+') -if [[ -n "$HOST_GO" ]] && [[ "${GOTOOLCHAIN:-}" != *auto* ]] && \ - [[ "$(printf '%s\n' "$HOST_GO" "$GO_VERSION" | sort -V | head -1)" == "$HOST_GO" ]] && \ - [[ "$HOST_GO" != "$GO_VERSION" ]]; then - echo "NEEDS_REVIEW: stdlib — host Go $HOST_GO is older than required $GO_VERSION" - echo "Update Go: go install golang.org/dl/go${GO_VERSION}@latest && go${GO_VERSION} download" - echo "Or set GOTOOLCHAIN=auto to allow automatic toolchain downloads." - exit 2 -fi - -# Update go directive in all go.mod files -echo "Updating go directive and tidying modules..." -while IFS= read -r GOMOD; do - MODDIR=$(dirname "$GOMOD") - go -C "$MODDIR" mod edit -go="${GO_VERSION}" - go -C "$MODDIR" mod tidy -done < <(find_gomods) - -clean_gomod - -# Check if Shipyard build image has sufficient Go version -if [[ "$SHIPYARD_GO_VERSION" != "unknown" ]]; then - SHIPYARD_GO=$(echo "$SHIPYARD_GO_VERSION" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || echo "") - if [[ -n "$SHIPYARD_GO" ]]; then - OLDEST=$(printf '%s\n' "$SHIPYARD_GO" "$GO_VERSION" | sort -V | head -1) - if [[ "$OLDEST" == "$SHIPYARD_GO" ]] && [[ "$SHIPYARD_GO" != "$GO_VERSION" ]]; then - echo "NOTE: Shipyard build image has Go $SHIPYARD_GO but go.mod now requires $GO_VERSION" - echo "CI may fail until Shipyard is updated with a newer Go version." - fi - fi -fi - -# Stage all changed go.mod/go.sum files -git diff --name-only | grep -E 'go\.(mod|sum)$' | xargs -r git add || true - -FIXES=$(printf '%s, ' "${CVE_IDS[@]}") -FIXES="${FIXES%, }" -git commit -s -m "$(cat <&2 - echo "Run detect.sh first." >&2 - return 1 - fi - # shellcheck source=/dev/null - source "$state_file" - cd "$REPO" || return 1 -} - -# Detect container runtime: docker, podman, or empty string -detect_container_cmd() { - if command -v docker &>/dev/null && docker info &>/dev/null; then - echo "docker" - elif command -v podman &>/dev/null && podman info &>/dev/null; then - echo "podman" - else - echo "" - fi -} - -# Run grype scan via local install or container fallback -# Usage: run_grype [--fresh] [--no-update] [--json] -# --fresh: ensure DB is up-to-date (grype auto-updates if stale) -# --no-update: skip DB update check (use after a recent --fresh scan) -# --json: output JSON instead of table -run_grype() { - local fresh=false no_update=false output_format="table" - for arg in "$@"; do - case "$arg" in - --fresh) fresh=true ;; - --no-update) no_update=true ;; - --json) output_format="json" ;; - esac - done - - # Prefer local grype (fast, uses host-cached DB) - if [[ "$HAS_LOCAL_GRYPE" == "true" ]]; then - if [[ "$no_update" == "true" ]]; then - GRYPE_DB_AUTO_UPDATE=false GRYPE_DB_VALIDATE_AGE=false \ - grype . --config .grype.yaml -o "$output_format" - else - grype . --config .grype.yaml -o "$output_format" - fi - return $? - fi - - # Fall back to container - if [[ -n "$CONTAINER_CMD" ]]; then - if [[ "$fresh" == "true" ]]; then - $CONTAINER_CMD volume create grype-db >/dev/null 2>&1 || true - echo "Updating vulnerability database (container)..." >&2 - if ! $CONTAINER_CMD run --pull=always --rm \ - -v grype-db:/.cache/grype anchore/grype:latest db update >&2; then - echo "WARNING: DB update failed. Scan will use cached data." >&2 - fi - fi - local -a env_args=() - if [[ "$no_update" == "true" ]]; then - env_args=(-e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_DB_VALIDATE_AGE=false) - fi - if $CONTAINER_CMD run --rm "${env_args[@]}" \ - -v grype-db:/.cache/grype \ - -v "$(pwd)":/src \ - anchore/grype:latest /src --config /src/.grype.yaml -o "$output_format"; then - return 0 - fi - echo "WARNING: Container scan failed." >&2 - fi - - echo "ERROR: No scanner available." >&2 - echo " Install grype locally or docker/podman." >&2 - return 1 -} - -# Abbreviate Go package path for commit messages -# github.com/docker/docker -> docker/docker -# golang.org/x/net -> x/net -# helm.sh/helm/v3 -> helm/v3 -# go.opentelemetry.io/otel -> otel -# go.opentelemetry.io/otel/exporters/otlp/*/X -> otel/X -# google.golang.org/grpc -> grpc -# sigs.k8s.io/controller-runtime -> controller-runtime -# k8s.io/* stays as-is -abbreviate_package() { - local pkg="$1" - case "$pkg" in - github.com/*) echo "${pkg#github.com/}" ;; - golang.org/x/*) echo "x/${pkg#golang.org/x/}" ;; - helm.sh/*) echo "${pkg#helm.sh/}" ;; - go.opentelemetry.io/otel/exporters/otlp/*) - echo "otel/$(basename "$pkg")" ;; - go.opentelemetry.io/*) echo "${pkg#go.opentelemetry.io/}" ;; - google.golang.org/*) echo "${pkg#google.golang.org/}" ;; - sigs.k8s.io/*) echo "${pkg#sigs.k8s.io/}" ;; - *) echo "$pkg" ;; - esac -} - -# Find all go.mod files in the repo (excluding vendor and gitignored dirs) -find_gomods() { - git ls-files --cached --others --exclude-standard '*/go.mod' 'go.mod' 2>/dev/null || \ - find . -name go.mod -not -path '*/vendor/*' -} - -# Remove artifacts added by go mod tidy from all go.mod files: -# - toolchain directive (Shipyard image controls the build toolchain; -# uses sed because go mod edit has no -droptoolchain flag) -# - consecutive blank lines (tidy sometimes adds extra whitespace) -clean_gomod() { - local gomod - while IFS= read -r gomod; do - sed -i '/^toolchain/d' "$gomod" - sed -i '/^$/{N;/^\n$/s/\n//;}' "$gomod" - done < <(find_gomods) -} - -# Insert an ignore entry into a .grype.yaml file. -# Inserts before exclude: section if present, otherwise appends. -# Usage: insert_grype_ignore FILE CVE_ID PACKAGE REASON -insert_grype_ignore() { - local file="$1" cve_id="$2" package="$3" reason="$4" - local entry - entry=" # $reason - - vulnerability: $cve_id - package: - name: $package" - - if grep -qn '^exclude:' "$file" 2>/dev/null; then - local exclude_line - exclude_line=$(grep -n '^exclude:' "$file" | head -1 | cut -d: -f1) - { - head -n "$((exclude_line - 1))" "$file" - printf '%s\n' "$entry" - tail -n +"$exclude_line" "$file" - } > "${file}.tmp" - mv "${file}.tmp" "$file" - else - printf '\n%s\n' "$entry" >> "$file" - fi -} - -# Print a section header -banner() { - echo "" - echo "--- $1 ---" -} diff --git a/scripts/cve/review-prompt.md b/scripts/cve/review-prompt.md deleted file mode 100644 index 865a52aa8..000000000 --- a/scripts/cve/review-prompt.md +++ /dev/null @@ -1,58 +0,0 @@ -# CVE Fix Review: ${REPO} ${BRANCH} - -You are reviewing CVE fix results for ${REPO} on ${BRANCH}. -The deterministic phase has already attempted to fix all CVEs. -All evidence has been pre-fetched below. - -## Fix Results - -${FIX_SUMMARY} - -## Current Scan - -${CURRENT_SCAN} - -## Unfixed CVE Details - -${LOCATE_OUTPUT} - -## Build Environment - -Go version in Shipyard build image: ${SHIPYARD_GO_VERSION} - -## Task - -Review ALL CVE outcomes: - -1. **Verify fixes**: Do the committed fixes look correct? Any concerns? - -2. **Handle unfixed CVEs**: For each CVE that was not fixed: - - Try a different approach if possible (different version, drop replace directive) - - If fix would break the branch: add to .grype.yaml ignore list. - **Batch all CVEs for the same package into one ignore.sh call** (it accepts multiple CVE IDs). - Run: `bash ${CVE_SCRIPTS}/ignore.sh ${STATE_FILE} PACKAGE SEVERITY "reason" CVE_ID [CVE_ID...]` - - Note anything that needs team discussion - -3. **Check for regressions**: Did any fix introduce new CVEs? - -## Available Actions - -ONLY use these scripts. Do NOT run go/git/sed commands directly. - -- `bash ${CVE_SCRIPTS}/fix-package.sh ${STATE_FILE} PACKAGE VERSION CVE_IDS...` -- `bash ${CVE_SCRIPTS}/fix-stdlib.sh ${STATE_FILE} GO_VERSION CVE_IDS...` -- `bash ${CVE_SCRIPTS}/ignore.sh ${STATE_FILE} PACKAGE SEVERITY "reason" CVE_ID [CVE_ID...]` -- `bash ${CVE_SCRIPTS}/scan.sh ${STATE_FILE}` - -If a script exits with NEEDS_REVIEW, do NOT attempt the same fix manually. -Use ignore.sh to document it, or report it as UNRESOLVED. - -## Output - -End with a summary: - -```text -FIXED: N packages (list) -IGNORED: M packages (list with reasons) -UNRESOLVED: P packages (list — need team input) -``` diff --git a/scripts/cve/test-lib.sh b/scripts/cve/test-lib.sh deleted file mode 100755 index 8f267d84e..000000000 --- a/scripts/cve/test-lib.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -# Unit tests for CVE fix library functions. -# Runs without docker, grype, or network access. -# Uses the real shipyard repo for integration tests. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# shellcheck source-path=SCRIPTDIR -# shellcheck source=lib.sh -source "$SCRIPT_DIR/lib.sh" - -PASS=0 FAIL=0 -check() { - local desc="$1"; shift - local negate=false - [[ "$1" == "!" ]] && { negate=true; shift; } - local rc=0; "$@" 2>/dev/null || rc=$? - if { [[ "$negate" == false ]] && [[ $rc -eq 0 ]]; } || \ - { [[ "$negate" == true ]] && [[ $rc -ne 0 ]]; }; then - PASS=$((PASS + 1)) - else - echo " FAIL: $desc"; FAIL=$((FAIL + 1)) - fi -} - -TMPD=$(mktemp -d) -trap 'rm -rf "$TMPD"' EXIT - -# === Pure functions === -echo "pure functions" -check "state_file_path" bash -c "[[ $(state_file_path /x/shipyard release-0.23) == /tmp/cve-fix-shipyard-release-0-23-*.env ]]" -check "state_file_path devel" bash -c "[[ $(state_file_path /x/admiral devel) == /tmp/cve-fix-admiral-devel-*.env ]]" -check "abbrev github" test "$(abbreviate_package github.com/docker/docker)" = "docker/docker" -check "abbrev x/" test "$(abbreviate_package golang.org/x/net)" = "x/net" -check "abbrev helm" test "$(abbreviate_package helm.sh/helm/v3)" = "helm/v3" -check "abbrev k8s" test "$(abbreviate_package k8s.io/client-go)" = "k8s.io/client-go" -check "abbrev other" test "$(abbreviate_package go.etcd.io/bbolt)" = "go.etcd.io/bbolt" -check "abbrev nested" test "$(abbreviate_package github.com/go-git/go-git/v5)" = "go-git/go-git/v5" -check "abbrev otel" test "$(abbreviate_package go.opentelemetry.io/otel)" = "otel" -check "abbrev otel/sdk" test "$(abbreviate_package go.opentelemetry.io/otel/sdk)" = "otel/sdk" -check "abbrev otel deep" test "$(abbreviate_package go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp)" = "otel/otlptracehttp" -check "abbrev grpc" test "$(abbreviate_package google.golang.org/grpc)" = "grpc" -check "abbrev sigs" test "$(abbreviate_package sigs.k8s.io/controller-runtime)" = "controller-runtime" -CVE_LIST=("GHSA-aaaa" "GHSA-bbbb"); JOINED=$(printf '%s, ' "${CVE_LIST[@]}"); JOINED="${JOINED%, }" -check "CVE join" test "$JOINED" = "GHSA-aaaa, GHSA-bbbb" - -# === clean_gomod === -echo "clean_gomod" -printf 'module t\ngo 1.25.0\ntoolchain go1.25.1\n' > "$TMPD/go.mod" -(cd "$TMPD" && clean_gomod) -check "toolchain removed" test -z "$(grep toolchain "$TMPD/go.mod")" -check "go kept" grep -q "^go 1.25.0" "$TMPD/go.mod" - -# === load_state === -echo "load_state" -check "missing fails" ! load_state /tmp/nonexistent-cve-test.env -printf 'REPO="%s"\nBRANCH="devel"\n' "$TMPD" > "$TMPD/s.env" -check "loads vars" bash -c "source '$SCRIPT_DIR/lib.sh'; load_state '$TMPD/s.env' && [[ \$BRANCH = devel ]]" - -# === insert_grype_ignore === -echo "insert_grype_ignore" -cp "$REPO_ROOT/.grype.yaml" "$TMPD/g.yaml" -insert_grype_ignore "$TMPD/g.yaml" GHSA-test-1234 example.com/pkg "Test reason" -check "before exclude" test "$(grep -n GHSA-test-1234 "$TMPD/g.yaml" | cut -d: -f1)" -lt "$(grep -n '^exclude:' "$TMPD/g.yaml" | cut -d: -f1)" -check "entry present" grep -q GHSA-test-1234 "$TMPD/g.yaml" -check "original kept" grep -q CVE-2015-5237 "$TMPD/g.yaml" -printf -- '---\nignore:\n - vulnerability: CVE-1\n package:\n name: x\n' > "$TMPD/no-exc.yaml" -insert_grype_ignore "$TMPD/no-exc.yaml" GHSA-append example.com/other "No exclude" -check "appends" grep -q GHSA-append "$TMPD/no-exc.yaml" - -# === detect.sh (real repo) === -echo "detect.sh" -DETECT_OUT=$("$SCRIPT_DIR/detect.sh" "$REPO_ROOT" 0.23 2>&1) || true -check "normalizes 0.23" grep -q "release-0.23" <<< "$DETECT_OUT" -STATE=$(tail -1 <<< "$DETECT_OUT") -check "creates state" test -f "$STATE"; rm -f "$STATE" -check "bad repo fails" ! "$SCRIPT_DIR/detect.sh" /nonexistent devel -check "non-git fails" ! "$SCRIPT_DIR/detect.sh" /tmp devel - -# === locate.sh (real repo, inline state) === -echo "locate.sh" -printf 'REPO="%s"\nBRANCH="devel"\nCVE_SCRIPTS="%s"\n' "$REPO_ROOT" "$SCRIPT_DIR" > "$TMPD/loc.env" -LOCATE_OUT=$(bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" k8s.io/client-go 2>&1) || true -check "finds in go.mod" grep -q "Found in:" <<< "$LOCATE_OUT" -LOCATE_OUT=$(bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" github.com/golangci/golangci-lint 2>&1) || true -check "finds in tools" grep -q "tools" <<< "$LOCATE_OUT" -check "missing fails" ! bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" nonexistent/pkg >/dev/null - -# === Summary === -echo "" -TOTAL=$((PASS + FAIL)) -echo "$PASS/$TOTAL passed" -if [[ "$FAIL" -gt 0 ]]; then echo "$FAIL FAILED"; exit 1; fi diff --git a/skills/cve-fix/Makefile b/skills/cve-fix/Makefile new file mode 100644 index 000000000..26dfac174 --- /dev/null +++ b/skills/cve-fix/Makefile @@ -0,0 +1,36 @@ +help: + @echo "" + @echo " CVE fix skill" + @echo " ─────────────" + @echo "" + @echo " Fix" + @echo " make fix REPO=admiral BRANCH=0.23 One repo (name from repos.yaml)" + @echo " make fix REPO=/absolute/path BRANCH=0.23" + @echo " make all BRANCH=0.24 All repos in repos.yaml" + @echo "" + @echo " Manage" + @echo " make clean Kill orphaned CVE fix processes" + @echo " make test Run unit tests" + @echo "" + @echo " Repos: $(shell grep '^\s*- name:' repos.yaml | awk '{printf "%s ", $$NF}')" + @echo "" + @echo " Config: repos.yaml" + @echo "" + +fix: + $(if $(REPO),,$(error REPO is required (name from repos.yaml or absolute path))) + $(if $(BRANCH),,$(error BRANCH is required (e.g., 0.23, release-0.23, or devel))) + ./scripts/fix-all.sh "$(REPO)" "$(BRANCH)" + +all: + $(if $(BRANCH),,$(error BRANCH is required for all)) + @grep '^\s*- name:' repos.yaml | awk '{print $$NF}' | \ + xargs -I{} $(MAKE) fix REPO={} BRANCH=$(BRANCH) + +clean: + ./scripts/clean.sh + +test: + bash scripts/test-lib.sh + +.PHONY: help fix all clean test diff --git a/skills/cve-fix/SKILL.md b/skills/cve-fix/SKILL.md index c99adb971..cd16152fb 100644 --- a/skills/cve-fix/SKILL.md +++ b/skills/cve-fix/SKILL.md @@ -1,30 +1,32 @@ --- name: cve-fix -description: Fix CVEs in Submariner Go repositories. Arguments are optional and order-independent. TRIGGER when user asks to fix CVEs, scan for vulnerabilities, or mentions grype/CVE/GHSA. -argument-hint: "[branch] [repo]" +description: Fix CVEs in Go repositories. Arguments are optional and order-independent. TRIGGER when user asks to fix CVEs, scan for vulnerabilities, or mentions grype/CVE/GHSA. +argument-hint: "[branch] [repo | all]" user-invocable: true -allowed-tools: Bash, Read +allowed-tools: Bash, Read, Agent context: fork --- # CVE Fix Workflow -Run the command below exactly as written. Do not read, debug, or modify -the CVE scripts. If a bare repo name like `subctl` is passed, convert it -to `../subctl` before running. +Do not read, debug, or modify the CVE scripts. + +## Single Repo (default) + +If the arguments do NOT contain `all`, run this command exactly as written: ```bash #!/bin/bash set -euo pipefail -# Find scripts directory (in shipyard repo) -CVE_SCRIPTS="$(pwd)/scripts/cve" -if [[ ! -d "$CVE_SCRIPTS" ]]; then - CVE_SCRIPTS="$HOME/go/src/submariner-io/shipyard/scripts/cve" -fi -if [[ ! -d "$CVE_SCRIPTS" ]]; then - echo "ERROR: Cannot find scripts/cve/ directory" - echo "Expected in current directory or ~/go/src/submariner-io/shipyard/" +# Find scripts directory +CVE_SCRIPTS="" +for d in "$(pwd)/skills/cve-fix/scripts" \ + "$HOME/.claude/plugins/cve-fix/scripts"; do + [[ -d "$d" ]] && CVE_SCRIPTS="$d" && break +done +if [[ -z "$CVE_SCRIPTS" ]]; then + echo "ERROR: Cannot find skills/cve-fix/scripts/ directory" exit 1 fi @@ -41,33 +43,41 @@ the PR command. **Exit code 1**: Error. -## Multi-Repo +## Multi-Repo ("all") + +If the arguments contain `all` (e.g., `/cve-fix all 0.23`), do NOT run +the bash block above. Instead: + +1. Find the skill directory: look for `skills/cve-fix/` in the current + repo, or under `~/.claude/plugins/`. +2. Read `repos.yaml` from that directory for the list of repo names. +3. Extract the branch from the remaining arguments (e.g., `0.23`). +4. Spawn one Agent per repo. Each agent should run + `bash SKILL_DIR/scripts/fix-all.sh NAME BRANCH` via the Bash + tool (not the Skill tool, which times out in subagents). -For multiple repos, spawn one agent per repo. Each agent should run -`bash ~/go/src/submariner-io/shipyard/scripts/cve/fix-all.sh REPO BRANCH` -via the Bash tool (not the Skill tool, which times out in subagents). Report per repo: CVEs found, fixed, ignored, and PR command. On errors -or timeout, clean up orphaned processes with -`bash ~/go/src/submariner-io/shipyard/scripts/cve/clean.sh` before reporting. +or timeout, run `bash SKILL_DIR/scripts/clean.sh` before reporting. Never modify the CVE fix scripts themselves. ## Usage - `/cve-fix` - current repo, current branch -- `/cve-fix 0.23` - current repo, specified branch (short form) -- `/cve-fix ../submariner-operator` - specified repo, current branch -- `/cve-fix release-0.23 ../submariner-operator` - both specified (order doesn't matter) +- `/cve-fix 0.23` - current repo, specified branch +- `/cve-fix submariner-operator 0.24` - repo name from repos.yaml +- `/cve-fix /absolute/path/to/repo 0.24` - absolute path +- `/cve-fix all 0.23` - all repos in repos.yaml Arguments are order-independent. Short versions like `0.23` auto-expand -to `release-0.23`. Repos must be paths. If a bare name like `subctl` is passed, resolve it -to `../subctl` (from any submariner repo) or `~/go/src/submariner-io/subctl`. +to `release-0.23`. Repos are absolute paths or names from repos.yaml. **From the command line** (without Claude): ```bash -make cve-fix # current repo, current branch -make cve-fix BRANCH=release-0.23 # current repo, specified branch -make cve-fix REPO=../submariner-operator BRANCH=release-0.23 # specified repo and branch +make -C skills/cve-fix # current repo, current branch +make -C skills/cve-fix BRANCH=0.23 # specified branch +make -C skills/cve-fix REPO=submariner-operator BRANCH=0.24 # repo from repos.yaml +make -C skills/cve-fix all BRANCH=0.23 # all repos ``` ## Common Issues @@ -78,5 +88,5 @@ make cve-fix REPO=../submariner-operator BRANCH=release-0.23 # specified re | New CVE appears after fix | Dependency downgrade introduced it; fix immediately | | Tests fail | Try different version; check CI logs | | Container "no route to host" | Run `sudo systemctl restart docker` or `sudo systemctl restart podman` | -| Stdlib CVEs | Fixed via go directive update. Check Shipyard Go version if CI fails | +| Stdlib CVEs | Fixed via go directive update. Check build image Go version if CI fails | | Git fetch fails | Run `git fetch` manually before starting | diff --git a/skills/cve-fix/repos.yaml b/skills/cve-fix/repos.yaml new file mode 100644 index 000000000..386eb3c99 --- /dev/null +++ b/skills/cve-fix/repos.yaml @@ -0,0 +1,16 @@ +--- +repos: + - name: submariner-operator + path: ~/go/src/submariner-io/submariner-operator + - name: submariner + path: ~/go/src/submariner-io/submariner + - name: lighthouse + path: ~/go/src/submariner-io/lighthouse + - name: admiral + path: ~/go/src/submariner-io/admiral + - name: cloud-prepare + path: ~/go/src/submariner-io/cloud-prepare + - name: shipyard + path: ~/go/src/submariner-io/shipyard + - name: subctl + path: ~/go/src/submariner-io/subctl diff --git a/scripts/cve/clean.sh b/skills/cve-fix/scripts/clean.sh similarity index 74% rename from scripts/cve/clean.sh rename to skills/cve-fix/scripts/clean.sh index 8a8a4076f..aa7b58fab 100755 --- a/scripts/cve/clean.sh +++ b/skills/cve-fix/scripts/clean.sh @@ -1,5 +1,5 @@ #!/bin/bash # Kill orphaned CVE fix processes and containers. -# Safe to run anytime — only targets fix-all.sh, grype, and dapper fix containers. +# Safe to run anytime — only targets fix-all.sh, grype, and build containers. pkill -f fix-all.sh 2>/dev/null || true docker ps --format '{{.ID}} {{.Image}}' 2>/dev/null | grep -E 'grype:latest|fix-.*-cves-' | awk '{print $1}' | xargs -r docker kill 2>/dev/null || true diff --git a/scripts/cve/detect.sh b/skills/cve-fix/scripts/detect.sh similarity index 54% rename from scripts/cve/detect.sh rename to skills/cve-fix/scripts/detect.sh index ccc758ae2..b35ef5e44 100755 --- a/scripts/cve/detect.sh +++ b/skills/cve-fix/scripts/detect.sh @@ -13,20 +13,20 @@ source "$SCRIPT_DIR/lib.sh" REPO="" BRANCH="" SETUP_BRANCH=false +REPOS_YAML="$SCRIPT_DIR/../repos.yaml" for arg in "$@"; do case "$arg" in --setup-branch) SETUP_BRANCH=true ;; *) - # Expand tilde arg="${arg/#\~/$HOME}" - # Try sibling directory for bare names (e.g., "subctl" -> "../subctl") - if [[ "$arg" != */* ]] && [[ -d "../$arg" ]]; then - arg="../$arg" - fi - if [[ "$arg" == /* ]] || [[ "$arg" == ./* ]] || [[ "$arg" == ../* ]] || [[ -d "$arg" ]]; then + if [[ "$arg" == /* ]]; then [[ -n "$REPO" ]] && { echo "ERROR: Multiple repositories specified" >&2; exit 1; } REPO="$arg" + elif [[ -f "$REPOS_YAML" ]] && grep -q "^ - name: ${arg}$" "$REPOS_YAML"; then + [[ -n "$REPO" ]] && { echo "ERROR: Multiple repositories specified" >&2; exit 1; } + REPO=$(grep -A1 "^ - name: ${arg}$" "$REPOS_YAML" | grep 'path:' | awk '{print $2}') + REPO="${REPO/#\~/$HOME}" else [[ -n "$BRANCH" ]] && { echo "ERROR: Multiple branches specified" >&2; exit 1; } BRANCH="$arg" @@ -66,6 +66,9 @@ echo "=== CVE Fix: $REPO_NAME/$BRANCH ===" HAS_TOOLS_GOMOD=false test -f tools/go.mod && HAS_TOOLS_GOMOD=true +HAS_VENDOR=false +test -d vendor && HAS_VENDOR=true + GENERATED_FILE="" DIFF_IGNORE_ARGS="" if grep -rql "controller-gen.kubebuilder.io/version" --include="*.go" . 2>/dev/null; then @@ -77,38 +80,68 @@ elif find . -name "*.pb.go" -type f 2>/dev/null | head -1 | grep -q .; then fi NEEDS_BUILD_FOR_SCAN=false -grep -q '^build:' Makefile 2>/dev/null && NEEDS_BUILD_FOR_SCAN=true +has_make_target() { make -q "$1" 2>/dev/null; [[ $? -ne 2 ]]; } +has_make_target build && NEEDS_BUILD_FOR_SCAN=true CONTAINER_CMD=$(detect_container_cmd) HAS_LOCAL_GRYPE=false command -v grype &>/dev/null && HAS_LOCAL_GRYPE=true -# Shipyard build image -if [[ "$BRANCH" == "devel" ]]; then - SHIPYARD_TAG="devel" -elif [[ "$BRANCH" =~ ^release- ]]; then - SHIPYARD_TAG="$BRANCH" -else - echo "WARNING: Unknown branch pattern, assuming devel build image" - SHIPYARD_TAG="devel" +# --- Read .cve-fix.yaml config (optional) --- +BUILD_IMAGE="" +BUILD_CMD="" +CLEAN_CMD="" +TEST_CMD="" +ENV_UNSET="" +K8S_VERSION_GUARD=false +UPSTREAM_ORG="" +read_config "$REPO" + +# Auto-detect build/clean/test commands if not set by config +if [[ -z "$BUILD_CMD" ]] && [[ "$NEEDS_BUILD_FOR_SCAN" == "true" ]]; then + BUILD_CMD="make build" +fi +if [[ -z "$CLEAN_CMD" ]] && has_make_target clean; then + CLEAN_CMD="make clean" +fi +if [[ -z "$TEST_CMD" ]]; then + if has_make_target unit; then TEST_CMD="make unit" + elif has_make_target test; then TEST_CMD="make test" + else TEST_CMD="go test ./..." + fi fi -SHIPYARD_IMAGE="quay.io/submariner/shipyard-dapper-base:${SHIPYARD_TAG}" -SHIPYARD_GO_VERSION="unknown" -if [[ -n "$CONTAINER_CMD" ]] && [[ -n "$($CONTAINER_CMD image ls -q "$SHIPYARD_IMAGE" 2>/dev/null)" ]]; then - SHIPYARD_GO_VERSION=$($CONTAINER_CMD run --rm "$SHIPYARD_IMAGE" go version 2>/dev/null || echo "unknown") +# Detect Go version from build image, host, or go.mod +BUILD_GO_VERSION="unknown" +if [[ -n "$BUILD_IMAGE" ]] && [[ -n "$CONTAINER_CMD" ]]; then + if [[ -n "$($CONTAINER_CMD image ls -q "$BUILD_IMAGE" 2>/dev/null)" ]]; then + BUILD_GO_VERSION=$($CONTAINER_CMD run --rm "$BUILD_IMAGE" go version 2>/dev/null || echo "unknown") + fi +fi +if [[ "$BUILD_GO_VERSION" == "unknown" ]] && command -v go &>/dev/null; then + BUILD_GO_VERSION=$(go version 2>/dev/null || echo "unknown") fi # --- Branch setup (optional) --- ORIGINAL_REF="" FIX_BRANCH="" FETCH_FAILED=false +WORKTREE_DIR="" + +# Detect self-referential fix (fixing the repo that contains these scripts). +# Checkout would overwrite the scripts on disk, so use a worktree instead. +SCRIPT_REPO=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "") +SELF_FIX=false +[[ "$SCRIPT_REPO" == "$REPO" ]] && SELF_FIX=true if [[ "$SETUP_BRANCH" == "true" ]]; then - if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then - echo "ERROR: Working tree has uncommitted changes. Commit or stash first." >&2 - exit 1 + # Uncommitted changes check: skip for self-fix (worktree won't touch working tree) + if [[ "$SELF_FIX" != "true" ]]; then + if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then + echo "ERROR: Working tree has uncommitted changes. Commit or stash first." >&2 + exit 1 + fi fi ORIGINAL_REF=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) @@ -130,11 +163,24 @@ if [[ "$SETUP_BRANCH" == "true" ]]; then done FIX_BRANCH="${FIX_BRANCH}${SUFFIX}" - if ! git checkout -b "$FIX_BRANCH" "origin/$BRANCH" 2>/dev/null; then - echo "ERROR: Could not create fix branch from origin/$BRANCH" >&2 - exit 1 + if [[ "$SELF_FIX" == "true" ]]; then + WORKTREE_DIR=$(mktemp -d) + if ! git worktree add -b "$FIX_BRANCH" "$WORKTREE_DIR" "origin/$BRANCH" 2>/dev/null; then + rm -rf "$WORKTREE_DIR" + echo "ERROR: Could not create worktree from origin/$BRANCH" >&2 + exit 1 + fi + REPO="$WORKTREE_DIR" + # Re-read config from worktree if present + read_config "$WORKTREE_DIR" + echo "Fix branch: $FIX_BRANCH (worktree: $WORKTREE_DIR)" + else + if ! git checkout -b "$FIX_BRANCH" "origin/$BRANCH" 2>/dev/null; then + echo "ERROR: Could not create fix branch from origin/$BRANCH" >&2 + exit 1 + fi + echo "Fix branch: $FIX_BRANCH" fi - echo "Fix branch: $FIX_BRANCH" fi # --- Write state file --- @@ -146,14 +192,22 @@ BRANCH="$BRANCH" FIX_BRANCH="$FIX_BRANCH" ORIGINAL_REF="$ORIGINAL_REF" HAS_TOOLS_GOMOD=$HAS_TOOLS_GOMOD +HAS_VENDOR=$HAS_VENDOR GENERATED_FILE="$GENERATED_FILE" DIFF_IGNORE_ARGS="$DIFF_IGNORE_ARGS" NEEDS_BUILD_FOR_SCAN=$NEEDS_BUILD_FOR_SCAN CONTAINER_CMD="$CONTAINER_CMD" HAS_LOCAL_GRYPE=$HAS_LOCAL_GRYPE -SHIPYARD_IMAGE="$SHIPYARD_IMAGE" -SHIPYARD_GO_VERSION="$SHIPYARD_GO_VERSION" +BUILD_IMAGE="$BUILD_IMAGE" +BUILD_GO_VERSION="$BUILD_GO_VERSION" +BUILD_CMD="$BUILD_CMD" +CLEAN_CMD="$CLEAN_CMD" +TEST_CMD="$TEST_CMD" +ENV_UNSET="$ENV_UNSET" +K8S_VERSION_GUARD=$K8S_VERSION_GUARD +UPSTREAM_ORG="$UPSTREAM_ORG" FETCH_FAILED=$FETCH_FAILED +WORKTREE_DIR="$WORKTREE_DIR" CVE_SCRIPTS="$SCRIPT_DIR" EOF diff --git a/scripts/cve/fix-all.sh b/skills/cve-fix/scripts/fix-all.sh similarity index 54% rename from scripts/cve/fix-all.sh rename to skills/cve-fix/scripts/fix-all.sh index 08c576dad..74cd61035 100755 --- a/scripts/cve/fix-all.sh +++ b/skills/cve-fix/scripts/fix-all.sh @@ -16,12 +16,13 @@ source "$SCRIPT_DIR/lib.sh" # shellcheck disable=SC2317,SC2329 # invoked via trap cleanup() { kill -- -$$ 2>/dev/null || true - # Dapper containers run in their own namespace and survive process group kill - if [[ -n "${FIX_BRANCH:-}" ]]; then + # Build containers may survive process group kill + if [[ -n "${FIX_BRANCH:-}" ]] && command -v docker &>/dev/null; then docker ps --format '{{.ID}} {{.Image}}' 2>/dev/null | grep -F ":${FIX_BRANCH}" | awk '{print $1}' | xargs -r docker kill 2>/dev/null || true fi git checkout -- . 2>/dev/null || true rm -f "${STATE_FILE:-}" + rm -rf "/tmp/cve-fix-subagents-"*"-$$" 2>/dev/null || true } trap cleanup EXIT trap 'exit 1' INT TERM @@ -35,17 +36,17 @@ source "$STATE_FILE" cd "$REPO" echo "" -if [[ "$NEEDS_BUILD_FOR_SCAN" == "true" ]]; then +if [[ -n "${CLEAN_CMD:-}" ]]; then echo "Cleaning build artifacts..." - if ! make clean >/dev/null 2>&1; then - echo "WARNING: make clean failed. Continuing with existing artifacts." + if ! eval "$CLEAN_CMD" >/dev/null 2>&1; then + echo "WARNING: clean command failed. Continuing with existing artifacts." fi fi echo "Scanning for CVEs..." SCAN_JSON=$("$SCRIPT_DIR/scan.sh" "$STATE_FILE" --fresh --json || true) -if ! MATCH_COUNT=$(printf '%s\n' "$SCAN_JSON" | jq '.matches | length' 2>/dev/null) || [[ -z "$MATCH_COUNT" ]]; then +if ! MATCH_COUNT=$(printf '%s\n' "$SCAN_JSON" | jq '[.matches[] | select(.artifact.type == "go-module")] | length' 2>/dev/null) || [[ -z "$MATCH_COUNT" ]]; then echo "ERROR: Scan failed or produced invalid output." >&2 echo "Check scanner availability (grype or docker/podman)." >&2 exit 1 @@ -54,8 +55,14 @@ fi if [[ "$MATCH_COUNT" -eq 0 ]]; then echo "No CVEs found. $(basename "$REPO")/$BRANCH is clean." if [[ -n "$FIX_BRANCH" ]]; then - git checkout "$ORIGINAL_REF" 2>/dev/null - git branch -D "$FIX_BRANCH" 2>/dev/null + if [[ -n "$WORKTREE_DIR" ]]; then + cd / 2>/dev/null || true + git -C "$SCRIPT_DIR" worktree remove --force "$WORKTREE_DIR" 2>/dev/null || true + git -C "$SCRIPT_DIR" branch -D "$FIX_BRANCH" 2>/dev/null || true + else + git checkout "$ORIGINAL_REF" 2>/dev/null + git branch -D "$FIX_BRANCH" 2>/dev/null + fi fi exit 0 fi @@ -65,7 +72,7 @@ echo "Found $MATCH_COUNT CVE(s) in $(basename "$REPO")/$BRANCH." # Show summary table from JSON printf '%s\n' "$SCAN_JSON" | jq -r ' ["NAME","INSTALLED","FIXED-IN","VULNERABILITY","SEVERITY"], - (.matches[] | [.artifact.name, .artifact.version, (.vulnerability.fix.versions[0] // ""), .vulnerability.id, .vulnerability.severity]) | + (.matches[] | select(.artifact.type == "go-module") | [.artifact.name, .artifact.version, (.vulnerability.fix.versions[0] // ""), .vulnerability.id, .vulnerability.severity]) | @tsv' 2>/dev/null | column -t || true # --- Parse CVEs, group by package, pick highest fix version --- @@ -75,53 +82,66 @@ banner "Fixing CVEs" # jq outputs: PACKAGE TAB FIXED_IN TAB CVE_ID (one line per match) CVE_LINES=$(printf '%s\n' "$SCAN_JSON" | jq -r ' [.matches[] | - select(.vulnerability.fix.versions != null and (.vulnerability.fix.versions | length) > 0) | + select(.artifact.type == "go-module") | { pkg: .artifact.name, - fixedIn: .vulnerability.fix.versions[0], + fixedIn: (.vulnerability.fix.versions[0] // ""), + allFixVersions: ((.vulnerability.fix.versions // []) | join(",")), cve: .vulnerability.id, severity: .vulnerability.severity, - type: (if .artifact.name == "stdlib" then "stdlib" else "package" end) + type: (if ((.vulnerability.fix.versions // []) | length) == 0 then "no-fix" + elif .artifact.name == "stdlib" then "stdlib" + else "package" end) } ] | - group_by(.pkg) | + group_by(.pkg + ":" + .type) | map({ pkg: .[0].pkg, type: .[0].type, - fixedIn: (map(.fixedIn) | sort_by(split(".") | map(tonumber)) | last), + fixedIn: (if .[0].type == "no-fix" then "" + else (map(.fixedIn) | map(select(. != "")) | sort_by(split("-")[0] | split(".") | map(tonumber)) | last // "") end), + allFixVersions: (if .[0].type == "stdlib" then ([.[].allFixVersions] | unique | join(",")) else "" end), cves: (map(.cve) | unique), severity: .[0].severity }) | .[] | - "\(.type)\t\(.pkg)\t\(.fixedIn)\t\(.cves | join(","))\t\(.severity)" + "\(.type)\t\(.pkg)\t\(.fixedIn)\t\(.cves | join(","))\t\(.severity)\t\(.allFixVersions)" ' 2>/dev/null || echo "") -if [[ -z "$CVE_LINES" ]]; then - echo "WARNING: Could not parse CVE data from JSON. All matches may lack fix versions." - echo "Proceeding to agent review." - CVE_LINES="" -fi - # Track results FIX_SUMMARY="" FIXED_COUNT=0 +IGNORED_COUNT=0 REVIEW_COUNT=0 -while IFS=$'\t' read -r TYPE PKG FIX_VER CVE_CSV _SEVERITY; do +if [[ -z "$CVE_LINES" ]]; then + echo "WARNING: Could not parse CVE data from JSON. All matches may lack fix versions." + REVIEW_COUNT=1 + FIX_SUMMARY="NEEDS_REVIEW: CVE data could not be parsed from scan JSON"$'\n' + CVE_LINES="" +fi + +while IFS=$'\t' read -r TYPE PKG FIX_VER CVE_CSV _SEVERITY ALL_FIX_VERS; do [[ -z "$PKG" ]] && continue # Split CVE_CSV into array IFS=',' read -ra CVES <<< "$CVE_CSV" FIX_LOG=$(mktemp) - if [[ "$TYPE" == "stdlib" ]]; then + if [[ "$TYPE" == "no-fix" ]]; then + "$SCRIPT_DIR/ignore.sh" "$STATE_FILE" "$PKG" "${_SEVERITY:-High}" \ + "No fix available" --no-fix "${CVES[@]}" 2>&1 | tee "$FIX_LOG" || true + FIX_SUMMARY+="IGNORED: $PKG (no fix available) for ${CVES[*]}"$'\n' + IGNORED_COUNT=$((IGNORED_COUNT + 1)) + elif [[ "$TYPE" == "stdlib" ]]; then STDLIB_EXIT=0 - "$SCRIPT_DIR/fix-stdlib.sh" "$STATE_FILE" "$FIX_VER" "${CVES[@]}" 2>&1 | tee "$FIX_LOG" || STDLIB_EXIT=$? + "$SCRIPT_DIR/fix-stdlib.sh" "$STATE_FILE" "$FIX_VER" "$ALL_FIX_VERS" "${CVES[@]}" 2>&1 | tee "$FIX_LOG" || STDLIB_EXIT=$? if [[ "$STDLIB_EXIT" -eq 0 ]]; then FIX_SUMMARY+="FIXED: stdlib go $FIX_VER for ${CVES[*]}"$'\n' FIXED_COUNT=$((FIXED_COUNT + 1)) else - FIX_SUMMARY+="NEEDS_REVIEW: stdlib — fix-stdlib.sh exited $STDLIB_EXIT"$'\n' + REASON=$(grep "^NEEDS_REVIEW:" "$FIX_LOG" || echo "NEEDS_REVIEW: stdlib — fix-stdlib.sh exited $STDLIB_EXIT") + FIX_SUMMARY+="$REASON"$'\n' REVIEW_COUNT=$((REVIEW_COUNT + 1)) fi else @@ -148,18 +168,46 @@ done <<< "$CVE_LINES" # Verify echo "" -echo "Running unit tests..." -if ! make unit; then - FIX_SUMMARY+="NEEDS_REVIEW: unit tests failed after applying fixes"$'\n' - REVIEW_COUNT=$((REVIEW_COUNT + 1)) +echo "Running tests..." +if [[ -n "${TEST_CMD:-}" ]]; then + ENV_CMD="" + for _v in ${ENV_UNSET:-}; do ENV_CMD+="env -u $_v "; done + if ! eval "${ENV_CMD}${TEST_CMD}"; then + FIX_SUMMARY+="NEEDS_REVIEW: tests failed after applying fixes"$'\n' + REVIEW_COUNT=$((REVIEW_COUNT + 1)) + fi fi echo "Final scan..." FINAL_SCAN=$("$SCRIPT_DIR/scan.sh" "$STATE_FILE" --no-update --skip-build 2>&1) || true printf '%s\n' "$FINAL_SCAN" -# Agent review (pass scan output to avoid re-scanning) -banner "Agent Review" -"$SCRIPT_DIR/review.sh" "$STATE_FILE" "$FIX_SUMMARY" "$FINAL_SCAN" || true +# Agent review — only when deterministic fixes left items for review +if [[ "$REVIEW_COUNT" -gt 0 ]]; then + banner "Agent Review ($REVIEW_COUNT items)" + "$SCRIPT_DIR/review.sh" "$STATE_FILE" "$FIX_SUMMARY" "$FINAL_SCAN" || true +else + echo "All CVEs handled deterministically. Skipping agent review." +fi + +# --- Subagent Verification --- +banner "Subagent Verification" +SUBAGENT_DIR="" +SUBAGENT_DIR="$(cd "$SCRIPT_DIR/../subagents" 2>/dev/null && pwd || true)" +[[ -d "$SUBAGENT_DIR" ]] || SUBAGENT_DIR="" + +SUBAGENT_REPORT_DIR="/tmp/cve-fix-subagents-$(basename "$REPO")-${BRANCH//\//-}-$$" +mkdir -p "$SUBAGENT_REPORT_DIR" +printf '%s\n' "$SCAN_JSON" > "$SUBAGENT_REPORT_DIR/initial-scan.json" + +if [[ -n "$SUBAGENT_DIR" ]] && command -v claude &>/dev/null; then + if ! verify_fix_loop "$STATE_FILE" "$SUBAGENT_DIR" "$SUBAGENT_REPORT_DIR" 3; then + REVIEW_COUNT=$((REVIEW_COUNT + 1)) + FIX_SUMMARY+="NEEDS_REVIEW: subagent verification failed — see reports above"$'\n' + fi +else + echo "Skipping: ${SUBAGENT_DIR:+claude CLI not available}${SUBAGENT_DIR:-no subagents directory found}" + rm -rf "$SUBAGENT_REPORT_DIR" +fi # --- Summary --- @@ -168,7 +216,7 @@ banner "Summary: $(basename "$REPO")/$BRANCH" # shellcheck source=/dev/null source "$STATE_FILE" -echo "Fixed: $FIXED_COUNT, Needs review: $REVIEW_COUNT" +echo "Fixed: $FIXED_COUNT, Ignored: $IGNORED_COUNT, Needs review: $REVIEW_COUNT" printf '%s' "$FIX_SUMMARY" COMMIT_COUNT=$(git --no-pager log "origin/$BRANCH"..HEAD --oneline 2>/dev/null | wc -l) @@ -179,12 +227,17 @@ if [[ "$COMMIT_COUNT" -gt 0 ]]; then # Generate PR command echo "" CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - BASE_BRANCH=$(echo "$CURRENT_BRANCH" | sed 's/fix-\([0-9.]*\)-.*/release-\1/; s/fix-devel-.*/devel/') + BASE_BRANCH="$BRANCH" PLURAL=$([[ "$COMMIT_COUNT" -eq 1 ]] && echo "" || echo "s") - FORK_REMOTE=$(git remote -v | awk '!/submariner-io/ && /\(push\)/ { print $1; exit }') + _UPSTREAM="${UPSTREAM_ORG:-}" + [[ -z "$_UPSTREAM" ]] && _UPSTREAM=$(git remote get-url origin 2>/dev/null | sed -E 's#.*github.com[:/]+([^/]+)/.*#\1#') + FORK_REMOTE=$(git remote -v | awk -v org="$_UPSTREAM" '!index($0,org) && /\(push\)/ { print $1; exit }') FORK_USER=$(git remote get-url "${FORK_REMOTE}" 2>/dev/null | sed -E 's#.*github.com[:/]+([^/]+)/.*#\1#') echo "PR command:" + if [[ -n "${WORKTREE_DIR:-}" ]]; then + echo "cd $WORKTREE_DIR && \\" + fi echo "git push $FORK_REMOTE $CURRENT_BRANCH && \\" echo "gh pr create \\" echo " --title \"Fix CVE${PLURAL} in ${BASE_BRANCH}\" \\" diff --git a/skills/cve-fix/scripts/fix-package.sh b/skills/cve-fix/scripts/fix-package.sh new file mode 100755 index 000000000..81e53a9df --- /dev/null +++ b/skills/cve-fix/scripts/fix-package.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# Fix a single CVE package: update, verify, commit. +# Usage: fix-package.sh STATE_FILE PACKAGE VERSION CVE_ID [CVE_ID...] +# Exit 0: fixed. Exit 2: needs review (breaking change). Exit 3: CVE persists. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +STATE_FILE="${1:?Usage: fix-package.sh STATE_FILE PACKAGE VERSION CVE_ID [CVE_ID...]}" +PACKAGE="${2:?Missing PACKAGE}" +VERSION="${3:?Missing VERSION}" +shift 3 +CVE_IDS=("$@") +[[ ${#CVE_IDS[@]} -gt 0 ]] || { echo "ERROR: At least one CVE_ID required" >&2; exit 1; } + +load_state "$STATE_FILE" + +export GOTOOLCHAIN=auto + +trap 'git reset --quiet HEAD -- . 2>/dev/null || true; git checkout -- . 2>/dev/null || true' ERR + +echo "--- Fixing: $PACKAGE -> v$VERSION for ${CVE_IDS[*]} ---" + +# Check for replace directives across all go.mod files (warn, don't block) +while IFS= read -r GOMOD; do + if grep -q "replace.*${PACKAGE}" "$GOMOD" 2>/dev/null; then + echo "WARNING: Replace directive found in $GOMOD for $PACKAGE" + grep "replace.*${PACKAGE}" "$GOMOD" 2>/dev/null || true + fi +done < <(find_gomods) + +# Snapshot go directives before update (for breaking-change detection) +GO_BEFORE="" +while IFS= read -r GOMOD; do + GO_VER=$(grep '^go ' "$GOMOD" 2>/dev/null | awk '{print $2}') + [[ -n "$GO_VER" ]] && GO_BEFORE+="$GOMOD:$GO_VER " +done < <(find_gomods) +K8S_BEFORE="" +if [[ "${K8S_VERSION_GUARD:-false}" == "true" ]]; then + K8S_BEFORE=$(grep -h 'k8s.io/client-go' go.mod 2>/dev/null | grep -oP 'v0\.\K[0-9]+' | sort -un | tr '\n' ' ') +fi + +# Update in all go.mod files that contain this package +while IFS= read -r GOMOD; do + MODDIR=$(dirname "$GOMOD") + if grep -qF "$PACKAGE" "$GOMOD" 2>/dev/null; then + INSTALLED=$(grep -F "$PACKAGE" "$GOMOD" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+' | head -1) + if [[ -n "$INSTALLED" ]] && version_gte "$INSTALLED" "$VERSION"; then + echo "NOTE: $GOMOD already has $PACKAGE v$INSTALLED (>= v$VERSION)" + else + echo "Updating $PACKAGE in $GOMOD..." + go -C "$MODDIR" get "${PACKAGE}@v${VERSION}" && go -C "$MODDIR" mod tidy + fi + fi +done < <(find_gomods) + +clean_gomod + +if [[ "${HAS_VENDOR:-false}" == "true" ]]; then + echo "Syncing vendor directory..." + go mod vendor +fi + +# Check for breaking changes (Go or K8s minor version upgrade) +# Go directive bumps are safe if the build image already satisfies them. +# K8s minor version changes are genuinely breaking (different API versions). +COMPILER_GO=$(echo "$BUILD_GO_VERSION" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || echo "") +BREAKING="" +while IFS= read -r GOMOD; do + GO_AFTER=$(grep '^go ' "$GOMOD" 2>/dev/null | awk '{print $2}') + [[ -z "$GO_AFTER" ]] && continue + for PAIR in $GO_BEFORE; do + if [[ "${PAIR%%:*}" == "$GOMOD" ]]; then + GO_WAS="${PAIR#*:}" + if [[ "$(echo "$GO_WAS" | cut -d. -f1-2)" != "$(echo "$GO_AFTER" | cut -d. -f1-2)" ]]; then + if [[ -n "$COMPILER_GO" ]] && \ + [[ "$(printf '%s\n' "$COMPILER_GO" "$GO_AFTER" | sort -V | tail -1)" == "$COMPILER_GO" ]]; then + echo "NOTE: $GOMOD Go $GO_WAS -> $GO_AFTER (safe: build image has Go $COMPILER_GO)" + else + BREAKING="${BREAKING:+$BREAKING; }$GOMOD: Go $GO_WAS -> $GO_AFTER" + fi + fi + break + fi + done +done < <(find_gomods) + +if [[ "${K8S_VERSION_GUARD:-false}" == "true" ]]; then + K8S_AFTER=$(grep -h 'k8s.io/client-go' go.mod 2>/dev/null | grep -oP 'v0\.\K[0-9]+' | sort -un | tr '\n' ' ') + if [[ -n "$K8S_BEFORE" ]] && [[ -n "$K8S_AFTER" ]] && [[ "$K8S_BEFORE" != "$K8S_AFTER" ]]; then + BREAKING="${BREAKING:+$BREAKING; }K8s minor versions changed" + fi +fi + +if [[ -n "$BREAKING" ]]; then + echo "NEEDS_REVIEW: $PACKAGE — would upgrade $BREAKING" + git checkout -- . || echo "ERROR: Could not rollback changes" >&2 + exit 2 +fi + +# Verify fix: check that go.mod has version >= the fix version +STILL_VULNERABLE=false +while IFS= read -r GOMOD; do + if grep -qF "$PACKAGE" "$GOMOD" 2>/dev/null; then + INSTALLED=$(grep -F "$PACKAGE" "$GOMOD" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+' | head -1) + if [[ -n "$INSTALLED" ]] && version_gte "$INSTALLED" "$VERSION"; then + : # Installed version >= fix version, good + else + echo "WARNING: $GOMOD has $PACKAGE at v${INSTALLED:-unknown} (need >= v$VERSION)" + STILL_VULNERABLE=true + fi + fi +done < <(find_gomods) + +if [[ "$STILL_VULNERABLE" == "true" ]]; then + echo "NEEDS_REVIEW: $PACKAGE — CVE persists after update to v$VERSION" + git checkout -- . || echo "ERROR: Could not rollback changes" >&2 + exit 3 +fi + +# Stage all changed go.mod/go.sum and vendor files +git diff --name-only | grep -E 'go\.(mod|sum)$' | xargs -r git add || true +if [[ "${HAS_VENDOR:-false}" == "true" ]]; then + git add vendor/ 2>/dev/null || true +fi + +# Handle generated files +if [[ -n "$GENERATED_FILE" ]] && [[ -n "$DIFF_IGNORE_ARGS" ]]; then + # shellcheck disable=SC2086 # DIFF_IGNORE_ARGS needs word splitting (-I'pattern') + if git diff $DIFF_IGNORE_ARGS "$GENERATED_FILE" 2>/dev/null | grep -q .; then + git add "$GENERATED_FILE" + else + git checkout "$GENERATED_FILE" 2>/dev/null || true + fi +fi + +# If nothing to commit, the CVE was already fixed by a prior package upgrade +if ! git diff --staged --quiet 2>/dev/null; then + # Determine if tools-only change (all staged go files under tools/) + TOOLS_ONLY="" + if ! git diff --staged --name-only | grep -qE '^go\.(mod|sum)$' && \ + git diff --staged --name-only | grep -qE '^tools/'; then + TOOLS_ONLY=" in /tools" + fi + + # Format commit message + ABBREV=$(abbreviate_package "$PACKAGE") + if [[ ${#CVE_IDS[@]} -eq 1 ]]; then + SUBJECT="Bump ${ABBREV} for ${CVE_IDS[0]}${TOOLS_ONLY}" + BODY="Full package: $PACKAGE" + else + SUBJECT="Bump ${ABBREV} for CVEs${TOOLS_ONLY}" + FIXES_LINE="Fixes: $(printf '%s, ' "${CVE_IDS[@]}")" + FIXES_LINE="${FIXES_LINE%, }" + if [[ ${#FIXES_LINE} -gt 80 ]]; then + FIXES=$(printf ' %s\n' "${CVE_IDS[@]}") + BODY="Full package: $PACKAGE +Fixes: +$FIXES" + else + BODY="Full package: $PACKAGE +$FIXES_LINE" + fi + fi + + git commit -s -m "$(printf '%s\n\n%s' "$SUBJECT" "$BODY")" +else + echo "NOTE: $PACKAGE already at v$VERSION (fixed by prior upgrade)" +fi + +echo "FIXED: $PACKAGE v$VERSION for ${CVE_IDS[*]}" diff --git a/skills/cve-fix/scripts/fix-stdlib.sh b/skills/cve-fix/scripts/fix-stdlib.sh new file mode 100755 index 000000000..e35ba2e62 --- /dev/null +++ b/skills/cve-fix/scripts/fix-stdlib.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Fix stdlib CVEs by updating the go directive. +# Usage: fix-stdlib.sh STATE_FILE GO_VERSION ALL_FIX_VERSIONS CVE_ID [CVE_ID...] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +STATE_FILE="${1:?Usage: fix-stdlib.sh STATE_FILE GO_VERSION ALL_FIX_VERSIONS CVE_ID [CVE_ID...]}" +GO_VERSION="${2:?Missing GO_VERSION}" +ALL_FIX_VERSIONS="${3:-}" +shift 3 +CVE_IDS=("$@") +[[ ${#CVE_IDS[@]} -gt 0 ]] || { echo "ERROR: At least one CVE_ID required" >&2; exit 1; } + +load_state "$STATE_FILE" + +trap 'git reset --quiet HEAD -- . 2>/dev/null || true; git checkout -- . 2>/dev/null || true' ERR + +echo "--- Fixing stdlib: go $GO_VERSION for ${CVE_IDS[*]} ---" + +OLD_GO=$(grep '^go ' go.mod | awk '{print $2}') + +# Use build image compiler version if known, else fall back to go.mod +COMPILER_GO=$(echo "$BUILD_GO_VERSION" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || echo "") +REF_GO="${COMPILER_GO:-$OLD_GO}" +REF_MINOR=$(echo "$REF_GO" | cut -d. -f1-2) + +# Pick fix version matching the compiler's minor stream +if [[ -n "$COMPILER_GO" ]] && [[ -n "$ALL_FIX_VERSIONS" ]]; then + IFS=',' read -ra FIX_VERS <<< "$ALL_FIX_VERSIONS" + for v in "${FIX_VERS[@]}"; do + if [[ "$(echo "$v" | cut -d. -f1-2)" == "$REF_MINOR" ]]; then + GO_VERSION="$v" + break + fi + done +fi + +NEW_MINOR=$(echo "$GO_VERSION" | cut -d. -f1-2) + +# Check if fix is in the compiler's minor stream +if [[ "$REF_MINOR" != "$NEW_MINOR" ]]; then + if [[ -n "$COMPILER_GO" ]]; then + echo "NEEDS_REVIEW: stdlib — no fix in compiler stream $REF_MINOR (build image Go $COMPILER_GO, fix needs $GO_VERSION)" + else + echo "NEEDS_REVIEW: stdlib — would upgrade Go $OLD_GO -> $GO_VERSION (minor version change)" + fi + exit 2 +fi + +# Patch bump in the right stream — but can the build image support it? +if [[ -n "$COMPILER_GO" ]]; then + if [[ "$(printf '%s\n' "$COMPILER_GO" "$GO_VERSION" | sort -V | head -1)" == "$COMPILER_GO" ]] && \ + [[ "$COMPILER_GO" != "$GO_VERSION" ]]; then + echo "NEEDS_REVIEW: stdlib — build image has Go $COMPILER_GO, fix needs $GO_VERSION (patch bump, update build image)" + exit 2 + fi +fi + +# Compiler already has the fix version — bump go.mod to match +if [[ -z "${GOTOOLCHAIN:-}" ]]; then + export GOTOOLCHAIN=auto +fi + +# Check host Go version is sufficient +HOST_GO=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+\.[0-9]+') +if [[ -n "$HOST_GO" ]] && [[ "${GOTOOLCHAIN:-}" != *auto* ]] && \ + [[ "$(printf '%s\n' "$HOST_GO" "$GO_VERSION" | sort -V | head -1)" == "$HOST_GO" ]] && \ + [[ "$HOST_GO" != "$GO_VERSION" ]]; then + echo "NEEDS_REVIEW: stdlib — host Go $HOST_GO is older than required $GO_VERSION" + echo "Update Go: go install golang.org/dl/go${GO_VERSION}@latest && go${GO_VERSION} download" + echo "Or set GOTOOLCHAIN=auto to allow automatic toolchain downloads." + exit 2 +fi + +# Update go directive in all go.mod files +echo "Updating go directive and tidying modules..." +while IFS= read -r GOMOD; do + MODDIR=$(dirname "$GOMOD") + go -C "$MODDIR" mod edit -go="${GO_VERSION}" + go -C "$MODDIR" mod tidy +done < <(find_gomods) + +clean_gomod + +if [[ "${HAS_VENDOR:-false}" == "true" ]]; then + echo "Syncing vendor directory..." + go mod vendor +fi + +# Check if build image has sufficient Go version +BUILD_GO=$(echo "$BUILD_GO_VERSION" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || echo "") +if [[ -n "$BUILD_GO" ]]; then + OLDEST=$(printf '%s\n' "$BUILD_GO" "$GO_VERSION" | sort -V | head -1) + if [[ "$OLDEST" == "$BUILD_GO" ]] && [[ "$BUILD_GO" != "$GO_VERSION" ]]; then + echo "NOTE: Build image has Go $BUILD_GO but go.mod now requires $GO_VERSION" + echo "CI may fail until the build image is updated with a newer Go version." + fi +fi + +# Stage all changed go.mod/go.sum files +git diff --name-only | grep -E 'go\.(mod|sum)$' | xargs -r git add || true + +FIXES=$(printf '%s, ' "${CVE_IDS[@]}") +FIXES="${FIXES%, }" +git commit -s -m "$(cat <&2; exit 1; } +[[ "$SEVERITY" =~ ^(Critical|High|Medium|Low|Negligible|Unknown)$ ]] || \ + { echo "ERROR: Invalid severity: $SEVERITY (use Critical/High/Medium/Low/Negligible/Unknown)" >&2; exit 1; } +for _cve in "${CVE_IDS[@]}"; do + [[ "$_cve" =~ ^(CVE-|GHSA-|GO-) ]] || \ + { echo "ERROR: Invalid CVE ID format: $_cve (must start with CVE-, GHSA-, or GO-)" >&2; exit 1; } +done + load_state "$STATE_FILE" echo "--- Ignoring: ${CVE_IDS[*]} ($PACKAGE) [$SEVERITY] ---" for CVE_ID in "${CVE_IDS[@]}"; do - insert_grype_ignore .grype.yaml "$CVE_ID" "$PACKAGE" "$REASON" + insert_grype_ignore .grype.yaml "$CVE_ID" "$PACKAGE" "$REASON" "$FIX_STATE" done git add .grype.yaml diff --git a/skills/cve-fix/scripts/lib.sh b/skills/cve-fix/scripts/lib.sh new file mode 100644 index 000000000..c61f2d012 --- /dev/null +++ b/skills/cve-fix/scripts/lib.sh @@ -0,0 +1,427 @@ +# shellcheck shell=bash +# Shared functions for CVE fix scripts + +# Compute state file path from repo path and branch name +state_file_path() { + local repo_basename branch_sanitized + repo_basename=$(basename "$1") + branch_sanitized=$(echo "$2" | tr './' '-') + echo "/tmp/cve-fix-${repo_basename}-${branch_sanitized}-$$.env" +} + +# Load state from file, cd to repo +load_state() { + local state_file="${1:?Usage: load_state STATE_FILE}" + if [[ ! -f "$state_file" ]]; then + echo "ERROR: State file not found: $state_file" >&2 + echo "Run detect.sh first." >&2 + return 1 + fi + # shellcheck source=/dev/null + source "$state_file" + cd "$REPO" || return 1 +} + +# Detect container runtime: docker, podman, or empty string +detect_container_cmd() { + if command -v docker &>/dev/null && docker info &>/dev/null; then + echo "docker" + elif command -v podman &>/dev/null && podman info &>/dev/null; then + echo "podman" + else + echo "" + fi +} + +# Run grype scan via local install or container fallback +# Usage: run_grype [--fresh] [--no-update] [--json] +# --fresh: ensure DB is up-to-date (grype auto-updates if stale) +# --no-update: skip DB update check (use after a recent --fresh scan) +# --json: output JSON instead of table +run_grype() { + local fresh=false no_update=false output_format="table" + for arg in "$@"; do + case "$arg" in + --fresh) fresh=true ;; + --no-update) no_update=true ;; + --json) output_format="json" ;; + esac + done + + # Build config flag (only if .grype.yaml exists) + local -a config_args=() + [[ -f .grype.yaml ]] && config_args=(--config .grype.yaml) + + # Prefer local grype (fast, uses host-cached DB); fall back to container on failure + if [[ "$HAS_LOCAL_GRYPE" == "true" ]]; then + if [[ "$no_update" == "true" ]]; then + GRYPE_DB_AUTO_UPDATE=false GRYPE_DB_VALIDATE_AGE=false \ + grype . "${config_args[@]}" -o "$output_format" && return 0 + else + grype . "${config_args[@]}" -o "$output_format" && return 0 + fi + echo "WARNING: Local grype failed. Trying container fallback..." >&2 + fi + + # Fall back to container + if [[ -n "$CONTAINER_CMD" ]]; then + if [[ "$fresh" == "true" ]]; then + $CONTAINER_CMD volume create grype-db >/dev/null 2>&1 || true + echo "Updating vulnerability database (container)..." >&2 + if ! $CONTAINER_CMD run --pull=always --rm \ + -v grype-db:/.cache/grype anchore/grype:latest db update >&2; then + echo "WARNING: DB update failed. Scan will use cached data." >&2 + fi + fi + local -a env_args=() + if [[ "$no_update" == "true" ]]; then + env_args=(-e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_DB_VALIDATE_AGE=false) + fi + local -a container_config=() + [[ -f .grype.yaml ]] && container_config=(--config /src/.grype.yaml) + if $CONTAINER_CMD run --rm "${env_args[@]}" \ + -v grype-db:/.cache/grype \ + -v "$(pwd)":/src \ + anchore/grype:latest /src "${container_config[@]}" -o "$output_format"; then + return 0 + fi + echo "WARNING: Container scan failed." >&2 + fi + + echo "ERROR: No scanner available." >&2 + echo " Install grype locally or docker/podman." >&2 + return 1 +} + +# Check if version A >= version B (semver, uses sort -V) +version_gte() { + [[ "$(printf '%s\n' "$1" "$2" | sort -V | head -1)" == "$2" ]] +} + +# Abbreviate Go package path for commit messages +# github.com/docker/docker -> docker/docker +# golang.org/x/net -> x/net +# helm.sh/helm/v3 -> helm/v3 +# go.opentelemetry.io/otel -> otel +# go.opentelemetry.io/otel/exporters/otlp/*/X -> otel/X +# google.golang.org/grpc -> grpc +# sigs.k8s.io/controller-runtime -> controller-runtime +# k8s.io/* stays as-is +abbreviate_package() { + local pkg="$1" + case "$pkg" in + github.com/*) echo "${pkg#github.com/}" ;; + golang.org/x/*) echo "x/${pkg#golang.org/x/}" ;; + helm.sh/*) echo "${pkg#helm.sh/}" ;; + go.opentelemetry.io/otel/exporters/otlp/*) + echo "otel/$(basename "$pkg")" ;; + go.opentelemetry.io/*) echo "${pkg#go.opentelemetry.io/}" ;; + google.golang.org/*) echo "${pkg#google.golang.org/}" ;; + sigs.k8s.io/*) echo "${pkg#sigs.k8s.io/}" ;; + *) echo "$pkg" ;; + esac +} + +# Find all go.mod files in the repo (excluding vendor and gitignored dirs) +find_gomods() { + git ls-files --cached --others --exclude-standard '*/go.mod' 'go.mod' 2>/dev/null || \ + find . -name go.mod -not -path '*/vendor/*' +} + +# Remove artifacts added by go mod tidy from all go.mod files: +# - toolchain directive (build image controls the toolchain; +# uses sed because go mod edit has no -droptoolchain flag) +# - consecutive blank lines (tidy sometimes adds extra whitespace) +clean_gomod() { + local gomod + while IFS= read -r gomod; do + sed -i '/^toolchain/d' "$gomod" + sed -i '/^$/{N;/^\n$/s/\n//;}' "$gomod" + done < <(find_gomods) +} + +# Insert an ignore entry into a .grype.yaml file. +# Inserts before exclude: section if present, otherwise appends. +# When FIX_STATE is provided (e.g. "not-fixed"), the entry auto-expires +# once grype's DB shows a fix is available. +# Usage: insert_grype_ignore FILE CVE_ID PACKAGE REASON [FIX_STATE] +insert_grype_ignore() { + local file="$1" cve_id="$2" package="$3" reason="$4" fix_state="${5:-}" + + if grep -q "vulnerability: $cve_id" "$file" 2>/dev/null && \ + grep -A4 "vulnerability: $cve_id" "$file" | grep -q "name: $package"; then + echo "NOTE: $cve_id for $package already in $file — skipping" + return 0 + fi + + local entry + entry=" # $reason + - vulnerability: $cve_id" + [[ -n "$fix_state" ]] && entry+=$'\n'" fix-state: $fix_state" + entry+=" + package: + name: $package" + + if grep -qn '^exclude:' "$file" 2>/dev/null; then + local exclude_line + exclude_line=$(grep -n '^exclude:' "$file" | head -1 | cut -d: -f1) + { + head -n "$((exclude_line - 1))" "$file" + printf '%s\n' "$entry" + tail -n +"$exclude_line" "$file" + } > "${file}.tmp" + mv "${file}.tmp" "$file" + else + printf '\n%s\n' "$entry" >> "$file" + fi +} + +# Print a section header +banner() { + echo "" + echo "--- $1 ---" +} + +# Read optional .cve-fix.yaml config from repo root. +# Sets BUILD_IMAGE, BUILD_CMD, CLEAN_CMD, TEST_CMD, ENV_UNSET, +# K8S_VERSION_GUARD, UPSTREAM_ORG. Unset fields keep their defaults. +read_config() { + local repo_dir="$1" + local config="$repo_dir/.cve-fix.yaml" + [[ -f "$config" ]] || return 0 + + local val + val=$(grep '^build-image:' "$config" | awk '{print $2}') && [[ -n "$val" ]] && BUILD_IMAGE="$val" + val=$(grep '^build-command:' "$config" | sed 's/^build-command:[[:space:]]*//') && [[ -n "$val" ]] && BUILD_CMD="$val" + val=$(grep '^clean-command:' "$config" | sed 's/^clean-command:[[:space:]]*//') && [[ -n "$val" ]] && CLEAN_CMD="$val" + val=$(grep '^test-command:' "$config" | sed 's/^test-command:[[:space:]]*//') && [[ -n "$val" ]] && TEST_CMD="$val" + val=$(grep '^unset-env:' "$config" | sed 's/^unset-env:[[:space:]]*//') && [[ -n "$val" ]] && ENV_UNSET="$val" + val=$(grep '^k8s-version-guard:' "$config" | awk '{print $2}') && [[ "$val" == "true" ]] && K8S_VERSION_GUARD=true + val=$(grep '^upstream-org:' "$config" | awk '{print $2}') && [[ -n "$val" ]] && UPSTREAM_ORG="$val" +} + +# --- Subagent verification infrastructure --- + +# Run verification subagents: companion scripts first, then LLM subagents in parallel. +# Usage: run_subagents STATE_FILE SUBAGENT_DIR REPORT_DIR +# Returns: 0 if all pass, 1 if any fail +run_subagents() { + local state_file="$1" subagent_dir="$2" report_dir="$3" + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=/dev/null + source "$state_file" + mkdir -p "$report_dir" + + # Clear stale reports so a crashed subagent can't be masked by a prior PASS + rm -f "$report_dir"/*.report + + # Phase 1: Run companion scripts (deterministic, sequential) + for sh_file in "$subagent_dir"/*.sh; do + [[ -f "$sh_file" ]] || continue + local sa_name + sa_name=$(basename "$sh_file" .sh) + echo " Companion: $sa_name..." + local evidence + if [[ "$sa_name" == "scan-regression" ]]; then + evidence=$(bash "$sh_file" "$state_file" "$report_dir" 2>&1) || true + else + evidence=$(bash "$sh_file" "$state_file" 2>&1) || true + fi + printf '%s\n' "$evidence" > "$report_dir/${sa_name}.evidence" + done + + # Phase 2: Launch subagents in parallel + if ! command -v claude &>/dev/null; then + echo " WARNING: claude CLI not available, skipping subagent verification." + return 0 + fi + + local pids=() + for md_file in "$subagent_dir"/*.md; do + [[ -f "$md_file" ]] || continue + local sa_name + sa_name=$(basename "$md_file" .md) + local evidence_file="$report_dir/${sa_name}.evidence" + local companion_evidence="" + [[ -f "$evidence_file" ]] && companion_evidence=$(cat "$evidence_file") + local prompt_template + prompt_template=$(cat "$md_file") + + local prompt + prompt=$(REPO="$REPO" BRANCH="$BRANCH" CVE_SCRIPTS="$script_dir" \ + REPORT_DIR="$report_dir" SUBAGENT_NAME="$sa_name" \ + COMPANION_EVIDENCE="$companion_evidence" \ + envsubst '$REPO $BRANCH $CVE_SCRIPTS $REPORT_DIR $SUBAGENT_NAME $COMPANION_EVIDENCE' <<< "$prompt_template") + + echo " Subagent: $sa_name..." + claude -p "$prompt" \ + --print \ + --model sonnet \ + --allowedTools "Bash" \ + > "$report_dir/${sa_name}.agent-output" 2>&1 & + pids+=("$!:$sa_name") + done + + # Wait for all subagents and read reports + local failed=() + for pid_name in "${pids[@]}"; do + local pid="${pid_name%%:*}" + local sa_name="${pid_name#*:}" + wait "$pid" 2>/dev/null || true + + local report_file="$report_dir/${sa_name}.report" + if [[ ! -f "$report_file" ]]; then + echo " WARNING: $sa_name produced no report" + failed+=("$sa_name") + continue + fi + local verdict issues + verdict=$(grep '^VERDICT:' "$report_file" | head -1 | awk '{print $2}') + issues=$(grep '^ISSUES:' "$report_file" | head -1 | awk '{print $2}') + echo " $sa_name: $verdict (issues: ${issues:-?})" + [[ "$verdict" == "FAIL" ]] && failed+=("$sa_name") + done + + if [[ ${#failed[@]} -gt 0 ]]; then + echo " FAILED: ${failed[*]}" + return 1 + fi + return 0 +} + +# Run subagents with verify-fix loop. +# Usage: verify_fix_loop STATE_FILE SUBAGENT_DIR REPORT_DIR [MAX_ITERATIONS] +verify_fix_loop() { + local state_file="$1" subagent_dir="$2" report_dir="$3" max_iter="${4:-3}" + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + for ((i=1; i<=max_iter; i++)); do + echo "" + echo "Verification iteration $i/$max_iter..." + + if run_subagents "$state_file" "$subagent_dir" "$report_dir"; then + echo "All subagents passed." + return 0 + fi + + if [[ $i -eq $max_iter ]]; then + echo "Subagents still failing after $max_iter iterations." + return 1 + fi + + # Attempt deterministic fixes for each failed subagent + for report_file in "$report_dir"/*.report; do + [[ -f "$report_file" ]] || continue + local verdict + verdict=$(grep '^VERDICT:' "$report_file" | head -1 | awk '{print $2}') + [[ "$verdict" != "FAIL" ]] && continue + + local sa_name + sa_name=$(basename "$report_file" .report) + echo " Fixing: $sa_name" + + local fix_fn="subagent_fix_${sa_name//-/_}" + if declare -f "$fix_fn" &>/dev/null; then + "$fix_fn" "$state_file" "$report_file" "$script_dir" || true + else + echo " No deterministic fix for $sa_name" + fi + + rm -f "$report_file" + done + done +} + +# Fix function: yaml-integrity — reset bad .grype.yaml entries and re-apply via ignore.sh +subagent_fix_yaml_integrity() { + local state_file="$1" report_file="$2" script_dir="$3" + # shellcheck source=/dev/null + source "$state_file" + + # Get the list of ignore commits on this branch (snapshot before reset) + local ignore_commits + ignore_commits=$(git log "origin/$BRANCH"..HEAD --grep="^Ignore" --format="%H" 2>/dev/null || true) + [[ -z "$ignore_commits" ]] && return 0 + + # Deduplicate: only process the FIRST ignore commit per package + local seen_packages="" + local unique_commits="" + while IFS= read -r commit; do + [[ -z "$commit" ]] && continue + local pkg + pkg=$(git diff-tree --no-commit-id -r -p "$commit" -- .grype.yaml | \ + grep '^+.*name:' | head -1 | sed 's/.*name:[[:space:]]*//' | xargs) + [[ -z "$pkg" ]] && continue + if echo "$seen_packages" | grep -qxF "$pkg"; then continue; fi + seen_packages+="$pkg"$'\n' + unique_commits+="$commit"$'\n' + done <<< "$ignore_commits" + + [[ -z "$unique_commits" ]] && return 0 + + # Reset .grype.yaml to the state before ignore commits + git checkout "origin/$BRANCH" -- .grype.yaml 2>/dev/null || true + + # Re-apply each unique ignore commit's changes via ignore.sh + while IFS= read -r commit; do + [[ -z "$commit" ]] && continue + local msg + msg=$(git log -1 --format="%B" "$commit") + # Extract package and CVEs from commit diff + local pkg cves + pkg=$(git diff-tree --no-commit-id -r -p "$commit" -- .grype.yaml | \ + grep '^+.*name:' | head -1 | sed 's/.*name:[[:space:]]*//' | xargs) + cves=$(git diff-tree --no-commit-id -r -p "$commit" -- .grype.yaml | \ + grep '^+.*vulnerability:' | sed 's/.*vulnerability:[[:space:]]*//' | xargs) + [[ -z "$pkg" || -z "$cves" ]] && continue + + local fix_flag="" + git diff-tree --no-commit-id -r -p "$commit" -- .grype.yaml | grep -q 'fix-state: not-fixed' && fix_flag="--no-fix" + + local reason + reason=$(echo "$msg" | tail -n +3 | head -1) + [[ -z "$reason" ]] && reason="CVE ignore (re-applied by verification)" + + # shellcheck disable=SC2086 + bash "$script_dir/ignore.sh" "$state_file" "$pkg" "High" "$reason" $fix_flag $cves || true + done <<< "$unique_commits" +} + +# Fix function: fix-availability — bump packages where fixes are available +subagent_fix_fix_availability() { + local state_file="$1" report_file="$2" script_dir="$3" + + # Parse FIX-AVAILABLE lines from the report details + while IFS= read -r line; do + [[ "$line" =~ ^FIX-AVAILABLE:\ (.+)\ (.+)\ -\>\ (.+) ]] || continue + local pkg="${BASH_REMATCH[1]}" version="${BASH_REMATCH[3]}" + # Strip go.mod/tools suffix + version="${version%% *}" + version="${version#v}" + + # Extract real CVE IDs from CVEs:ID1,ID2 suffix + local cve_args=() + if [[ "$line" =~ CVEs:([^[:space:]]+) ]]; then + IFS=',' read -ra cve_args <<< "${BASH_REMATCH[1]}" + fi + [[ ${#cve_args[@]} -eq 0 ]] && cve_args=("UNKNOWN-CVE") + + echo " Attempting fix: $pkg@$version for ${cve_args[*]}" + bash "$script_dir/fix-package.sh" "$state_file" "$pkg" "$version" "${cve_args[@]}" || true + done < <(grep '^DETAILS:' -A 999 "$report_file" | tail -n +2) +} + +# Fix function: existing-entries — update stale entries +subagent_fix_existing_entries() { + local state_file="$1" report_file="$2" script_dir="$3" + echo " Existing-entries fixes require manual review. See report for details." +} + +# Fix function: scan-regression — fix regressions +subagent_fix_scan_regression() { + local state_file="$1" report_file="$2" script_dir="$3" + echo " Scan regressions require manual review. See report for details." +} diff --git a/scripts/cve/locate.sh b/skills/cve-fix/scripts/locate.sh similarity index 100% rename from scripts/cve/locate.sh rename to skills/cve-fix/scripts/locate.sh diff --git a/skills/cve-fix/scripts/review-prompt.md b/skills/cve-fix/scripts/review-prompt.md new file mode 100644 index 000000000..3be6723f0 --- /dev/null +++ b/skills/cve-fix/scripts/review-prompt.md @@ -0,0 +1,76 @@ +# CVE Fix Review: ${REPO} ${BRANCH} + +You are reviewing CVE fix results for ${REPO} on ${BRANCH}. +The deterministic phase has already attempted to fix all CVEs. +All evidence has been pre-fetched below. + +## Fix Results + +${FIX_SUMMARY} + +## Current Scan + +${CURRENT_SCAN} + +## Unfixed CVE Details + +${LOCATE_OUTPUT} + +## Build Environment + +Go version (build image or host): ${BUILD_GO_VERSION} + +## Task + +Review ALL CVE outcomes: + +1. **Verify fixes**: Do the committed fixes look correct? Any concerns? + +2. **Handle unfixed CVEs**: For each CVE that was not fixed: + - Try a different approach if possible (different version, drop replace directive) + - If **no fix is available**: use `--no-fix` so the entry auto-expires when a fix is published. + Run: `bash ${CVE_SCRIPTS}/ignore.sh ${STATE_FILE} PACKAGE SEVERITY "reason" --no-fix CVE_ID [CVE_ID...]` + - If fix requires a **Go or K8s minor version upgrade** on a stable branch: report as **UNRESOLVED**. + Do NOT ignore — this needs team approval. + **Exception**: Go directive bumps that are already committed were validated by the deterministic phase + against the build image (Go ${BUILD_GO_VERSION}). Do NOT roll back or flag these. + - If fix exists but would break API compatibility (not a version upgrade): omit `--no-fix` (permanent ignore). + Run: `bash ${CVE_SCRIPTS}/ignore.sh ${STATE_FILE} PACKAGE SEVERITY "reason" CVE_ID [CVE_ID...]` + - **One ignore.sh call per package.** Combine ALL CVEs regardless of severity. + Use the highest severity for the SEVERITY arg (it is only a log label). + +3. **Check for regressions**: Did any fix introduce new CVEs? + +## Available Actions + +ONLY use these scripts. Do NOT run go/git/sed commands directly. + +## CRITICAL CONSTRAINTS + +You MUST NOT: + +- Write or edit .grype.yaml directly (use ignore.sh) +- Run go/git/sed/awk commands to modify files directly +- Create or modify any file other than through the scripts below + +Hand-written YAML entries will be caught and reverted by verification +subagents that run after your review. + +- `bash ${CVE_SCRIPTS}/fix-package.sh ${STATE_FILE} PACKAGE VERSION CVE_IDS...` +- `bash ${CVE_SCRIPTS}/fix-stdlib.sh ${STATE_FILE} GO_VERSION CVE_IDS...` +- `bash ${CVE_SCRIPTS}/ignore.sh ${STATE_FILE} PACKAGE SEVERITY "reason" [--no-fix] CVE_ID [CVE_ID...]` +- `bash ${CVE_SCRIPTS}/scan.sh ${STATE_FILE}` + +If a script exits with NEEDS_REVIEW, do NOT attempt the same fix manually. +If the reason indicates a policy decision (version upgrade), report as UNRESOLVED. +Otherwise use ignore.sh. + +## Output + +End with a summary: + +```text +FIXED: N packages (list) +IGNORED: M packages (list with reasons) +UNRESOLVED: P packages (list — need team input) +``` diff --git a/scripts/cve/review.sh b/skills/cve-fix/scripts/review.sh similarity index 94% rename from scripts/cve/review.sh rename to skills/cve-fix/scripts/review.sh index 8ac9aa957..0d4c5cdd5 100755 --- a/scripts/cve/review.sh +++ b/skills/cve-fix/scripts/review.sh @@ -10,7 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib.sh" STATE_FILE="${1:?Usage: review.sh STATE_FILE FIX_SUMMARY [SCAN_OUTPUT]}" -FIX_SUMMARY="${2:?Missing FIX_SUMMARY}" +FIX_SUMMARY="${2:-}" CURRENT_SCAN="${3:-}" load_state "$STATE_FILE" @@ -46,7 +46,7 @@ if [[ -z "$LOCATE_OUTPUT" ]]; then fi # --- Build prompt --- -export REPO BRANCH FIX_SUMMARY CURRENT_SCAN LOCATE_OUTPUT SHIPYARD_GO_VERSION +export REPO BRANCH FIX_SUMMARY CURRENT_SCAN LOCATE_OUTPUT BUILD_GO_VERSION export CVE_SCRIPTS="$SCRIPT_DIR" STATE_FILE PROMPT=$(envsubst < "$PROMPT_TEMPLATE") diff --git a/scripts/cve/scan.sh b/skills/cve-fix/scripts/scan.sh similarity index 70% rename from scripts/cve/scan.sh rename to skills/cve-fix/scripts/scan.sh index d095d0f0f..4844248b8 100755 --- a/scripts/cve/scan.sh +++ b/skills/cve-fix/scripts/scan.sh @@ -23,11 +23,12 @@ for arg in "$@"; do esac done -# Build if needed (e.g., submariner repo with UPX compression for stdlib CVE detection) -if [[ "$NEEDS_BUILD_FOR_SCAN" == "true" ]] && [[ "$SKIP_BUILD" != "true" ]]; then - if ! make BUILD_UPX=false build >&2; then +# Build if configured (needed for stdlib CVE detection in compiled binaries) +if [[ -n "${BUILD_CMD:-}" ]] && [[ "$SKIP_BUILD" != "true" ]]; then + ENV_CMD="" + for _v in ${ENV_UNSET:-}; do ENV_CMD+="env -u $_v "; done + if ! eval "${ENV_CMD}${BUILD_CMD}" >&2; then echo "WARNING: Build failed. Scanning source only (may miss stdlib CVEs in binaries)." >&2 - echo "VPN can cause transient Docker DNS failures. Retry, or: sudo systemctl restart docker" >&2 fi fi diff --git a/skills/cve-fix/scripts/test-lib.sh b/skills/cve-fix/scripts/test-lib.sh new file mode 100755 index 000000000..0a4386eef --- /dev/null +++ b/skills/cve-fix/scripts/test-lib.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# Unit tests for CVE fix library functions. +# Runs without docker, grype, or network access. +# Uses the current repo for integration tests. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +PASS=0 FAIL=0 +check() { + local desc="$1"; shift + local negate=false + [[ "$1" == "!" ]] && { negate=true; shift; } + local rc=0; "$@" 2>/dev/null || rc=$? + if { [[ "$negate" == false ]] && [[ $rc -eq 0 ]]; } || \ + { [[ "$negate" == true ]] && [[ $rc -ne 0 ]]; }; then + PASS=$((PASS + 1)) + else + echo " FAIL: $desc"; FAIL=$((FAIL + 1)) + fi +} + +TMPD=$(mktemp -d) +trap 'rm -rf "$TMPD"' EXIT + +# === Pure functions === +echo "pure functions" +check "state_file_path" bash -c "[[ $(state_file_path /x/myrepo release-0.23) == /tmp/cve-fix-myrepo-release-0-23-*.env ]]" +check "state_file_path devel" bash -c "[[ $(state_file_path /x/myrepo devel) == /tmp/cve-fix-myrepo-devel-*.env ]]" +check "abbrev github" test "$(abbreviate_package github.com/docker/docker)" = "docker/docker" +check "abbrev x/" test "$(abbreviate_package golang.org/x/net)" = "x/net" +check "abbrev helm" test "$(abbreviate_package helm.sh/helm/v3)" = "helm/v3" +check "abbrev k8s" test "$(abbreviate_package k8s.io/client-go)" = "k8s.io/client-go" +check "abbrev other" test "$(abbreviate_package go.etcd.io/bbolt)" = "go.etcd.io/bbolt" +check "abbrev nested" test "$(abbreviate_package github.com/go-git/go-git/v5)" = "go-git/go-git/v5" +check "abbrev otel" test "$(abbreviate_package go.opentelemetry.io/otel)" = "otel" +check "abbrev otel/sdk" test "$(abbreviate_package go.opentelemetry.io/otel/sdk)" = "otel/sdk" +check "abbrev otel deep" test "$(abbreviate_package go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp)" = "otel/otlptracehttp" +check "abbrev grpc" test "$(abbreviate_package google.golang.org/grpc)" = "grpc" +check "abbrev sigs" test "$(abbreviate_package sigs.k8s.io/controller-runtime)" = "controller-runtime" +CVE_LIST=("GHSA-aaaa" "GHSA-bbbb"); JOINED=$(printf '%s, ' "${CVE_LIST[@]}"); JOINED="${JOINED%, }" +check "CVE join" test "$JOINED" = "GHSA-aaaa, GHSA-bbbb" +check "gte higher" version_gte 0.45.0 0.44.0 +check "gte equal" version_gte 0.44.0 0.44.0 +check "gte lower" ! version_gte 0.41.0 0.44.0 +check "gte major" version_gte 28.0.0 0.44.0 + +# === clean_gomod === +echo "clean_gomod" +printf 'module t\ngo 1.25.0\ntoolchain go1.25.1\n' > "$TMPD/go.mod" +(cd "$TMPD" && clean_gomod) +check "toolchain removed" test -z "$(grep toolchain "$TMPD/go.mod")" +check "go kept" grep -q "^go 1.25.0" "$TMPD/go.mod" + +# === load_state === +echo "load_state" +check "missing fails" ! load_state /tmp/nonexistent-cve-test.env +printf 'REPO="%s"\nBRANCH="devel"\n' "$TMPD" > "$TMPD/s.env" +check "loads vars" bash -c "source '$SCRIPT_DIR/lib.sh'; load_state '$TMPD/s.env' && [[ \$BRANCH = devel ]]" + +# === insert_grype_ignore === +echo "insert_grype_ignore" +cp "$REPO_ROOT/.grype.yaml" "$TMPD/g.yaml" +insert_grype_ignore "$TMPD/g.yaml" GHSA-test-1234 example.com/pkg "Test reason" +check "before exclude" test "$(grep -n GHSA-test-1234 "$TMPD/g.yaml" | cut -d: -f1)" -lt "$(grep -n '^exclude:' "$TMPD/g.yaml" | cut -d: -f1)" +check "entry present" grep -q GHSA-test-1234 "$TMPD/g.yaml" +check "original kept" grep -q CVE-2015-5237 "$TMPD/g.yaml" +printf -- '---\nignore:\n - vulnerability: CVE-1\n package:\n name: x\n' > "$TMPD/no-exc.yaml" +insert_grype_ignore "$TMPD/no-exc.yaml" GHSA-append example.com/other "No exclude" +check "appends" grep -q GHSA-append "$TMPD/no-exc.yaml" + +# === detect.sh (real repo) === +echo "detect.sh" +DETECT_OUT=$("$SCRIPT_DIR/detect.sh" "$REPO_ROOT" 0.23 2>&1) || true +check "normalizes 0.23" grep -q "release-0.23" <<< "$DETECT_OUT" +STATE=$(tail -1 <<< "$DETECT_OUT") +check "creates state" test -f "$STATE"; rm -f "$STATE" +check "bad repo fails" ! "$SCRIPT_DIR/detect.sh" /nonexistent devel +check "non-git fails" ! "$SCRIPT_DIR/detect.sh" /tmp devel + +# === locate.sh (real repo, inline state) === +echo "locate.sh" +printf 'REPO="%s"\nBRANCH="devel"\nCVE_SCRIPTS="%s"\n' "$REPO_ROOT" "$SCRIPT_DIR" > "$TMPD/loc.env" +LOCATE_OUT=$(bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" k8s.io/client-go 2>&1) || true +check "finds in go.mod" grep -q "Found in:" <<< "$LOCATE_OUT" +LOCATE_OUT=$(bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" github.com/golangci/golangci-lint 2>&1) || true +check "finds in tools" grep -q "tools" <<< "$LOCATE_OUT" +check "missing fails" ! bash "$SCRIPT_DIR/locate.sh" "$TMPD/loc.env" nonexistent/pkg >/dev/null + +# === write-subagent-report.sh === +echo "write-subagent-report" +GATE_TMPD="$TMPD/subagent-reports" +mkdir -p "$GATE_TMPD" +bash "$SCRIPT_DIR/write-subagent-report.sh" "$GATE_TMPD" "test-pass" "PASS" "0" "All checks passed" +check "report created" test -f "$GATE_TMPD/test-pass.report" +check "verdict line" grep -q "^VERDICT: PASS" "$GATE_TMPD/test-pass.report" +check "issues line" grep -q "^ISSUES: 0" "$GATE_TMPD/test-pass.report" +check "summary line" grep -q "^SUMMARY: All checks passed" "$GATE_TMPD/test-pass.report" +check "no tmp file" ! test -f "$GATE_TMPD/test-pass.report.tmp" + +bash "$SCRIPT_DIR/write-subagent-report.sh" "$GATE_TMPD" "test-fail" "FAIL" "2" "Found issues" \ + "line 1: bad format" "line 2: missing field" +check "fail verdict" grep -q "^VERDICT: FAIL" "$GATE_TMPD/test-fail.report" +check "fail issues" grep -q "^ISSUES: 2" "$GATE_TMPD/test-fail.report" +check "details present" grep -q "line 1: bad format" "$GATE_TMPD/test-fail.report" +check "bad verdict rejected" ! bash "$SCRIPT_DIR/write-subagent-report.sh" "$GATE_TMPD" "bad" "INVALID" "0" "test" +check "bad issues rejected" ! bash "$SCRIPT_DIR/write-subagent-report.sh" "$GATE_TMPD" "bad" "PASS" "abc" "test" +bash "$SCRIPT_DIR/write-subagent-report.sh" "$GATE_TMPD" "test-skip" "SKIP" "0" "Could not check" +check "skip accepted" grep -q "^VERDICT: SKIP" "$GATE_TMPD/test-skip.report" + +# === insert_grype_ignore duplicate detection === +echo "insert_grype_ignore duplicates" +printf -- '---\nignore:\n' > "$TMPD/dup.yaml" +insert_grype_ignore "$TMPD/dup.yaml" GHSA-dup-test example.com/dup "First insert" +DUP_COUNT1=$(grep -c "GHSA-dup-test" "$TMPD/dup.yaml") +insert_grype_ignore "$TMPD/dup.yaml" GHSA-dup-test example.com/dup "Duplicate insert" +DUP_COUNT2=$(grep -c "GHSA-dup-test" "$TMPD/dup.yaml") +check "dup skipped" test "$DUP_COUNT1" -eq "$DUP_COUNT2" + +# === insert_grype_ignore fix-state === +echo "insert_grype_ignore fix-state" +printf -- '---\nignore:\n' > "$TMPD/fs.yaml" +insert_grype_ignore "$TMPD/fs.yaml" GHSA-fs-test example.com/fs "Reason" "not-fixed" +check "fix-state present" grep -q "fix-state: not-fixed" "$TMPD/fs.yaml" + +printf -- '---\nignore:\n' > "$TMPD/nofs.yaml" +insert_grype_ignore "$TMPD/nofs.yaml" GHSA-nofs-test example.com/nofs "Reason" +check "no fix-state" ! grep -q "fix-state" "$TMPD/nofs.yaml" + +# Bug fix 1 regression: dup detection with fix-state (grep -A4, not -A2) +printf -- '---\nignore:\n' > "$TMPD/fsdup.yaml" +insert_grype_ignore "$TMPD/fsdup.yaml" GHSA-fsdup example.com/fsdup "First" "not-fixed" +FS_COUNT1=$(grep -c "GHSA-fsdup" "$TMPD/fsdup.yaml") +insert_grype_ignore "$TMPD/fsdup.yaml" GHSA-fsdup example.com/fsdup "Dup" "not-fixed" +FS_COUNT2=$(grep -c "GHSA-fsdup" "$TMPD/fsdup.yaml") +check "fix-state dup skipped" test "$FS_COUNT1" -eq "$FS_COUNT2" + +# === ignore.sh validation === +echo "ignore.sh validation" +printf 'REPO="%s"\nBRANCH="devel"\nCVE_SCRIPTS="%s"\n' "$TMPD" "$SCRIPT_DIR" > "$TMPD/ign.env" +check "bad severity" ! bash "$SCRIPT_DIR/ignore.sh" "$TMPD/ign.env" pkg "invalid" "reason" GHSA-xxxx +check "bad CVE format" ! bash "$SCRIPT_DIR/ignore.sh" "$TMPD/ign.env" pkg "High" "reason" "not-a-cve" + +# === ignore.sh --no-fix === +echo "ignore.sh --no-fix" +IGN_REPO="$TMPD/ign-repo" +mkdir -p "$IGN_REPO" +git -C "$IGN_REPO" init -q +git -C "$IGN_REPO" config user.email "test@test.com" +git -C "$IGN_REPO" config user.name "Test" +printf -- '---\nignore:\n' > "$IGN_REPO/.grype.yaml" +git -C "$IGN_REPO" add .grype.yaml +git -C "$IGN_REPO" commit -q -m "init" +git -C "$IGN_REPO" checkout -q -b devel +printf 'REPO="%s"\nBRANCH="devel"\nCVE_SCRIPTS="%s"\n' "$IGN_REPO" "$SCRIPT_DIR" > "$TMPD/ignf.env" + +bash "$SCRIPT_DIR/ignore.sh" "$TMPD/ignf.env" example.com/nf "High" "No fix" --no-fix GHSA-nf-0001 >/dev/null 2>&1 +check "--no-fix sets fix-state" grep -q "fix-state: not-fixed" "$IGN_REPO/.grype.yaml" + +git -C "$IGN_REPO" checkout -- .grype.yaml 2>/dev/null +printf -- '---\nignore:\n' > "$IGN_REPO/.grype.yaml" +git -C "$IGN_REPO" add .grype.yaml +git -C "$IGN_REPO" commit -q -m "reset" +bash "$SCRIPT_DIR/ignore.sh" "$TMPD/ignf.env" example.com/wf "High" "Has fix" GHSA-wf-0001 >/dev/null 2>&1 +check "no flag no fix-state" ! grep -q "fix-state" "$IGN_REPO/.grype.yaml" + +# === Summary === +echo "" +TOTAL=$((PASS + FAIL)) +echo "$PASS/$TOTAL passed" +if [[ "$FAIL" -gt 0 ]]; then echo "$FAIL FAILED"; exit 1; fi diff --git a/skills/cve-fix/scripts/write-subagent-report.sh b/skills/cve-fix/scripts/write-subagent-report.sh new file mode 100755 index 000000000..811eb995c --- /dev/null +++ b/skills/cve-fix/scripts/write-subagent-report.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Write a structured subagent verdict report atomically. +# Usage: write-subagent-report.sh REPORT_DIR NAME VERDICT ISSUES SUMMARY [DETAILS...] +# REPORT_DIR directory for report files +# NAME subagent identifier (e.g. yaml-integrity) +# VERDICT PASS | FAIL | SKIP +# ISSUES integer count of issues found +# SUMMARY one-line summary text +# DETAILS... zero or more detail lines (one per arg) +set -euo pipefail + +REPORT_DIR="${1:?Usage: write-subagent-report.sh REPORT_DIR NAME VERDICT ISSUES SUMMARY [DETAILS...]}" +NAME="${2:?Missing NAME}" +VERDICT="${3:?Missing VERDICT}" +ISSUES="${4:?Missing ISSUES}" +SUMMARY="${5:?Missing SUMMARY}" +shift 5 + +[[ "$VERDICT" =~ ^(PASS|FAIL|SKIP)$ ]] || { echo "ERROR: VERDICT must be PASS, FAIL, or SKIP" >&2; exit 1; } +[[ "$ISSUES" =~ ^[0-9]+$ ]] || { echo "ERROR: ISSUES must be an integer" >&2; exit 1; } + +mkdir -p "$REPORT_DIR" + +{ + echo "VERDICT: $VERDICT" + echo "ISSUES: $ISSUES" + echo "SUMMARY: $SUMMARY" + echo "DETAILS:" + for detail in "$@"; do + echo "$detail" + done +} > "$REPORT_DIR/${NAME}.report.tmp" + +mv "$REPORT_DIR/${NAME}.report.tmp" "$REPORT_DIR/${NAME}.report" diff --git a/skills/cve-fix/subagents/existing-entries.md b/skills/cve-fix/subagents/existing-entries.md new file mode 100644 index 000000000..b4c987afa --- /dev/null +++ b/skills/cve-fix/subagents/existing-entries.md @@ -0,0 +1,68 @@ +# Existing Entries Audit: ${REPO} ${BRANCH} + +You are read-only -- do not edit repo files. Your sole permitted write is your subagent report. + +## Companion Evidence + +${COMPANION_EVIDENCE} + +## Task + +Evaluate the companion script output above. The script audited pre-existing .grype.yaml +entries (those present on origin/${BRANCH} before this fix run) to check for staleness. + +Two types of findings: + +### SHOULD-BE-NOT-FIXED + +A permanent ignore (no fix-state) has newer versions available. This means the entry +could potentially use `fix-state: not-fixed` instead, which would auto-expire when +grype detects the fix. + +**However**, some permanent ignores are intentionally permanent. Check the comment field: + +- If the comment mentions "requires v2.x", "incompatible API", "major version upgrade", + or similar context about breaking changes, the permanent ignore is **valid** -- the + newer version exists but cannot be used on this branch. +- If the comment has no such context, the entry **should** be converted to not-fixed. + +### FIX-NOW-AVAILABLE + +An entry marked `fix-state: not-fixed` has newer versions available. The fix-state +mechanism should auto-expire these, but if the newer version is significantly newer +than the current version, it may indicate the entry should have been fixed rather +than ignored. + +## Verdict + +- **PASS** if no entries genuinely need updating after applying judgment: + - SHOULD-BE-NOT-FIXED entries with breaking-change context are valid (not issues) + - FIX-NOW-AVAILABLE entries will auto-expire (informational only unless egregious) +- **FAIL** if any entry clearly should be changed: + - SHOULD-BE-NOT-FIXED without breaking-change justification + - FIX-NOW-AVAILABLE where the fix has been available for a long time + +Use your judgment. When in doubt, PASS with informational details. + +## Report + +Write your verdict using write-subagent-report.sh: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" VERDICT ISSUE_COUNT "summary" [detail_lines...] +``` + +Where: + +- VERDICT is PASS or FAIL +- ISSUE_COUNT is the count of entries that genuinely need updating +- summary is a one-line description of the verdict +- detail_lines provide context for each finding + +Example: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" \ + PASS 0 "All pre-existing entries are valid" \ + "SHOULD-BE-NOT-FIXED: CVE-2024-1234 (pkg) has newer version but comment says requires v2.x -- valid permanent ignore" +``` diff --git a/skills/cve-fix/subagents/existing-entries.sh b/skills/cve-fix/subagents/existing-entries.sh new file mode 100755 index 000000000..8faa5c803 --- /dev/null +++ b/skills/cve-fix/subagents/existing-entries.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Audit pre-existing .grype.yaml entries for staleness. +# Checks if permanent ignores should use fix-state: not-fixed, and +# if not-fixed entries now have fixes available. +# Usage: existing-entries.sh STATE_FILE +set -euo pipefail + +STATE_FILE="${1:?Usage: existing-entries.sh STATE_FILE}" + +# shellcheck source=/dev/null +source "$STATE_FILE" + +# shellcheck source=/dev/null +source "$CVE_SCRIPTS/lib.sh" +load_state "$STATE_FILE" + +ISSUES=() + +# Get pre-existing .grype.yaml content from the branch base +ORIGINAL=$(git show "origin/$BRANCH:.grype.yaml" 2>/dev/null || echo "") +if [[ -z "$ORIGINAL" ]]; then + echo "NEW_ISSUES=0" + echo "No pre-existing .grype.yaml on origin/$BRANCH" + exit 0 +fi + +# Skip CVEs listed in .grype.yaml exclude section (known false positives) +SKIP_VULNS="" +if [[ -f .grype.yaml ]]; then + SKIP_VULNS=$(sed -n '/^exclude:/,/^[^ ]/p' .grype.yaml | grep -oP 'CVE-[0-9-]+|GHSA-[a-z0-9-]+' | tr '\n' ' ') +fi + +# Parse entries from pre-existing content +# Track: vulnerability ID, package name, fix-state, comment +ENTRIES=() +CURRENT_VULN="" +CURRENT_PKG="" +CURRENT_FIX_STATE="" +CURRENT_COMMENT="" +PENDING_COMMENT="" + +while IFS= read -r line; do + # Buffer comment lines for the NEXT entry + if echo "$line" | grep -q '^\s*#'; then + PENDING_COMMENT="$line" + continue + fi + + if echo "$line" | grep -q '^\s*- vulnerability:'; then + # Save previous entry if complete + if [[ -n "$CURRENT_VULN" ]] && [[ -n "$CURRENT_PKG" ]]; then + ENTRIES+=("$CURRENT_VULN|$CURRENT_PKG|$CURRENT_FIX_STATE|$CURRENT_COMMENT") + fi + CURRENT_VULN=$(echo "$line" | sed 's/.*vulnerability:[[:space:]]*//' | xargs) + CURRENT_PKG="" + CURRENT_FIX_STATE="" + CURRENT_COMMENT="$PENDING_COMMENT" + PENDING_COMMENT="" + fi + + if echo "$line" | grep -q 'fix-state:'; then + CURRENT_FIX_STATE=$(echo "$line" | sed 's/.*fix-state:[[:space:]]*//' | xargs) + fi + + if echo "$line" | grep -q '^\s*name:'; then + CURRENT_PKG=$(echo "$line" | sed 's/.*name:[[:space:]]*//' | xargs) + fi +done <<< "$ORIGINAL" + +# Save last entry +if [[ -n "$CURRENT_VULN" ]] && [[ -n "$CURRENT_PKG" ]]; then + ENTRIES+=("$CURRENT_VULN|$CURRENT_PKG|$CURRENT_FIX_STATE|$CURRENT_COMMENT") +fi + +# Check each entry +for entry in "${ENTRIES[@]}"; do + IFS='|' read -r VULN PKG FIX_STATE COMMENT <<< "$entry" + + # Skip known false positives + SKIP=false + for skip_vuln in $SKIP_VULNS; do + if [[ "$VULN" == "$skip_vuln" ]]; then + SKIP=true + break + fi + done + [[ "$SKIP" == true ]] && continue + + # Get current and available versions + CURRENT_VER=$(go list -m "$PKG" 2>/dev/null | awk '{print $2}' || echo "") + VERSIONS=$(go list -m -versions "$PKG" 2>/dev/null | sed "s|^$PKG ||" || echo "") + LATEST_VER="" + if [[ -n "$VERSIONS" ]]; then + LATEST_VER=$(echo "$VERSIONS" | tr ' ' '\n' | tail -1) + fi + + # Also check tools/go.mod + if [[ "$HAS_TOOLS_GOMOD" == "true" ]] && [[ -z "$CURRENT_VER" ]]; then + CURRENT_VER=$(go -C tools list -m "$PKG" 2>/dev/null | awk '{print $2}' || echo "") + VERSIONS=$(go -C tools list -m -versions "$PKG" 2>/dev/null | sed "s|^$PKG ||" || echo "") + if [[ -n "$VERSIONS" ]]; then + LATEST_VER=$(echo "$VERSIONS" | tr ' ' '\n' | tail -1) + fi + fi + + HAS_NEWER=false + if [[ -n "$CURRENT_VER" ]] && [[ -n "$LATEST_VER" ]] && [[ "$LATEST_VER" != "$CURRENT_VER" ]]; then + if version_gte "$LATEST_VER" "$CURRENT_VER"; then + HAS_NEWER=true + fi + fi + + if [[ -z "$FIX_STATE" ]]; then + # Permanent ignore (no fix-state) + if [[ "$HAS_NEWER" == true ]]; then + ISSUES+=("SHOULD-BE-NOT-FIXED: $VULN ($PKG) is a permanent ignore but $CURRENT_VER -> $LATEST_VER available | comment: $COMMENT") + fi + else + # Has fix-state: not-fixed + if [[ "$HAS_NEWER" == true ]]; then + ISSUES+=("FIX-NOW-AVAILABLE: $VULN ($PKG) marked not-fixed but $CURRENT_VER -> $LATEST_VER available | comment: $COMMENT") + fi + fi +done + +# Output structured results +echo "NEW_ISSUES=${#ISSUES[@]}" +for issue in "${ISSUES[@]}"; do + echo "$issue" +done + +exit 0 diff --git a/skills/cve-fix/subagents/fix-availability.md b/skills/cve-fix/subagents/fix-availability.md new file mode 100644 index 000000000..f91463905 --- /dev/null +++ b/skills/cve-fix/subagents/fix-availability.md @@ -0,0 +1,52 @@ +# Fix Availability Verification: ${REPO} ${BRANCH} + +You are read-only -- do not edit repo files. Your sole permitted write is your subagent report. + +## Companion Evidence + +${COMPANION_EVIDENCE} + +## Task + +Evaluate the companion script output above. The script cross-referenced .grype.yaml +entries marked with `fix-state: not-fixed` against `go list -m -versions` to check +whether newer versions of the package are now available. + +For each `FIX-AVAILABLE` entry: + +- A newer version of the package exists in the Go module proxy +- This strongly suggests a fix is available and the `fix-state: not-fixed` entry + may be stale or was added prematurely +- The package should be fixed rather than ignored + +## Verdict + +- **PASS** if `NEW_ISSUES=0` -- all not-fixed entries are genuine (no newer versions available) +- **FAIL** if any `FIX-AVAILABLE` entries exist -- a fix may be available for packages + currently marked as unfixable + +Include every FIX-AVAILABLE line from the evidence in your detail output. + +## Report + +Write your verdict using write-subagent-report.sh: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" VERDICT ISSUE_COUNT "summary" [detail_lines...] +``` + +Where: + +- VERDICT is PASS or FAIL +- ISSUE_COUNT is the integer from NEW_ISSUES +- summary is a one-line description +- detail_lines are optional, one per FIX-AVAILABLE finding + +Example: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" \ + FAIL 2 "2 packages marked not-fixed have newer versions available" \ + "FIX-AVAILABLE: golang.org/x/net v0.20.0 -> v0.23.0 (go.mod) CVEs:GHSA-xxxx" \ + "FIX-AVAILABLE: golang.org/x/crypto v0.18.0 -> v0.21.0 (tools/go.mod) CVEs:GHSA-yyyy" +``` diff --git a/skills/cve-fix/subagents/fix-availability.sh b/skills/cve-fix/subagents/fix-availability.sh new file mode 100755 index 000000000..5244342cd --- /dev/null +++ b/skills/cve-fix/subagents/fix-availability.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Cross-reference grype not-fixed verdicts with go list -m -versions. +# Checks if newer versions are available for packages marked as not-fixed. +# Usage: fix-availability.sh STATE_FILE +set -euo pipefail + +STATE_FILE="${1:?Usage: fix-availability.sh STATE_FILE}" + +# shellcheck source=/dev/null +source "$STATE_FILE" + +# shellcheck source=/dev/null +source "$CVE_SCRIPTS/lib.sh" +load_state "$STATE_FILE" + +ISSUES=() + +# Parse .grype.yaml for entries with fix-state: not-fixed +GRYPE_FILE=".grype.yaml" +if [[ ! -f "$GRYPE_FILE" ]]; then + echo "NEW_ISSUES=0" + echo "No .grype.yaml file found" + exit 0 +fi + +# Extract package names and vulnerability IDs with fix-state: not-fixed +# YAML structure: +# - vulnerability: GHSA-xxxx +# fix-state: not-fixed +# package: +# name: package.name/path +NOT_FIXED_PACKAGES=() +declare -A PKG_VULNS +CURRENT_FIX_STATE="" +CURRENT_PKG="" +CURRENT_VULN="" +while IFS= read -r line; do + if echo "$line" | grep -q '^\s*- vulnerability:'; then + # Save previous entry if it was not-fixed + if [[ "$CURRENT_FIX_STATE" == "not-fixed" ]] && [[ -n "$CURRENT_PKG" ]]; then + NOT_FIXED_PACKAGES+=("$CURRENT_PKG") + if [[ -n "${PKG_VULNS[$CURRENT_PKG]:-}" ]]; then + PKG_VULNS["$CURRENT_PKG"]+=",${CURRENT_VULN}" + else + PKG_VULNS["$CURRENT_PKG"]="${CURRENT_VULN}" + fi + fi + CURRENT_VULN=$(echo "$line" | sed 's/.*vulnerability:[[:space:]]*//' | xargs) + CURRENT_FIX_STATE="" + CURRENT_PKG="" + fi + if echo "$line" | grep -q 'fix-state:'; then + CURRENT_FIX_STATE=$(echo "$line" | sed 's/.*fix-state:[[:space:]]*//' | xargs) + fi + if echo "$line" | grep -q '^\s*name:'; then + CURRENT_PKG=$(echo "$line" | sed 's/.*name:[[:space:]]*//' | xargs) + fi +done < "$GRYPE_FILE" +# Save last entry +if [[ "$CURRENT_FIX_STATE" == "not-fixed" ]] && [[ -n "$CURRENT_PKG" ]]; then + NOT_FIXED_PACKAGES+=("$CURRENT_PKG") + if [[ -n "${PKG_VULNS[$CURRENT_PKG]:-}" ]]; then + PKG_VULNS["$CURRENT_PKG"]+=",${CURRENT_VULN}" + else + PKG_VULNS["$CURRENT_PKG"]="${CURRENT_VULN}" + fi +fi + +if [[ ${#NOT_FIXED_PACKAGES[@]} -eq 0 ]]; then + echo "NEW_ISSUES=0" + echo "No not-fixed entries in .grype.yaml" + exit 0 +fi + +# Deduplicate +readarray -t UNIQUE_PACKAGES < <(printf '%s\n' "${NOT_FIXED_PACKAGES[@]}" | sort -u) + +# Find packages already bumped on this branch (fix didn't resolve the CVE) +ALREADY_BUMPED="" +ALREADY_BUMPED=$(git log "origin/$BRANCH"..HEAD --format="%s" 2>/dev/null | \ + awk '/^Bump /{print $2}' || true) + +# Check each package for newer versions +for PKG in "${UNIQUE_PACKAGES[@]}"; do + # Skip if this package was already bumped on this branch — the CVE persists + # despite the bump, so a newer version won't fix it + PKG_SHORT=$(abbreviate_package "$PKG") + if echo "$ALREADY_BUMPED" | grep -qxF "$PKG_SHORT"; then + continue + fi + + CURRENT="" + LATEST="" + CHECKED_ROOT=false + CHECKED_TOOLS=false + + # Check root go.mod + CURRENT=$(go list -m "$PKG" 2>/dev/null | awk '{print $2}' || echo "") + if [[ -n "$CURRENT" ]]; then + CHECKED_ROOT=true + VERSIONS=$(go list -m -versions "$PKG" 2>/dev/null | sed "s|^$PKG ||" || echo "") + if [[ -n "$VERSIONS" ]]; then + LATEST=$(echo "$VERSIONS" | tr ' ' '\n' | tail -1) + if [[ -n "$LATEST" ]] && [[ "$LATEST" != "$CURRENT" ]]; then + if version_gte "$LATEST" "$CURRENT"; then + ISSUES+=("FIX-AVAILABLE: $PKG $CURRENT -> $LATEST (go.mod) CVEs:${PKG_VULNS[$PKG]}") + fi + fi + fi + fi + + # Check tools/go.mod if it exists + if [[ "$HAS_TOOLS_GOMOD" == "true" ]]; then + TOOLS_CURRENT=$(go -C tools list -m "$PKG" 2>/dev/null | awk '{print $2}' || echo "") + if [[ -n "$TOOLS_CURRENT" ]]; then + CHECKED_TOOLS=true + TOOLS_VERSIONS=$(go -C tools list -m -versions "$PKG" 2>/dev/null | sed "s|^$PKG ||" || echo "") + if [[ -n "$TOOLS_VERSIONS" ]]; then + TOOLS_LATEST=$(echo "$TOOLS_VERSIONS" | tr ' ' '\n' | tail -1) + if [[ -n "$TOOLS_LATEST" ]] && [[ "$TOOLS_LATEST" != "$TOOLS_CURRENT" ]]; then + if version_gte "$TOOLS_LATEST" "$TOOLS_CURRENT"; then + ISSUES+=("FIX-AVAILABLE: $PKG $TOOLS_CURRENT -> $TOOLS_LATEST (tools/go.mod) CVEs:${PKG_VULNS[$PKG]}") + fi + fi + fi + fi + fi + + if [[ "$CHECKED_ROOT" == false ]] && [[ "$CHECKED_TOOLS" == false ]]; then + # Package not found in either go.mod -- may be a transitive dependency + # Try go list with -m flag which checks the module graph + VERSIONS=$(go list -m -versions "$PKG" 2>/dev/null | sed "s|^$PKG ||" || echo "") + if [[ -n "$VERSIONS" ]]; then + LATEST=$(echo "$VERSIONS" | tr ' ' '\n' | tail -1) + ISSUES+=("FIX-AVAILABLE: $PKG (transitive) -> $LATEST available CVEs:${PKG_VULNS[$PKG]}") + fi + fi +done + +# Output structured results +echo "NEW_ISSUES=${#ISSUES[@]}" +for issue in "${ISSUES[@]}"; do + echo "$issue" +done + +exit 0 diff --git a/skills/cve-fix/subagents/scan-regression.md b/skills/cve-fix/subagents/scan-regression.md new file mode 100644 index 000000000..9d1e8b0c9 --- /dev/null +++ b/skills/cve-fix/subagents/scan-regression.md @@ -0,0 +1,56 @@ +# Scan Regression Verification: ${REPO} ${BRANCH} + +You are read-only -- do not edit repo files. Your sole permitted write is your subagent report. + +## Companion Evidence + +${COMPANION_EVIDENCE} + +## Task + +Evaluate the companion script output above. The script compared the initial CVE scan +(taken before any fixes) with a final scan (taken after all fixes and ignores) to +detect regressions. + +Two categories of findings: + +### NEW + +CVEs that appear in the final scan but were NOT in the initial scan. These are +regressions -- a dependency update introduced a new vulnerability. + +### UNRESOLVED + +CVEs that were in the initial scan and are still in the final scan without a +corresponding .grype.yaml ignore entry. These were neither fixed nor ignored. + +## Verdict + +- **PASS** if `NEW_CVES=0` AND `UNRESOLVED=0` +- **FAIL** if either count is greater than 0 +- **SKIP** if the companion script reported SKIP (e.g., missing initial scan) + +Include every NEW and UNRESOLVED line from the evidence in your detail output. + +## Report + +Write your verdict using write-subagent-report.sh: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" VERDICT ISSUE_COUNT "summary" [detail_lines...] +``` + +Where: + +- VERDICT is PASS, FAIL, or SKIP +- ISSUE_COUNT is NEW_CVES + UNRESOLVED +- summary is a one-line description +- detail_lines list each NEW or UNRESOLVED CVE + +Example: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" \ + FAIL 1 "1 new CVE introduced by dependency updates" \ + "NEW: GHSA-xxxx-xxxx-xxxx (golang.org/x/text v0.14.0 High)" +``` diff --git a/skills/cve-fix/subagents/scan-regression.sh b/skills/cve-fix/subagents/scan-regression.sh new file mode 100755 index 000000000..93f9ead34 --- /dev/null +++ b/skills/cve-fix/subagents/scan-regression.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Compare initial and final scans to detect regressions. +# Finds new CVEs introduced by fixes and unresolved CVEs without ignore entries. +# Usage: scan-regression.sh STATE_FILE REPORT_DIR +set -euo pipefail + +STATE_FILE="${1:?Usage: scan-regression.sh STATE_FILE REPORT_DIR}" +REPORT_DIR="${2:?Usage: scan-regression.sh STATE_FILE REPORT_DIR}" + +# shellcheck source=/dev/null +source "$STATE_FILE" + +# shellcheck source=/dev/null +source "$CVE_SCRIPTS/lib.sh" +load_state "$STATE_FILE" + +NEW_CVES=0 +UNRESOLVED=0 +DETAILS=() + +# Read initial scan +INITIAL_SCAN_FILE="$REPORT_DIR/initial-scan.json" +if [[ ! -f "$INITIAL_SCAN_FILE" ]]; then + echo "NEW_CVES=0 UNRESOLVED=0" + echo "SKIP: No initial scan found at $INITIAL_SCAN_FILE" + exit 0 +fi + +# Run final scan +FINAL_SCAN=$(bash "$CVE_SCRIPTS/scan.sh" "$STATE_FILE" --no-update --skip-build --json 2>/dev/null || echo "") +if [[ -z "$FINAL_SCAN" ]] || ! echo "$FINAL_SCAN" | jq empty 2>/dev/null; then + echo "NEW_CVES=0 UNRESOLVED=0" + echo "SKIP: Final scan failed or produced invalid JSON" + exit 0 +fi + +# Extract CVE IDs from initial scan (go-module only) +INITIAL_CVES=$(jq -r '.matches[] | select(.artifact.type == "go-module") | .vulnerability.id' "$INITIAL_SCAN_FILE" 2>/dev/null | sort -u || echo "") + +# Extract CVE IDs from final scan (go-module only) +FINAL_CVES=$(echo "$FINAL_SCAN" | jq -r '.matches[] | select(.artifact.type == "go-module") | .vulnerability.id' 2>/dev/null | sort -u || echo "") + +# Extract CVE IDs from .grype.yaml ignore entries +IGNORED_CVES="" +if [[ -f .grype.yaml ]]; then + IGNORED_CVES=$(grep 'vulnerability:' .grype.yaml | sed 's/.*vulnerability:[[:space:]]*//' | xargs -r -n1 | sort -u || echo "") +fi + +# Find NEW CVEs: in final scan but not in initial scan +if [[ -n "$FINAL_CVES" ]] && [[ -n "$INITIAL_CVES" ]]; then + NEW_CVE_LIST=$(comm -23 <(echo "$FINAL_CVES") <(echo "$INITIAL_CVES") || echo "") +elif [[ -n "$FINAL_CVES" ]]; then + NEW_CVE_LIST="$FINAL_CVES" +else + NEW_CVE_LIST="" +fi + +while IFS= read -r cve; do + [[ -z "$cve" ]] && continue + # Get package info from final scan + PKG_INFO=$(echo "$FINAL_SCAN" | jq -r --arg cve "$cve" \ + '.matches[] | select(.vulnerability.id == $cve) | "\(.artifact.name) \(.artifact.version) \(.vulnerability.severity)"' \ + 2>/dev/null | head -1 || echo "unknown") + DETAILS+=("NEW: $cve ($PKG_INFO)") + NEW_CVES=$((NEW_CVES + 1)) +done <<< "$NEW_CVE_LIST" + +# Find UNRESOLVED CVEs: in initial AND final scan, not in .grype.yaml ignores +if [[ -n "$FINAL_CVES" ]] && [[ -n "$INITIAL_CVES" ]]; then + STILL_PRESENT=$(comm -12 <(echo "$FINAL_CVES") <(echo "$INITIAL_CVES") || echo "") + + while IFS= read -r cve; do + [[ -z "$cve" ]] && continue + # Check if it's in the ignore list + if echo "$IGNORED_CVES" | grep -qF "$cve"; then + continue + fi + # Get package info + PKG_INFO=$(echo "$FINAL_SCAN" | jq -r --arg cve "$cve" \ + '.matches[] | select(.vulnerability.id == $cve) | "\(.artifact.name) \(.artifact.version) \(.vulnerability.severity)"' \ + 2>/dev/null | head -1 || echo "unknown") + DETAILS+=("UNRESOLVED: $cve ($PKG_INFO)") + UNRESOLVED=$((UNRESOLVED + 1)) + done <<< "$STILL_PRESENT" +fi + +# Output structured results +echo "NEW_CVES=$NEW_CVES UNRESOLVED=$UNRESOLVED" +for detail in "${DETAILS[@]}"; do + echo "$detail" +done + +exit 0 diff --git a/skills/cve-fix/subagents/yaml-integrity.md b/skills/cve-fix/subagents/yaml-integrity.md new file mode 100644 index 000000000..c3a1a4980 --- /dev/null +++ b/skills/cve-fix/subagents/yaml-integrity.md @@ -0,0 +1,46 @@ +# YAML Integrity Verification: ${REPO} ${BRANCH} + +You are read-only -- do not edit repo files. Your sole permitted write is your subagent report. + +## Companion Evidence + +${COMPANION_EVIDENCE} + +## Task + +Evaluate the companion script output above. The script validated .grype.yaml entries +added during this CVE fix run, checking for: + +- Vulnerability IDs matching CVE-/GHSA-/GO- patterns +- Non-empty package names +- fix-state values limited to "not-fixed" +- No blank lines with trailing whitespace +- Comment lines preceding each entry + +## Verdict + +- **PASS** if `NEW_ISSUES=0` +- **FAIL** if `NEW_ISSUES` is greater than 0 + +Include every issue line from the evidence in your detail output. + +## Report + +Write your verdict using write-subagent-report.sh: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" VERDICT ISSUE_COUNT "summary" [detail_lines...] +``` + +Where: + +- VERDICT is PASS or FAIL +- ISSUE_COUNT is the integer from NEW_ISSUES +- summary is a one-line description +- detail_lines are optional, one per issue found + +Example: + +```bash +bash "${CVE_SCRIPTS}/write-subagent-report.sh" "${REPORT_DIR}" "${SUBAGENT_NAME}" PASS 0 "All .grype.yaml entries are well-formed" +``` diff --git a/skills/cve-fix/subagents/yaml-integrity.sh b/skills/cve-fix/subagents/yaml-integrity.sh new file mode 100755 index 000000000..688304b76 --- /dev/null +++ b/skills/cve-fix/subagents/yaml-integrity.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Validate .grype.yaml entries added in this run. +# Checks format, required fields, no blank lines/trailing whitespace. +# Usage: yaml-integrity.sh STATE_FILE +set -euo pipefail + +STATE_FILE="${1:?Usage: yaml-integrity.sh STATE_FILE}" + +# shellcheck source=/dev/null +source "$STATE_FILE" + +# shellcheck source=/dev/null +source "$CVE_SCRIPTS/lib.sh" +load_state "$STATE_FILE" + +ISSUES=() + +# Get diff of .grype.yaml against the branch base +DIFF=$(git diff "origin/$BRANCH" -- .grype.yaml 2>/dev/null || echo "") + +if [[ -z "$DIFF" ]]; then + echo "NEW_ISSUES=0" + echo "No changes to .grype.yaml" + exit 0 +fi + +# Extract added lines with context (line numbers in new file) +# Process added blocks: look for entries starting with "- vulnerability:" +ADDED_LINES=$(echo "$DIFF" | grep '^+' | grep -v '^+++' | sed 's/^+//') + +# Check for blank lines with trailing whitespace +TRAILING_WS=$(echo "$ADDED_LINES" | grep -n '^[[:space:]]*$' | grep -c '[[:space:]]$' || echo 0) +if [[ "$TRAILING_WS" -gt 0 ]]; then + ISSUES+=("TRAILING_WHITESPACE: $TRAILING_WS blank line(s) with trailing whitespace in added content") +fi + +# Check each vulnerability entry in added lines +VULN_IDS=$(echo "$ADDED_LINES" | grep 'vulnerability:' | sed 's/.*vulnerability:[[:space:]]*//' || true) + +while IFS= read -r vuln_id; do + [[ -z "$vuln_id" ]] && continue + + # Check vulnerability ID format + if ! [[ "$vuln_id" =~ ^(CVE-|GHSA-|GO-) ]]; then + ISSUES+=("BAD_VULN_FORMAT: '$vuln_id' does not match CVE-/GHSA-/GO- pattern") + fi +done <<< "$VULN_IDS" + +# Check for package name entries +PKG_NAMES=$(echo "$ADDED_LINES" | grep 'name:' | sed 's/.*name:[[:space:]]*//' || true) + +while IFS= read -r pkg_name; do + [[ -z "$pkg_name" ]] && continue + # Check package name is non-empty (after trimming) + TRIMMED=$(echo "$pkg_name" | xargs) + if [[ -z "$TRIMMED" ]]; then + ISSUES+=("EMPTY_PACKAGE_NAME: package name is empty") + fi +done <<< "$PKG_NAMES" + +# Check fix-state values +FIX_STATES=$(echo "$ADDED_LINES" | grep 'fix-state:' | sed 's/.*fix-state:[[:space:]]*//' || true) + +while IFS= read -r fix_state; do + [[ -z "$fix_state" ]] && continue + TRIMMED=$(echo "$fix_state" | xargs) + if [[ "$TRIMMED" != "not-fixed" ]]; then + ISSUES+=("BAD_FIX_STATE: fix-state '$TRIMMED' is not 'not-fixed'") + fi +done <<< "$FIX_STATES" + +# Check that each vulnerability entry has a preceding comment +# Parse the full new-file content of .grype.yaml to check context +FULL_GRYPE=$(git show HEAD:.grype.yaml 2>/dev/null || cat .grype.yaml 2>/dev/null || echo "") +PREV_LINE="" +while IFS= read -r line; do + if echo "$line" | grep -q '^\s*- vulnerability:'; then + # Check if the previous line is a comment + if ! echo "$PREV_LINE" | grep -q '^\s*#'; then + VULN=$(echo "$line" | sed 's/.*vulnerability:[[:space:]]*//') + # Only flag if this is a newly added entry + if echo "$ADDED_LINES" | grep -qF "vulnerability: $VULN"; then + ISSUES+=("MISSING_COMMENT: no comment line before entry for $VULN") + fi + fi + fi + PREV_LINE="$line" +done <<< "$FULL_GRYPE" + +# Output structured results +echo "NEW_ISSUES=${#ISSUES[@]}" +for issue in "${ISSUES[@]}"; do + echo "$issue" +done + +exit 0 diff --git a/test/scripts/cve/test.sh b/test/scripts/cve/test.sh index 93b70325c..af9991402 100755 --- a/test/scripts/cve/test.sh +++ b/test/scripts/cve/test.sh @@ -3,4 +3,4 @@ # Uses source tree (not installed scripts) since we're testing changes. set -e cd "$(dirname "$0")/../../.." -./scripts/cve/test-lib.sh +./skills/cve-fix/scripts/test-lib.sh