From eb3429bf67ac3daf96c5a45b5dac8f5f791db991 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:22:48 +0000 Subject: [PATCH 1/4] Run container remediation agents with Pi and publish fixes without agent write access --- .../container-remediation-self-test.yml | 1 + .../workflows/container-remediation-tests.yml | 29 ++ .github/workflows/container-remediation.yml | 303 ++++++++++++++---- .../container-scan/disallowed-paths.sh | 11 + .../workflows/container-scan/fix-prompt.md | 9 +- .../workflows/container-scan/triage-prompt.md | 2 +- scripts/test_container_remediation.py | 221 +++++++++++++ 7 files changed, 505 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/container-remediation-tests.yml create mode 100644 .github/workflows/container-scan/disallowed-paths.sh create mode 100644 scripts/test_container_remediation.py diff --git a/.github/workflows/container-remediation-self-test.yml b/.github/workflows/container-remediation-self-test.yml index 883c8d6..09f1afa 100644 --- a/.github/workflows/container-remediation-self-test.yml +++ b/.github/workflows/container-remediation-self-test.yml @@ -20,4 +20,5 @@ jobs: image-ref: ${{ inputs.image-ref }} go-version-file: 'test/container-remediation/go.mod' ecr-role-to-assume: ${{ vars.ECR_PUSHER }} + security-workflows-ref: ${{ github.sha }} secrets: inherit diff --git a/.github/workflows/container-remediation-tests.yml b/.github/workflows/container-remediation-tests.yml new file mode 100644 index 0000000..e08ae87 --- /dev/null +++ b/.github/workflows/container-remediation-tests.yml @@ -0,0 +1,29 @@ +name: Container Remediation Tests + +on: + pull_request: + paths: + - .github/workflows/container-remediation.yml + - .github/workflows/container-remediation-tests.yml + - .github/workflows/container-scan/** + - scripts/test_container_remediation.py + push: + branches: [main] + paths: + - .github/workflows/container-remediation.yml + - .github/workflows/container-remediation-tests.yml + - .github/workflows/container-scan/** + - scripts/test_container_remediation.py + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - run: | + python3 -m venv /tmp/container-remediation-tests + /tmp/container-remediation-tests/bin/pip install PyYAML==6.0.3 + /tmp/container-remediation-tests/bin/python -m unittest discover -s scripts -p test_container_remediation.py diff --git a/.github/workflows/container-remediation.yml b/.github/workflows/container-remediation.yml index 8b5bacc..b6a25f0 100644 --- a/.github/workflows/container-remediation.yml +++ b/.github/workflows/container-remediation.yml @@ -6,6 +6,10 @@ name: Container Vulnerability Remediation # # Modeled after vuln-remediation.yml (Socket SCA), but for container-level # findings (OS packages + compiled-in Go deps). +# +# The agent never holds GitHub write credentials. The fix job runs Pi, keeps +# only go.mod, go.sum, and Dockerfile changes, and builds them. The publish job +# re-checks that patch and only then mints the token that pushes and opens the PR. on: workflow_call: @@ -45,21 +49,23 @@ jobs: has-findings: ${{ steps.check.outputs.has_findings }} timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Configure AWS credentials if: inputs.ecr-role-to-assume != '' - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: role-to-assume: ${{ inputs.ecr-role-to-assume }} aws-region: ${{ inputs.aws-region }} - name: Log in to ECR if: inputs.ecr-role-to-assume != '' - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 - name: Scan container image - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@38b337fff0a2f984d5be173c99e3f633438167ec # master with: image-ref: ${{ inputs.image-ref }} severity: 'CRITICAL,HIGH' @@ -81,7 +87,7 @@ jobs: fi - name: Upload scan results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: container-scan-report path: trivy-results.json @@ -92,30 +98,47 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - timeout-minutes: 5 + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Download scan results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: container-scan-report - - name: Install Claude Code CLI + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install Pi + env: + PI_TELEMETRY: '0' run: | - curl -fsSL https://claude.ai/install.sh | bash - echo "$HOME/.local/bin" >> $GITHUB_PATH + npm install -g --ignore-scripts @earendil-works/pi-coding-agent + pi --version - name: Triage findings env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }} + PI_SKIP_VERSION_CHECK: '1' + PI_TELEMETRY: '0' + REF: ${{ inputs.security-workflows-ref }} run: | - curl -fsSL "https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/.github/workflows/container-scan/triage-prompt.md" \ - | envsubst '${GITHUB_REPOSITORY}' \ - | claude -p --model ${{ vars.CLAUDE_CODE_PREFERRED_MODEL }} --dangerously-skip-permissions --output-format text --verbose + : "${PI_PREFERRED_MODEL:?Set the PI_PREFERRED_MODEL organization variable}" + curl -fsSL "https://raw.githubusercontent.com/kernel/security-workflows/$REF/.github/workflows/container-scan/triage-prompt.md" \ + | envsubst '${GITHUB_REPOSITORY}' > "$RUNNER_TEMP/triage-prompt.md" + pi --print --no-session --approve \ + --model "$PI_PREFERRED_MODEL" --thinking max \ + @"$RUNNER_TEMP/triage-prompt.md" - name: Upload triage results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: container-triage-result path: triage-result.json @@ -125,101 +148,249 @@ jobs: needs: triage runs-on: ubuntu-24.04 permissions: - contents: write - pull-requests: write - timeout-minutes: 10 + contents: read + timeout-minutes: 30 + outputs: + changed: ${{ steps.snapshot.outputs.changed }} + main_sha: ${{ steps.prepare.outputs.main_sha }} + update_sha: ${{ steps.prepare.outputs.update_sha }} + env: + GOPRIVATE: github.com/kernel/* + GO_VERSION_FILE: ${{ inputs.go-version-file }} steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.ADMIN_APP_ID }} - private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} - - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - name: Download scan results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: container-scan-report + path: ${{ runner.temp }}/container-scan - name: Download triage results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: container-triage-result + path: ${{ runner.temp }}/container-scan continue-on-error: true - - name: Configure git identity + - name: Reset remediation branch to main + id: prepare run: | - git config user.name "kernel-internal[bot]" - git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" - git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" - env: - GOPRIVATE: github.com/kernel/* + echo "main_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + echo "update_sha=$(git rev-parse -q --verify refs/remotes/origin/security/container-remediation || true)" >> "$GITHUB_OUTPUT" + git checkout -B security/container-remediation origin/main - - name: Reset remediation branch to main - run: git checkout -B security/container-remediation origin/main + - name: Fetch fix prompt and path allowlist + env: + REF: ${{ inputs.security-workflows-ref }} + run: | + base="https://raw.githubusercontent.com/kernel/security-workflows/$REF/.github/workflows/container-scan" + curl -fsSL "$base/fix-prompt.md" -o "$RUNNER_TEMP/fix-prompt.md" + curl -fsSL "$base/disallowed-paths.sh" -o "$RUNNER_TEMP/disallowed-paths.sh" - name: Setup Go if: inputs.go-version-file != '' - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: ${{ inputs.go-version-file }} + # The agent can write to the module cache, so this job must not save it. + cache: false + + - name: Find private Go modules + id: go-private + if: inputs.go-version-file != '' + run: | + repos=$(go mod edit -json "$GO_VERSION_FILE" | jq -r ' + [.Require[]?.Path, .Replace[]?.New.Path] + | map(select(startswith("github.com/kernel/")) | split("/")[2]) + | unique | join(",")') + echo "repos=$repos" >> "$GITHUB_OUTPUT" + + - name: Generate Go module read token + id: module-token + if: steps.go-private.outputs.repos != '' + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + owner: kernel + repositories: ${{ steps.go-private.outputs.repos }} + permission-contents: read + + # Private modules are fetched before the agent runs; the token is revoked + # here, so the agent only sees what is already in the module cache. + - name: Download Go modules + if: inputs.go-version-file != '' env: - GOPRIVATE: github.com/kernel/* + GH_TOKEN: ${{ steps.module-token.outputs.token }} + run: | + if [[ -n "$GH_TOKEN" ]]; then + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="url.https://x-access-token:$GH_TOKEN@github.com/kernel/.insteadOf" + export GIT_CONFIG_VALUE_0=https://github.com/kernel/ + fi + (cd "$(dirname "$GO_VERSION_FILE")" && go mod download) + if [[ -n "$GH_TOKEN" ]]; then + gh api --method DELETE /installation/token + fi + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' - - name: Install Claude Code CLI + - name: Install Pi + env: + PI_TELEMETRY: '0' run: | - curl -fsSL https://claude.ai/install.sh | bash - echo "$HOME/.local/bin" >> $GITHUB_PATH + npm install -g --ignore-scripts @earendil-works/pi-coding-agent + pi --version - name: Apply fixes - timeout-minutes: 8 + timeout-minutes: 20 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOPRIVATE: github.com/kernel/* + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }} + PI_SKIP_VERSION_CHECK: '1' + PI_TELEMETRY: '0' GO_MODULE_DIR: ${{ inputs.go-version-file != '' && format('{0}', inputs.go-version-file) || '.' }} + SCAN_DIR: ${{ runner.temp }}/container-scan run: | - export DATE="$(date -u +%Y-%m-%d)" - export GO_MODULE_DIR="$(dirname "$GO_MODULE_DIR")" - curl -fsSL "https://raw.githubusercontent.com/kernel/security-workflows/${{ inputs.security-workflows-ref }}/.github/workflows/container-scan/fix-prompt.md" \ - | envsubst '${GITHUB_REPOSITORY} ${DATE} ${GO_MODULE_DIR}' \ - | claude -p --model ${{ vars.CLAUDE_CODE_PREFERRED_MODEL }} --dangerously-skip-permissions --output-format text --verbose - - - name: Check for changes - id: diff + : "${PI_PREFERRED_MODEL:?Set the PI_PREFERRED_MODEL organization variable}" + DATE="$(date -u +%Y-%m-%d)" + GO_MODULE_DIR="$(dirname "$GO_MODULE_DIR")" + export DATE GO_MODULE_DIR + envsubst '${GITHUB_REPOSITORY} ${DATE} ${GO_MODULE_DIR} ${SCAN_DIR}' \ + < "$RUNNER_TEMP/fix-prompt.md" > "$RUNNER_TEMP/fix-prompt.rendered.md" + pi --print --no-session --approve \ + --model "$PI_PREFERRED_MODEL" --thinking max \ + @"$RUNNER_TEMP/fix-prompt.rendered.md" + + - name: Snapshot publishable changes + id: snapshot + env: + AGENT_BASE: ${{ steps.prepare.outputs.main_sha }} run: | - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" + git reset -q --soft "$AGENT_BASE" + git add -A + git diff --cached --name-only --no-renames -z \ + | bash "$RUNNER_TEMP/disallowed-paths.sh" > "$RUNNER_TEMP/excluded-paths" + if [[ -s "$RUNNER_TEMP/excluded-paths" ]]; then + echo "::warning::Discarding changes outside the container remediation allowlist:" + tr '\0' '\n' < "$RUNNER_TEMP/excluded-paths" + GIT_LITERAL_PATHSPECS=1 git reset -q --pathspec-from-file="$RUNNER_TEMP/excluded-paths" --pathspec-file-nul fi + git diff --cached --binary --no-renames --no-ext-diff --no-textconv > "$RUNNER_TEMP/changes.patch" + git reset -q --hard + git clean -fdq + if [[ -s "$RUNNER_TEMP/changes.patch" ]]; then + git apply --index "$RUNNER_TEMP/changes.patch" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Verify changes + if: steps.snapshot.outputs.changed == 'true' + run: | + git diff --cached --check + mapfile -t dirs < <(git diff --cached --name-only -- '*go.mod' '*go.sum' | xargs -r -n1 dirname | sort -u) + for dir in "${dirs[@]}"; do + (cd "$dir" && go build ./...) + done + + - name: Upload validated changes + if: steps.snapshot.outputs.changed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: container-remediation-patch + path: ${{ runner.temp }}/changes.patch + if-no-files-found: error + retention-days: 7 + + publish: + needs: fix + if: needs.fix.outputs.changed == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 10 + env: + BRANCH: security/container-remediation + MAIN_SHA: ${{ needs.fix.outputs.main_sha }} + UPDATE_SHA: ${{ needs.fix.outputs.update_sha }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download validated changes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: container-remediation-patch + path: ${{ runner.temp }}/container-remediation - - name: Commit and push - if: steps.diff.outputs.has_changes == 'true' + - name: Fetch path allowlist + env: + REF: ${{ inputs.security-workflows-ref }} run: | - git add --all -- '*.mod' '*.sum' '*Dockerfile*' - git diff --cached --quiet && { echo "No relevant changes to commit"; exit 0; } - git commit -m "security: container vulnerability remediation ($(date -u +%Y-%m-%d))" - git push --force-with-lease origin security/container-remediation + curl -fsSL "https://raw.githubusercontent.com/kernel/security-workflows/$REF/.github/workflows/container-scan/disallowed-paths.sh" \ + -o "$RUNNER_TEMP/disallowed-paths.sh" + + - name: Apply validated changes + run: | + git config user.name "kernel-internal[bot]" + git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" + git switch -C "$BRANCH" "$MAIN_SHA" + git apply --index "$RUNNER_TEMP/container-remediation/changes.patch" + git diff --cached --name-only --no-renames -z \ + | bash "$RUNNER_TEMP/disallowed-paths.sh" > "$RUNNER_TEMP/disallowed-paths" + if [[ -s "$RUNNER_TEMP/disallowed-paths" ]]; then + echo "::error::Validated changes include paths outside the container remediation allowlist:" + tr '\0' '\n' < "$RUNNER_TEMP/disallowed-paths" + exit 1 + fi + if git diff --cached --raw --no-renames | awk '$2 !~ /^(100644|100755|000000)$/ { found = 1 } END { exit !found }'; then + echo "::error::Validated changes may only contain regular files." + exit 1 + fi + # A replace directive can swap a dependency for any other source. + if git diff --cached -U0 --no-ext-diff -- '*go.mod' | grep -v '^+++' | grep -q '^+.*=>'; then + echo "::error::Validated changes may not add replace directives." + exit 1 + fi + git commit -q -m "security: container vulnerability remediation ($(date -u +%Y-%m-%d))" + + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - name: Create or update PR - if: steps.diff.outputs.has_changes == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + IMAGE_REF: ${{ inputs.image-ref }} run: | + gh auth setup-git + git push --force-with-lease="$BRANCH:$UPDATE_SHA" origin "$BRANCH" + REPO="${{ github.repository }}" - EXISTING_PR=$(gh pr list --repo "$REPO" --head security/container-remediation --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") BODY="## Container Vulnerability Remediation Automated fixes for container image vulnerabilities detected by Trivy. - **Image:** \`${{ inputs.image-ref }}\` + **Image:** \`${IMAGE_REF}\` **Date:** $(date -u +%Y-%m-%d) See workflow run for scan results and triage details." @@ -230,7 +401,7 @@ jobs: else gh pr create \ --repo "$REPO" \ - --head security/container-remediation \ + --head "$BRANCH" \ --base main \ --title "security: container vulnerability remediation" \ --assignee ulziibay-kernel \ diff --git a/.github/workflows/container-scan/disallowed-paths.sh b/.github/workflows/container-scan/disallowed-paths.sh new file mode 100644 index 0000000..66acb2f --- /dev/null +++ b/.github/workflows/container-scan/disallowed-paths.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Reads NUL-delimited paths on stdin and prints, NUL-delimited, the ones the +# container remediation workflow must not publish. +set -euo pipefail + +while IFS= read -r -d '' path; do + case "${path##*/}" in + go.mod | go.sum | Dockerfile | Dockerfile.* | *.Dockerfile) continue ;; + esac + printf '%s\0' "$path" +done diff --git a/.github/workflows/container-scan/fix-prompt.md b/.github/workflows/container-scan/fix-prompt.md index 5fe1a43..01ff0ca 100644 --- a/.github/workflows/container-scan/fix-prompt.md +++ b/.github/workflows/container-scan/fix-prompt.md @@ -6,15 +6,16 @@ You are a security engineer applying automated fixes for container image vulnera - Date: ${DATE} - Go module path: ${GO_MODULE_DIR} - You are on branch `security/container-remediation` which is reset to `origin/main` -- trivy-results.json contains the raw scan findings -- triage-result.json (if present) contains the LLM triage output with actionable items +- ${SCAN_DIR}/trivy-results.json contains the raw scan findings +- ${SCAN_DIR}/triage-result.json (if present) contains the LLM triage output with actionable items +- You have no GitHub credentials. Private `github.com/kernel/*` Go modules are preloaded in the module cache, so new versions of them cannot be downloaded # Task Apply dependency bumps to fix actionable container vulnerabilities. Focus on changes that are safe, minimal, and unlikely to break the build. -1. Read triage-result.json if it exists. If not, read trivy-results.json directly and +1. Read ${SCAN_DIR}/triage-result.json if it exists. If not, read ${SCAN_DIR}/trivy-results.json directly and identify Go dependency bumps with available fixes in the `api` binary target. 2. For each actionable finding with fix_type `go_dep`: @@ -35,7 +36,7 @@ that are safe, minimal, and unlikely to break the build. - Do NOT modify test files, scripts, or configuration - Do NOT add comments explaining what you changed - If no fixes can be safely applied, exit cleanly with no changes -- Do NOT commit — the workflow handles committing +- Do NOT commit or push — the workflow validates and publishes your changes - Apply ALL actionable fixes from the triage (no artificial limit) - Group fixes by priority: apply all P0 fixes first, then P1, then P2 - If any P0 fix breaks the build, stop and report — do not continue to P1/P2 diff --git a/.github/workflows/container-scan/triage-prompt.md b/.github/workflows/container-scan/triage-prompt.md index 005faf6..4740f8b 100644 --- a/.github/workflows/container-scan/triage-prompt.md +++ b/.github/workflows/container-scan/triage-prompt.md @@ -11,7 +11,7 @@ You are a security engineer triaging container image vulnerability scan results Prioritize findings by exploitability and impact, not just severity score. Use this order: -1. **Known Exploited (KEV)**: Check if the CVE is in the CISA Known Exploited Vulnerabilities catalog (https://www.cisa.gov/known-exploited-vulnerabilities-catalog). These get highest priority regardless of severity score. +1. **Known Exploited (KEV)**: Check if the CVE is in the CISA Known Exploited Vulnerabilities catalog. Download it with `curl -fsSL https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json` and search it for the CVE IDs. These get highest priority regardless of severity score. 2. **Reachable + Severe**: The vulnerable package is compiled into our application binary (`api` target) AND has Critical/High severity. These are reachable by definition — if a Go package appears in our compiled binary, its code is linked and callable. diff --git a/scripts/test_container_remediation.py b/scripts/test_container_remediation.py new file mode 100644 index 0000000..98c53a5 --- /dev/null +++ b/scripts/test_container_remediation.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/container-remediation.yml" +DISALLOWED_PATHS = ROOT / ".github/workflows/container-scan/disallowed-paths.sh" +TOKEN_REF = re.compile(r"GH_TOKEN|GITHUB_TOKEN|steps\.[\w-]+\.outputs\.token|secrets\.ADMIN_APP") + + +def git(cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout + + +def disallowed(paths): + out = subprocess.run( + ["bash", str(DISALLOWED_PATHS)], input="".join(f"{p}\0" for p in paths), + check=True, capture_output=True, text=True, + ).stdout + return [p for p in out.split("\0") if p] + + +class ContainerRemediationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workflow = yaml.safe_load(WORKFLOW.read_text()) + cls.jobs = cls.workflow["jobs"] + + def steps(self, job): + return self.jobs[job]["steps"] + + def step(self, job, name): + return next(s for s in self.steps(job) if s.get("name") == name) + + def index(self, job, name): + return self.steps(job).index(self.step(job, name)) + + def test_actions_are_pinned_and_checkouts_do_not_persist_credentials(self): + for job in self.jobs.values(): + for step in job["steps"]: + if "uses" not in step: + continue + self.assertRegex(step["uses"], r"^[\w.-]+/[\w.-]+@[0-9a-f]{40}$") + if step["uses"].startswith("actions/checkout@"): + self.assertIs(step["with"]["persist-credentials"], False) + self.assertNotIn("token", step["with"]) + + def test_agent_jobs_have_read_only_credentials(self): + for job, agent in (("triage", "Triage findings"), ("fix", "Apply fixes")): + self.assertEqual(self.jobs[job]["permissions"], {"contents": "read"}) + step = self.step(job, agent) + self.assertIn("pi --print --no-session --approve", step["run"]) + self.assertIn('--model "$PI_PREFERRED_MODEL"', step["run"]) + self.assertNotRegex(yaml.safe_dump(step), TOKEN_REF) + self.assertEqual(step["env"]["PI_PREFERRED_MODEL"], "${{ vars.PI_PREFERRED_MODEL }}") + for job in ("triage", "fix"): + for step in self.steps(job): + self.assertNotIn("claude", yaml.safe_dump(step)) + self.assertNotRegex(yaml.safe_dump(step.get("with", {})), r"permission-[\w-]+: write") + + def test_module_token_is_read_only_and_revoked_before_the_agent(self): + mint = self.step("fix", "Generate Go module read token") + self.assertEqual(mint["with"]["permission-contents"], "read") + self.assertEqual( + {k for k in mint["with"] if k.startswith("permission-")}, {"permission-contents"}) + download = self.step("fix", "Download Go modules") + self.assertEqual(download["env"]["GH_TOKEN"], "${{ steps.module-token.outputs.token }}") + self.assertRegex(download["run"], r"gh api --method DELETE /installation/token\n\s*fi\n$") + self.assertNotIn("git config", download["run"]) + self.assertLess(self.index("fix", "Download Go modules"), self.index("fix", "Apply fixes")) + self.assertIs(self.step("fix", "Setup Go")["with"]["cache"], False) + for step in self.steps("fix")[self.index("fix", "Download Go modules") + 1:]: + self.assertNotRegex(yaml.safe_dump(step), TOKEN_REF) + + def test_fix_snapshots_and_validates_after_the_agent(self): + order = ["Apply fixes", "Snapshot publishable changes", "Verify changes", "Upload validated changes"] + self.assertEqual([self.index("fix", n) for n in order], sorted(self.index("fix", n) for n in order)) + snapshot = self.step("fix", "Snapshot publishable changes")["run"] + self.assertIn('git reset -q --soft "$AGENT_BASE"\ngit add -A', snapshot) + self.assertIn("git reset -q --hard\ngit clean -fdq", snapshot) + self.assertIn("go build ./...", self.step("fix", "Verify changes")["run"]) + + def test_publish_never_runs_the_agent_or_repository_code(self): + publish = self.jobs["publish"] + self.assertEqual(publish["needs"], "fix") + self.assertEqual(publish["permissions"], {"contents": "read"}) + runs = "".join(s.get("run", "") for s in publish["steps"]) + self.assertNotRegex(runs, r"(?m)^\s*(go|pi|npm|npx|node|make|bash -c)\b") + dump = yaml.safe_dump(publish) + self.assertNotRegex(dump, r"ANTHROPIC|OPENAI|continue-on-error|always\(\)") + mint = self.step("publish", "Generate app token") + self.assertEqual( + {k: v for k, v in mint["with"].items() if k.startswith("permission-")}, + {"permission-contents": "write", "permission-pull-requests": "write"}) + self.assertLess(self.index("publish", "Apply validated changes"), self.index("publish", "Generate app token")) + for step in self.steps("publish")[:self.index("publish", "Generate app token")]: + self.assertNotRegex(yaml.safe_dump(step), TOKEN_REF) + push = self.step("publish", "Create or update PR")["run"] + self.assertIn('git push --force-with-lease="$BRANCH:$UPDATE_SHA" origin "$BRANCH"', push) + + def test_allowlist(self): + self.assertEqual( + disallowed([ + "go.mod", "go.sum", "packages/api/go.mod", "packages/api/go.sum", + "Dockerfile", "packages/api/Dockerfile", "Dockerfile.dev", "packages/x/ecs.Dockerfile", + "packages/api/main.go", "trivy-results.json", ".github/workflows/ci.yml", + "packages/api/lib/dockerfiles/templates.go", "go.mod.bak", "scripts/Dockerfile.sh/run", + ]), + ["packages/api/main.go", "trivy-results.json", ".github/workflows/ci.yml", + "packages/api/lib/dockerfiles/templates.go", "go.mod.bak", "scripts/Dockerfile.sh/run"]) + + +class ShellStepTests(unittest.TestCase): + """Runs the workflow's real snapshot and publish shell against local repos.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.temp = self.root / "runner-temp" + (self.temp / "container-remediation").mkdir(parents=True) + (self.temp / "disallowed-paths.sh").write_text(DISALLOWED_PATHS.read_text()) + self.repo = self.root / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q", "-b", "main") + git(self.repo, "config", "user.email", "test@example.com") + git(self.repo, "config", "user.name", "Test") + (self.repo / "api").mkdir() + (self.repo / "api/go.mod").write_text("module example.com/api\n\ngo 1.22\n\nrequire golang.org/x/net v0.1.0\n") + (self.repo / "api/main.go").write_text("package main\n\nfunc main() {}\n") + (self.repo / "Dockerfile").write_text("FROM alpine:3.20.0\n") + git(self.repo, "add", "-A") + git(self.repo, "commit", "-q", "-m", "base") + self.base = git(self.repo, "rev-parse", "HEAD").strip() + self.jobs = yaml.safe_load(WORKFLOW.read_text())["jobs"] + + def tearDown(self): + self.tmp.cleanup() + + def run_step(self, job, name, **env): + script = next(s["run"] for s in self.jobs[job]["steps"] if s.get("name") == name) + output = self.root / "output" + output.write_text("") + result = subprocess.run( + ["bash", "-e", "-c", script], cwd=self.repo, capture_output=True, text=True, + env={**os.environ, "RUNNER_TEMP": str(self.temp), "GITHUB_OUTPUT": str(output), **env}, + ) + return result, output.read_text() + + def bump_go_mod(self): + (self.repo / "api/go.mod").write_text("module example.com/api\n\ngo 1.22\n\nrequire golang.org/x/net v0.2.0\n") + + def test_snapshot_keeps_only_allowlisted_changes(self): + self.bump_go_mod() + (self.repo / "Dockerfile").write_text("FROM alpine:3.20.1\n") + (self.repo / "api/main.go").write_text("package main\n\nfunc main() { panic(1) }\n") + (self.repo / "evil.sh").write_text("curl example.com\n") + git(self.repo, "add", "evil.sh") + git(self.repo, "commit", "-q", "-m", "agent commit") + result, output = self.run_step("fix", "Snapshot publishable changes", AGENT_BASE=self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("changed=true", output) + patch = (self.temp / "changes.patch").read_text() + self.assertIn("api/go.mod", patch) + self.assertIn("Dockerfile", patch) + self.assertNotIn("main.go", patch) + self.assertNotIn("evil.sh", patch) + self.assertEqual(git(self.repo, "rev-parse", "HEAD").strip(), self.base) + self.assertFalse((self.repo / "evil.sh").exists()) + self.assertEqual(sorted(git(self.repo, "diff", "--cached", "--name-only").split()), ["Dockerfile", "api/go.mod"]) + + def test_snapshot_without_publishable_changes(self): + (self.repo / "api/main.go").write_text("package main\n") + result, output = self.run_step("fix", "Snapshot publishable changes", AGENT_BASE=self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("changed=true", output) + + def publish_patch(self, mutate): + mutate() + git(self.repo, "add", "-A") + patch = git(self.repo, "diff", "--cached", "--binary", "--no-renames") + git(self.repo, "reset", "-q", "--hard") + git(self.repo, "clean", "-fdq") + (self.temp / "container-remediation/changes.patch").write_text(patch) + return self.run_step("publish", "Apply validated changes", MAIN_SHA=self.base, BRANCH="security/container-remediation") + + def test_publish_commits_allowlisted_patch(self): + result, _ = self.publish_patch(self.bump_go_mod) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(git(self.repo, "branch", "--show-current").strip(), "security/container-remediation") + self.assertEqual(git(self.repo, "show", "--name-only", "--format=%an", "HEAD").split(), ["kernel-internal[bot]", "api/go.mod"]) + + def test_publish_rejects_disallowed_paths(self): + result, _ = self.publish_patch(lambda: (self.repo / "api/main.go").write_text("package main\n")) + self.assertNotEqual(result.returncode, 0) + self.assertIn("outside the container remediation allowlist", result.stdout) + + def test_publish_rejects_symlinks(self): + result, _ = self.publish_patch(lambda: (self.repo / "api/go.sum").symlink_to("/etc/passwd")) + self.assertNotEqual(result.returncode, 0) + self.assertIn("only contain regular files", result.stdout) + + def test_publish_rejects_replace_directives(self): + for replace in ("replace golang.org/x/net => ../net\n", + "replace (\n\tgolang.org/x/net => example.com/fork v0.2.0\n)\n"): + with self.subTest(replace=replace): + go_mod = self.repo / "api/go.mod" + result, _ = self.publish_patch(lambda: go_mod.write_text(go_mod.read_text() + replace)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("may not add replace directives", result.stdout) + git(self.repo, "switch", "-q", "--detach", self.base) + + +if __name__ == "__main__": + unittest.main() From 17a32d28126582c3ee4ebb004f659c578d36c1db Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:47:19 +0000 Subject: [PATCH 2/4] Scope the publish token to the calling repository --- .github/workflows/container-remediation.yml | 5 +++++ scripts/test_container_remediation.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/container-remediation.yml b/.github/workflows/container-remediation.yml index b6a25f0..5cbf1d8 100644 --- a/.github/workflows/container-remediation.yml +++ b/.github/workflows/container-remediation.yml @@ -343,6 +343,7 @@ jobs: -o "$RUNNER_TEMP/disallowed-paths.sh" - name: Apply validated changes + id: apply run: | git config user.name "kernel-internal[bot]" git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" @@ -365,6 +366,8 @@ jobs: exit 1 fi git commit -q -m "security: container vulnerability remediation ($(date -u +%Y-%m-%d))" + # Scheduled runs have no event payload, so github.event.repository is empty. + echo "repo=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT" - name: Generate app token id: app-token @@ -372,6 +375,8 @@ jobs: with: app-id: ${{ secrets.ADMIN_APP_ID }} private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.apply.outputs.repo }} permission-contents: write permission-pull-requests: write diff --git a/scripts/test_container_remediation.py b/scripts/test_container_remediation.py index 98c53a5..a72d4e4 100644 --- a/scripts/test_container_remediation.py +++ b/scripts/test_container_remediation.py @@ -99,6 +99,12 @@ def test_publish_never_runs_the_agent_or_repository_code(self): self.assertEqual( {k: v for k, v in mint["with"].items() if k.startswith("permission-")}, {"permission-contents": "write", "permission-pull-requests": "write"}) + self.assertEqual(mint["with"]["owner"], "${{ github.repository_owner }}") + # Not github.event.repository.name: schedule events have no payload, and an + # empty list with an owner mints a token for every repo the app can reach. + self.assertEqual(mint["with"]["repositories"], "${{ steps.apply.outputs.repo }}") + self.assertIn('echo "repo=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT"', + self.step("publish", "Apply validated changes")["run"]) self.assertLess(self.index("publish", "Apply validated changes"), self.index("publish", "Generate app token")) for step in self.steps("publish")[:self.index("publish", "Generate app token")]: self.assertNotRegex(yaml.safe_dump(step), TOKEN_REF) @@ -188,11 +194,14 @@ def publish_patch(self, mutate): git(self.repo, "reset", "-q", "--hard") git(self.repo, "clean", "-fdq") (self.temp / "container-remediation/changes.patch").write_text(patch) - return self.run_step("publish", "Apply validated changes", MAIN_SHA=self.base, BRANCH="security/container-remediation") + return self.run_step( + "publish", "Apply validated changes", + MAIN_SHA=self.base, BRANCH="security/container-remediation", GITHUB_REPOSITORY="kernel/kernel") def test_publish_commits_allowlisted_patch(self): - result, _ = self.publish_patch(self.bump_go_mod) + result, output = self.publish_patch(self.bump_go_mod) self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "repo=kernel\n") self.assertEqual(git(self.repo, "branch", "--show-current").strip(), "security/container-remediation") self.assertEqual(git(self.repo, "show", "--name-only", "--format=%an", "HEAD").split(), ["kernel-internal[bot]", "api/go.mod"]) From 323f8e6a70c4848f959dcdbfabff2cbe9b794eee Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:49:30 +0000 Subject: [PATCH 3/4] Fetch private Go modules in a job without the agent --- .github/workflows/container-remediation.yml | 127 ++++++++++++++------ scripts/test_container_remediation.py | 92 ++++++++++++-- 2 files changed, 170 insertions(+), 49 deletions(-) diff --git a/.github/workflows/container-remediation.yml b/.github/workflows/container-remediation.yml index 5cbf1d8..4b1ee6a 100644 --- a/.github/workflows/container-remediation.yml +++ b/.github/workflows/container-remediation.yml @@ -7,7 +7,8 @@ name: Container Vulnerability Remediation # Modeled after vuln-remediation.yml (Socket SCA), but for container-level # findings (OS packages + compiled-in Go deps). # -# The agent never holds GitHub write credentials. The fix job runs Pi, keeps +# The agent never holds GitHub credentials. The go-modules job fetches private +# Go modules ahead of it. The fix job runs Pi with only the model API keys, keeps # only go.mod, go.sum, and Dockerfile changes, and builds them. The publish job # re-checks that patch and only then mints the token that pushes and opens the PR. @@ -144,8 +145,82 @@ jobs: path: triage-result.json if: always() + # Every secret a job references stays readable in runner memory for the whole + # job, so private modules are fetched here and the agent's job only gets the + # model API keys. + go-modules: + needs: scan + if: needs.scan.outputs.has-findings == 'true' && inputs.go-version-file != '' + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 15 + outputs: + private-modules: ${{ steps.go-private.outputs.repos != '' }} + env: + GOPRIVATE: github.com/kernel/* + GO_VERSION_FILE: ${{ inputs.go-version-file }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: ${{ inputs.go-version-file }} + # Private module sources must not land in the repository's Actions cache. + cache: false + + - name: Find private Go modules + id: go-private + run: | + repos=$(go mod edit -json "$GO_VERSION_FILE" | jq -r ' + [.Require[]?.Path, .Replace[]?.New.Path] + | map(select(startswith("github.com/kernel/")) | split("/")[2]) + | unique | join(",")') + echo "repos=$repos" >> "$GITHUB_OUTPUT" + + - name: Generate Go module read token + id: module-token + if: steps.go-private.outputs.repos != '' + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + owner: kernel + repositories: ${{ steps.go-private.outputs.repos }} + permission-contents: read + + - name: Download private Go modules + id: download + if: steps.go-private.outputs.repos != '' + env: + GH_TOKEN: ${{ steps.module-token.outputs.token }} + run: | + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="url.https://x-access-token:$GH_TOKEN@github.com/kernel/.insteadOf" + export GIT_CONFIG_VALUE_0=https://github.com/kernel/ + (cd "$(dirname "$GO_VERSION_FILE")" && go mod download) + gh api --method DELETE /installation/token + echo "dir=$(go env GOMODCACHE)/cache/download/github.com/kernel" >> "$GITHUB_OUTPUT" + + - name: Upload private Go modules + if: steps.go-private.outputs.repos != '' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: container-remediation-go-modules + path: ${{ steps.download.outputs.dir }} + if-no-files-found: error + retention-days: 1 + fix: - needs: triage + needs: [triage, go-modules] + # go-modules is skipped when the caller has no Go module. + if: >- + !cancelled() && needs.triage.result == 'success' && + (needs.go-modules.result == 'success' || needs.go-modules.result == 'skipped') runs-on: ubuntu-24.04 permissions: contents: read @@ -156,7 +231,6 @@ jobs: update_sha: ${{ steps.prepare.outputs.update_sha }} env: GOPRIVATE: github.com/kernel/* - GO_VERSION_FILE: ${{ inputs.go-version-file }} steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -200,43 +274,19 @@ jobs: # The agent can write to the module cache, so this job must not save it. cache: false - - name: Find private Go modules - id: go-private - if: inputs.go-version-file != '' - run: | - repos=$(go mod edit -json "$GO_VERSION_FILE" | jq -r ' - [.Require[]?.Path, .Replace[]?.New.Path] - | map(select(startswith("github.com/kernel/")) | split("/")[2]) - | unique | join(",")') - echo "repos=$repos" >> "$GITHUB_OUTPUT" - - - name: Generate Go module read token - id: module-token - if: steps.go-private.outputs.repos != '' - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + - name: Download private Go modules + if: needs.go-modules.outputs.private-modules == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - app-id: ${{ secrets.ADMIN_APP_ID }} - private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} - owner: kernel - repositories: ${{ steps.go-private.outputs.repos }} - permission-contents: read + name: container-remediation-go-modules + path: ${{ runner.temp }}/go-modules - # Private modules are fetched before the agent runs; the token is revoked - # here, so the agent only sees what is already in the module cache. - - name: Download Go modules - if: inputs.go-version-file != '' - env: - GH_TOKEN: ${{ steps.module-token.outputs.token }} + - name: Add private Go modules to the module cache + if: needs.go-modules.outputs.private-modules == 'true' run: | - if [[ -n "$GH_TOKEN" ]]; then - export GIT_CONFIG_COUNT=1 - export GIT_CONFIG_KEY_0="url.https://x-access-token:$GH_TOKEN@github.com/kernel/.insteadOf" - export GIT_CONFIG_VALUE_0=https://github.com/kernel/ - fi - (cd "$(dirname "$GO_VERSION_FILE")" && go mod download) - if [[ -n "$GH_TOKEN" ]]; then - gh api --method DELETE /installation/token - fi + dir="$(go env GOMODCACHE)/cache/download/github.com/kernel" + mkdir -p "$dir" + cp -R "$RUNNER_TEMP/go-modules/." "$dir/" - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -313,7 +363,8 @@ jobs: publish: needs: fix - if: needs.fix.outputs.changed == 'true' + # A skipped go-modules job would otherwise skip this job too. + if: "!cancelled() && needs.fix.result == 'success' && needs.fix.outputs.changed == 'true'" runs-on: ubuntu-24.04 permissions: contents: read diff --git a/scripts/test_container_remediation.py b/scripts/test_container_remediation.py index a72d4e4..5403617 100644 --- a/scripts/test_container_remediation.py +++ b/scripts/test_container_remediation.py @@ -65,19 +65,39 @@ def test_agent_jobs_have_read_only_credentials(self): self.assertNotIn("claude", yaml.safe_dump(step)) self.assertNotRegex(yaml.safe_dump(step.get("with", {})), r"permission-[\w-]+: write") - def test_module_token_is_read_only_and_revoked_before_the_agent(self): - mint = self.step("fix", "Generate Go module read token") - self.assertEqual(mint["with"]["permission-contents"], "read") + def test_agent_jobs_reference_only_model_secrets(self): + # A job's secrets stay in runner memory for the whole job, where an agent + # with sudo can read them, so revoking a minted token is not enough. + agent_jobs = { + name for name, job in self.jobs.items() + if any("pi --print" in s.get("run", "") for s in job["steps"]) + } + self.assertEqual(agent_jobs, {"triage", "fix"}) + for name in agent_jobs: + refs = re.findall(r"\bsecrets\b(?:\.(\w+))?", yaml.safe_dump(self.jobs[name])) + self.assertLessEqual(set(refs), {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}, name) + + def test_private_modules_are_fetched_in_a_separate_job(self): + mint = self.step("go-modules", "Generate Go module read token") self.assertEqual( - {k for k in mint["with"] if k.startswith("permission-")}, {"permission-contents"}) - download = self.step("fix", "Download Go modules") + {k: v for k, v in mint["with"].items() if k.startswith("permission-")}, + {"permission-contents": "read"}) + self.assertEqual(mint["with"]["repositories"], "${{ steps.go-private.outputs.repos }}") + download = self.step("go-modules", "Download private Go modules") self.assertEqual(download["env"]["GH_TOKEN"], "${{ steps.module-token.outputs.token }}") - self.assertRegex(download["run"], r"gh api --method DELETE /installation/token\n\s*fi\n$") - self.assertNotIn("git config", download["run"]) - self.assertLess(self.index("fix", "Download Go modules"), self.index("fix", "Apply fixes")) - self.assertIs(self.step("fix", "Setup Go")["with"]["cache"], False) - for step in self.steps("fix")[self.index("fix", "Download Go modules") + 1:]: - self.assertNotRegex(yaml.safe_dump(step), TOKEN_REF) + upload = self.step("go-modules", "Upload private Go modules") + self.assertEqual(upload["with"]["path"], "${{ steps.download.outputs.dir }}") + self.assertEqual(upload["with"]["retention-days"], 1) + for job in ("go-modules", "fix"): + self.assertIs(self.step(job, "Setup Go")["with"]["cache"], False) + self.assertEqual(self.jobs["fix"]["needs"], ["triage", "go-modules"]) + self.assertIn("needs.go-modules.result == 'skipped'", self.jobs["fix"]["if"]) + fetch = self.step("fix", "Download private Go modules") + self.assertEqual(fetch["with"]["name"], upload["with"]["name"]) + self.assertLess( + self.index("fix", "Add private Go modules to the module cache"), self.index("fix", "Apply fixes")) + # Default success() also skips this job whenever go-modules was skipped. + self.assertIn("!cancelled() && needs.fix.result == 'success'", self.jobs["publish"]["if"]) def test_fix_snapshots_and_validates_after_the_agent(self): order = ["Apply fixes", "Snapshot publishable changes", "Verify changes", "Upload validated changes"] @@ -181,6 +201,45 @@ def test_snapshot_keeps_only_allowlisted_changes(self): self.assertFalse((self.repo / "evil.sh").exists()) self.assertEqual(sorted(git(self.repo, "diff", "--cached", "--name-only").split()), ["Dockerfile", "api/go.mod"]) + def stub(self, name, body): + bin_dir = self.root / "bin" + bin_dir.mkdir(exist_ok=True) + (bin_dir / name).write_text("#!/usr/bin/env bash\n" + body) + (bin_dir / name).chmod(0o755) + return f"{bin_dir}:{os.environ['PATH']}" + + def test_module_download_revokes_its_token_and_exports_only_private_modules(self): + log = self.root / "calls" + self.stub("gh", f'echo "gh $*" >> {log}\n') + path = self.stub("go", f''' + case "$*" in + "env GOMODCACHE") echo "$GOMODCACHE" ;; + "mod download") echo "go mod download in $PWD with $GIT_CONFIG_KEY_0" >> {log} ;; + esac + ''') + result, output = self.run_step( + "go-modules", "Download private Go modules", PATH=path, GH_TOKEN="ghs_test", + GO_VERSION_FILE="api/go.mod", GOMODCACHE="/modcache") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), [ + f"go mod download in {self.repo}/api with url.https://x-access-token:ghs_test@github.com/kernel/.insteadOf", + "gh api --method DELETE /installation/token", + ]) + self.assertEqual(output, "dir=/modcache/cache/download/github.com/kernel\n") + self.assertNotIn("ghs_test", git(self.repo, "config", "--list")) + + def test_fix_adds_private_modules_to_the_module_cache(self): + module = self.temp / "go-modules/hypeman-go/@v" + module.mkdir(parents=True) + (module / "v0.27.0.zip").write_text("zip") + path = self.stub("go", 'echo "$GOMODCACHE"\n') + cache = self.root / "modcache" + result, _ = self.run_step( + "fix", "Add private Go modules to the module cache", PATH=path, GOMODCACHE=str(cache)) + self.assertEqual(result.returncode, 0, result.stderr) + copied = cache / "cache/download/github.com/kernel/hypeman-go/@v/v0.27.0.zip" + self.assertEqual(copied.read_text(), "zip") + def test_snapshot_without_publishable_changes(self): (self.repo / "api/main.go").write_text("package main\n") result, output = self.run_step("fix", "Snapshot publishable changes", AGENT_BASE=self.base) @@ -193,6 +252,9 @@ def publish_patch(self, mutate): patch = git(self.repo, "diff", "--cached", "--binary", "--no-renames") git(self.repo, "reset", "-q", "--hard") git(self.repo, "clean", "-fdq") + return self.publish(patch) + + def publish(self, patch): (self.temp / "container-remediation/changes.patch").write_text(patch) return self.run_step( "publish", "Apply validated changes", @@ -210,6 +272,14 @@ def test_publish_rejects_disallowed_paths(self): self.assertNotEqual(result.returncode, 0) self.assertIn("outside the container remediation allowlist", result.stdout) + def test_publish_rejects_submodules(self): + result, _ = self.publish( + "diff --git a/api/go.sum b/api/go.sum\nnew file mode 160000\n" + f"index 0000000..{self.base}\n--- /dev/null\n+++ b/api/go.sum\n" + f"@@ -0,0 +1 @@\n+Subproject commit {self.base}\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn("only contain regular files", result.stdout) + def test_publish_rejects_symlinks(self): result, _ = self.publish_patch(lambda: (self.repo / "api/go.sum").symlink_to("/etc/passwd")) self.assertNotEqual(result.returncode, 0) From 722686bd394158306944784506db298c05bb2c71 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:59:18 +0000 Subject: [PATCH 4/4] Build fixes on the main commit private modules were fetched for --- .github/workflows/container-remediation.yml | 12 ++++++++++-- scripts/test_container_remediation.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/container-remediation.yml b/.github/workflows/container-remediation.yml index 4b1ee6a..fb021c5 100644 --- a/.github/workflows/container-remediation.yml +++ b/.github/workflows/container-remediation.yml @@ -157,13 +157,16 @@ jobs: timeout-minutes: 15 outputs: private-modules: ${{ steps.go-private.outputs.repos != '' }} + main_sha: ${{ steps.go-private.outputs.main_sha }} env: GOPRIVATE: github.com/kernel/* GO_VERSION_FILE: ${{ inputs.go-version-file }} steps: + # The fix job builds on this same main commit. - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: + ref: main persist-credentials: false - name: Setup Go @@ -181,6 +184,7 @@ jobs: | map(select(startswith("github.com/kernel/")) | split("/")[2]) | unique | join(",")') echo "repos=$repos" >> "$GITHUB_OUTPUT" + echo "main_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Generate Go module read token id: module-token @@ -253,10 +257,14 @@ jobs: - name: Reset remediation branch to main id: prepare + env: + MODULES_MAIN_SHA: ${{ needs.go-modules.outputs.main_sha }} run: | - echo "main_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + # Private modules were fetched for go-modules' main commit, which may trail origin/main. + main_sha=${MODULES_MAIN_SHA:-$(git rev-parse origin/main)} + echo "main_sha=$main_sha" >> "$GITHUB_OUTPUT" echo "update_sha=$(git rev-parse -q --verify refs/remotes/origin/security/container-remediation || true)" >> "$GITHUB_OUTPUT" - git checkout -B security/container-remediation origin/main + git checkout -B security/container-remediation "$main_sha" - name: Fetch fix prompt and path allowlist env: diff --git a/scripts/test_container_remediation.py b/scripts/test_container_remediation.py index 5403617..8d35ca9 100644 --- a/scripts/test_container_remediation.py +++ b/scripts/test_container_remediation.py @@ -92,6 +92,10 @@ def test_private_modules_are_fetched_in_a_separate_job(self): self.assertIs(self.step(job, "Setup Go")["with"]["cache"], False) self.assertEqual(self.jobs["fix"]["needs"], ["triage", "go-modules"]) self.assertIn("needs.go-modules.result == 'skipped'", self.jobs["fix"]["if"]) + self.assertEqual(self.step("go-modules", "Checkout repository")["with"]["ref"], "main") + self.assertEqual( + self.step("fix", "Reset remediation branch to main")["env"]["MODULES_MAIN_SHA"], + "${{ needs.go-modules.outputs.main_sha }}") fetch = self.step("fix", "Download private Go modules") self.assertEqual(fetch["with"]["name"], upload["with"]["name"]) self.assertLess( @@ -240,6 +244,19 @@ def test_fix_adds_private_modules_to_the_module_cache(self): copied = cache / "cache/download/github.com/kernel/hypeman-go/@v/v0.27.0.zip" self.assertEqual(copied.read_text(), "zip") + def test_fix_builds_on_the_commit_modules_were_fetched_for(self): + (self.repo / "Dockerfile").write_text("FROM alpine:3.21.0\n") + git(self.repo, "commit", "-qam", "main moved on") + git(self.repo, "update-ref", "refs/remotes/origin/main", "HEAD") + newer = git(self.repo, "rev-parse", "HEAD").strip() + for modules_sha, expected in ((self.base, self.base), ("", newer)): + with self.subTest(modules_sha=modules_sha): + result, output = self.run_step( + "fix", "Reset remediation branch to main", MODULES_MAIN_SHA=modules_sha) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, f"main_sha={expected}\nupdate_sha=\n") + self.assertEqual(git(self.repo, "rev-parse", "HEAD").strip(), expected) + def test_snapshot_without_publishable_changes(self): (self.repo / "api/main.go").write_text("package main\n") result, output = self.run_step("fix", "Snapshot publishable changes", AGENT_BASE=self.base)