From f9493b80a6bb7e95eda2ac1227f29aae92cf87bc Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Fri, 17 Oct 2025 17:11:20 -0600 Subject: [PATCH 01/29] ci: add update-pins tool and workflow Signed-off-by: Jacob Howard --- .github/workflows/update-pins.yaml | 136 +++++++++++++++++++++ Taskfile.yml | 4 + cmd/update-pins/main.go | 187 +++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .github/workflows/update-pins.yaml create mode 100644 cmd/update-pins/main.go diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml new file mode 100644 index 000000000..893e06d12 --- /dev/null +++ b/.github/workflows/update-pins.yaml @@ -0,0 +1,136 @@ +name: Update MCP Server Version Pins + +on: + schedule: + - cron: "0 5 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-pins: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git user + run: | + git config user.name "docker-mcp-bot" + git config user.email "docker-mcp-bot@users.noreply.github.com" + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update pinned commits + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + task update-pins + + - name: Collect per-server patches + id: prepare + run: | + # Gather the diff for each modified server YAML and store it as an + # individual patch file so we can open one PR per server. + mkdir -p patches + changed_files=$(git status --porcelain | awk '$2 ~ /^servers\/.*\/server.yaml$/ {print $2}') + if [ -z "$changed_files" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + server_list=() + for file in $changed_files; do + server=$(basename "$(dirname "$file")") + git diff -- "$file" > "patches/${server}.patch" + server_list+=("$server") + done + + # Reset the working tree so we can apply patches one-at-a-time. + git checkout -- servers + + # Expose the server list to later steps. + printf '%s\n' "${server_list[@]}" | paste -sd',' - > patches/servers.txt + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "servers=$(cat patches/servers.txt)" >> "$GITHUB_OUTPUT" + + - name: Create pull requests + if: steps.prepare.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IFS=',' read -ra SERVERS <<< "${{ steps.prepare.outputs.servers }}" + for server in "${SERVERS[@]}"; do + patch="patches/${server}.patch" + if [ ! -s "$patch" ]; then + echo "No patch found for $server, skipping." + continue + fi + + # Look up the new commit hash in the patch so we can decide whether + # an existing automation branch already covers it. + new_commit=$(awk '/^\+.*commit:/{print $2}' "$patch" | tail -n1) + branch="automation/update-pin-${server}" + + # Start from a clean copy of main for each server so branches do not + # interfere with one another. + git checkout main + git fetch origin main + git reset --hard origin/main + + # If a prior PR exists for this server, fetch it and bail out when + # the requested commit is identical (no update required). + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + git fetch origin "$branch" + existing_commit=$(git show "origin/${branch}:servers/${server}/server.yaml" 2>/dev/null | awk '/commit:/{print $2}' | tail -n1) + if [ -n "$existing_commit" ] && [ "$existing_commit" = "$new_commit" ]; then + echo "Existing PR for $server already pins ${existing_commit}; skipping." + continue + fi + fi + + # Apply the patch onto a fresh branch for this server. + git checkout -B "$branch" origin/main + if ! git apply "$patch"; then + echo "Failed to apply patch for $server, skipping." + continue + fi + + if git diff --quiet; then + echo "No changes after applying patch for $server, skipping." + continue + fi + + # Commit the server YAML change and force-push the automation branch. + git add "servers/${server}/server.yaml" + git commit -m "chore: update pin for ${server}" + git push --force origin "$branch" + + # Create or update the PR dedicated to this server. + if gh pr view --head "$branch" >/dev/null 2>&1; then + gh pr edit "$branch" \ + --title "chore: update pin for ${server}" \ + --body "Automated commit pin update for ${server}." + else + gh pr create \ + --title "chore: update pin for ${server}" \ + --body "Automated commit pin update for ${server}." \ + --base main \ + --head "$branch" + fi + done + + git checkout main diff --git a/Taskfile.yml b/Taskfile.yml index 6098d96fc..270a89f44 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,6 +5,10 @@ tasks: desc: Create a new mcp server definition cmd: go run ./cmd/create {{.CLI_ARGS}} + update-pins: + desc: Refresh server commit pins from upstream repositories + cmd: go run ./cmd/update-pins {{.CLI_ARGS}} + build: desc: Build a server image cmd: go run ./cmd/build {{.CLI_ARGS}} diff --git a/cmd/update-pins/main.go b/cmd/update-pins/main.go new file mode 100644 index 000000000..90aa69422 --- /dev/null +++ b/cmd/update-pins/main.go @@ -0,0 +1,187 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/docker/mcp-registry/pkg/github" + "github.com/docker/mcp-registry/pkg/servers" +) + +// main orchestrates the pin refresh process, updating server definitions when +// upstream branches advance. +func main() { + ctx := context.Background() + + // Enumerate the server directories that contain YAML definitions. + entries, err := os.ReadDir("servers") + if err != nil { + fmt.Fprintf(os.Stderr, "reading servers directory: %v\n", err) + os.Exit(1) + } + + var updated []string + for _, entry := range entries { + // Ignore any files that are not server directories. + if !entry.IsDir() { + continue + } + + serverPath := filepath.Join("servers", entry.Name(), "server.yaml") + server, err := servers.Read(serverPath) + if err != nil { + fmt.Fprintf(os.Stderr, "reading %s: %v\n", serverPath, err) + continue + } + + if server.Type != "server" { + continue + } + + if !strings.HasPrefix(server.Image, "mcp/") { + continue + } + + if server.Source.Project == "" { + continue + } + + // Only GitHub repositories are supported by the current workflow. + if !strings.Contains(server.Source.Project, "github.com/") { + fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name) + continue + } + + // Unpinned servers have to undergo a separate security audit first. + existing := strings.ToLower(server.Source.Commit) + if existing == "" { + fmt.Printf("Skipping %s: no pinned commit present.\n", server.Name) + continue + } + + // Resolve the current branch head for comparison. + branch := server.GetBranch() + client := github.NewFromServer(server) + + latest, err := client.GetCommitSHA1(ctx, server.Source.Project, branch) + if err != nil { + fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err) + continue + } + + latest = strings.ToLower(latest) + + changed, err := writeCommit(serverPath, latest) + if err != nil { + fmt.Fprintf(os.Stderr, "updating %s: %v\n", server.Name, err) + continue + } + + if existing != latest { + fmt.Printf("Updated %s: %s -> %s\n", server.Name, existing, latest) + } else if changed { + fmt.Printf("Reformatted pinned commit for %s at %s\n", server.Name, latest) + } + + if changed { + updated = append(updated, server.Name) + } + if existing == latest && !changed { + continue + } + } + + if len(updated) == 0 { + fmt.Println("No commit updates required.") + return + } + + sort.Strings(updated) + fmt.Println("Servers with updated pins:", strings.Join(updated, ", ")) +} + +// writeCommit inserts or updates the commit field inside the source block of +// a server definition while preserving the surrounding formatting. The bool +// return value indicates whether the file contents were modified. +func writeCommit(path string, updated string) (bool, error) { + content, err := os.ReadFile(path) + if err != nil { + return false, err + } + + lines := strings.Split(string(content), "\n") + sourceIndex := -1 + for i, line := range lines { + if strings.HasPrefix(line, "source:") { + sourceIndex = i + break + } + } + if sourceIndex == -1 { + return false, fmt.Errorf("no source block found") + } + + commitIndex := -1 + indent := "" + commitPattern := regexp.MustCompile(`^([ \t]+)commit:\s*[a-fA-F0-9]{40}\s*$`) + for i := sourceIndex + 1; i < len(lines); i++ { + line := lines[i] + if !strings.HasPrefix(line, " ") { + break + } + + if match := commitPattern.FindStringSubmatch(line); match != nil { + commitIndex = i + indent = match[1] + break + } + } + + if commitIndex < 0 { + return false, fmt.Errorf("no commit line found in source block") + } + + newLine := indent + "commit: " + updated + lines[commitIndex] = newLine + + output := strings.Join(lines, "\n") + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + if output == string(content) { + return false, nil + } + + if err := os.WriteFile(path, []byte(output), 0o644); err != nil { + return false, err + } + return true, nil +} From ec6c6c9ea637efe8c483a7a19a9b3add17d81b3b Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Thu, 23 Oct 2025 13:28:57 -0600 Subject: [PATCH 02/29] ci: add AI-driven security-review workflows Signed-off-by: Jacob Howard --- .github/workflows/security-review-diff.yaml | 195 ++++++++++++++++++++ .github/workflows/security-review-full.yaml | 126 +++++++++++++ Taskfile.yml | 4 + cmd/ci/collect_full_audit.go | 89 +++++++++ cmd/ci/collect_new_servers.go | 115 ++++++++++++ cmd/ci/collect_updated_pins.go | 119 ++++++++++++ cmd/ci/compose_pr_summary.go | 60 ++++++ cmd/ci/helpers.go | 146 +++++++++++++++ cmd/ci/main.go | 43 +++++ cmd/ci/prepare_full_audit.go | 77 ++++++++ cmd/ci/prepare_new_servers.go | 96 ++++++++++ cmd/ci/prepare_updated_pins.go | 97 ++++++++++ cmd/ci/types.go | 41 ++++ prompts/security-review-diff.txt | 19 ++ prompts/security-review-full.txt | 10 + templates/security-review-diff.md | 27 +++ templates/security-review-full.md | 25 +++ 17 files changed, 1289 insertions(+) create mode 100644 .github/workflows/security-review-diff.yaml create mode 100644 .github/workflows/security-review-full.yaml create mode 100644 cmd/ci/collect_full_audit.go create mode 100644 cmd/ci/collect_new_servers.go create mode 100644 cmd/ci/collect_updated_pins.go create mode 100644 cmd/ci/compose_pr_summary.go create mode 100644 cmd/ci/helpers.go create mode 100644 cmd/ci/main.go create mode 100644 cmd/ci/prepare_full_audit.go create mode 100644 cmd/ci/prepare_new_servers.go create mode 100644 cmd/ci/prepare_updated_pins.go create mode 100644 cmd/ci/types.go create mode 100644 prompts/security-review-diff.txt create mode 100644 prompts/security-review-full.txt create mode 100644 templates/security-review-diff.md create mode 100644 templates/security-review-full.md diff --git a/.github/workflows/security-review-diff.yaml b/.github/workflows/security-review-diff.yaml new file mode 100644 index 000000000..785c4bac9 --- /dev/null +++ b/.github/workflows/security-review-diff.yaml @@ -0,0 +1,195 @@ +name: Security Review (Diff) + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + +concurrency: + group: security-review-diff-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + pr-security-review: + name: Pull Request Security Review + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + issues: write + + env: + SECURITY_BLOCK_LABEL: "security:blocked" + SECURITY_RISK_LABEL_PREFIX: "security:risk:" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Collect updated pin targets + id: pins + run: | + task ci -- collect-updated-pins \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.sha }}" \ + --workspace "${{ github.workspace }}" \ + --output-json pins-context.json \ + --summary-md pins-summary.md + + if [ -s pins-context.json ]; then + echo "has_targets=true" >> "$GITHUB_OUTPUT" + echo "context=pins-context.json" >> "$GITHUB_OUTPUT" + echo "summary=pins-summary.md" >> "$GITHUB_OUTPUT" + else + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + + - name: Collect new local servers + id: newservers + run: | + task ci -- collect-new-servers \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.sha }}" \ + --workspace "${{ github.workspace }}" \ + --output-json new-servers-context.json \ + --summary-md new-servers-summary.md + + if [ -s new-servers-context.json ]; then + echo "has_targets=true" >> "$GITHUB_OUTPUT" + echo "context=new-servers-context.json" >> "$GITHUB_OUTPUT" + echo "summary=new-servers-summary.md" >> "$GITHUB_OUTPUT" + else + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + + - name: Ensure security labels exist + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "${SECURITY_BLOCK_LABEL}" \ + --color B60205 \ + --description "Security automation detected blocking issues." \ + || echo "Label ${SECURITY_BLOCK_LABEL} already exists." + + for risk in critical high medium low info; do + label="${SECURITY_RISK_LABEL_PREFIX}${risk}" + gh label create "$label" \ + --color 0E8A16 \ + --description "Security automation risk assessment: ${risk}." \ + || echo "Label $label already exists." + done + + - name: Remove stale security labels + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for label in "${SECURITY_BLOCK_LABEL}" \ + "${SECURITY_RISK_LABEL_PREFIX}critical" \ + "${SECURITY_RISK_LABEL_PREFIX}high" \ + "${SECURITY_RISK_LABEL_PREFIX}medium" \ + "${SECURITY_RISK_LABEL_PREFIX}low" \ + "${SECURITY_RISK_LABEL_PREFIX}info" + do + gh pr edit "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ + --remove-label "$label" || true + done + + - name: Prepare review context + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + run: | + mkdir -p /tmp/security-review/pins + mkdir -p /tmp/security-review/new + + if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then + task ci -- prepare-updated-pins \ + --context-file "${{ steps.pins.outputs.context }}" \ + --output-dir /tmp/security-review/pins + fi + + if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then + task ci -- prepare-new-servers \ + --context-file "${{ steps.newservers.outputs.context }}" \ + --output-dir /tmp/security-review/new + fi + + task ci -- compose-pr-summary \ + --pins-summary "${{ steps.pins.outputs.summary }}" \ + --new-summary "${{ steps.newservers.outputs.summary }}" \ + --output summary.md + + - name: Load security review prompt + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + run: | + { + echo 'SECURITY_REVIEW_PROMPT<> "$GITHUB_ENV" + + - name: Run Claude security review + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: ${{ env.SECURITY_REVIEW_PROMPT }} + claude_args: | + --add-file ${{ github.workspace }}/summary.md + --add-file ${{ github.workspace }}/templates/security-review-diff.md + --add-dir /tmp/security-review/pins + --add-dir /tmp/security-review/new + --allowed-tools "Read,Write,Bash(git:*),Bash(gh:*),Bash(mkdir)" + + - name: Post security review as PR comment + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ ! -f /tmp/security-review.md ]; then + echo "No security review report produced." + exit 0 + fi + + { + cat /tmp/security-review.md + echo "" + echo "" + } > security-review-comment.md + + comment_id=$(gh api \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '.[] | select(.body | contains("")) | .id' \ + || true) + + if [ -n "$comment_id" ]; then + gh api \ + -X PATCH \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/issues/comments/$comment_id \ + -F body="@security-review-comment.md" + else + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body-file security-review-comment.md + fi diff --git a/.github/workflows/security-review-full.yaml b/.github/workflows/security-review-full.yaml new file mode 100644 index 000000000..4c0aa9c6e --- /dev/null +++ b/.github/workflows/security-review-full.yaml @@ -0,0 +1,126 @@ +name: Security Review (Full) + +on: + workflow_dispatch: + inputs: + servers: + description: "Comma-separated list of local server names to audit (leave blank for all)." + required: false + default: "" + +concurrency: + group: security-review-full-${{ github.run_id }} + cancel-in-progress: false + +jobs: + full-audit: + name: Execute Full Audit + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Collect audit targets + run: | + task ci -- collect-full-audit \ + --workspace "${{ github.workspace }}" \ + --servers "${{ github.event.inputs.servers }}" \ + --output-json audit-targets.json + + if jq -e '. | length > 0' audit-targets.json >/dev/null; then + echo "AUDIT_HAS_TARGETS=true" >> "$GITHUB_ENV" + else + echo "No audit targets identified; exiting." >&2 + echo "AUDIT_HAS_TARGETS=false" >> "$GITHUB_ENV" + fi + + - name: Prepare audit contexts + if: env.AUDIT_HAS_TARGETS == 'true' + run: | + mkdir -p /tmp/full-audit + rm -f full-audit-summary.md + echo "# Full Audit Targets" >> full-audit-summary.md + echo "" >> full-audit-summary.md + + idx=0 + jq -c '.[]' audit-targets.json | while read -r target; do + server=$(echo "$target" | jq -r '.server') + echo "$target" > target.json + task ci -- prepare-full-audit \ + --target-file target.json \ + --output-dir /tmp/full-audit + + repo=$(echo "$target" | jq -r '.project') + commit=$(echo "$target" | jq -r '.commit') + directory=$(echo "$target" | jq -r '.directory') + if [ -z "$directory" ] || [ "$directory" = "null" ]; then + directory="(repository root)" + fi + + { + echo "## ${server}" + echo "- Repository: ${repo}" + echo "- Commit: \`${commit}\`" + echo "- Directory: ${directory}" + echo "" + } >> full-audit-summary.md + idx=$((idx+1)) + done + + echo "Prepared ${idx} audit targets." + + - name: Load security review prompt + if: env.AUDIT_HAS_TARGETS == 'true' + run: | + { + echo 'SECURITY_REVIEW_PROMPT<> "$GITHUB_ENV" + + - name: Run Claude security review + if: env.AUDIT_HAS_TARGETS == 'true' + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: ${{ env.SECURITY_REVIEW_PROMPT }} + claude_args: | + --add-file ${{ github.workspace }}/full-audit-summary.md + --add-file ${{ github.workspace }}/templates/security-review-full.md + --add-dir /tmp/full-audit + --allowed-tools "Read,Write,Bash(git:*),Bash(mkdir)" + + - name: Store security report + if: env.AUDIT_HAS_TARGETS == 'true' + run: | + if [ -f /tmp/security-review.md ]; then + mkdir -p reports + cp /tmp/security-review.md reports/full-audit-report.md + else + echo "warning: no security review produced" >&2 + fi + + - name: Upload security reports + if: env.AUDIT_HAS_TARGETS == 'true' + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: reports/ + if-no-files-found: warn diff --git a/Taskfile.yml b/Taskfile.yml index 270a89f44..4b724af8a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -29,6 +29,10 @@ tasks: desc: Clean build artifacts for servers cmd: go run ./cmd/clean {{.CLI_ARGS}} + ci: + desc: Run CI helper utilities + cmd: go run ./cmd/ci {{.CLI_ARGS}} + import: desc: Import a server into the registry cmd: docker mcp catalog import ./catalogs/{{.CLI_ARGS}}/catalog.yaml diff --git a/cmd/ci/collect_full_audit.go b/cmd/ci/collect_full_audit.go new file mode 100644 index 000000000..ade3aedbd --- /dev/null +++ b/cmd/ci/collect_full_audit.go @@ -0,0 +1,89 @@ +package main + +import ( + "errors" + "flag" + "io/fs" + "path/filepath" + "strings" +) + +// runCollectFullAudit enumerates local servers (optionally filtered) and writes +// their metadata to a JSON file for manual auditing. It expects --workspace, +// --servers, and --output-json flags. +func runCollectFullAudit(args []string) error { + flags := flag.NewFlagSet("collect-full-audit", flag.ContinueOnError) + workspace := flags.String("workspace", ".", "path to repository workspace") + filter := flags.String("servers", "", "optional comma-separated server filter") + outputJSON := flags.String("output-json", "", "path to write JSON context") + if err := flags.Parse(args); err != nil { + return err + } + + if *outputJSON == "" { + return errors.New("output-json is required") + } + + targets, err := collectAuditTargets(*workspace, *filter) + if err != nil { + return err + } + + if len(targets) == 0 { + removeIfPresent(*outputJSON) + return nil + } + + return writeJSONFile(*outputJSON, targets) +} + +// collectAuditTargets returns audit targets for all local servers or a filtered +// subset based on the supplied comma-separated list. +func collectAuditTargets(workspace, filter string) ([]auditTarget, error) { + filterSet := make(map[string]struct{}) + for _, name := range splitList(filter) { + filterSet[name] = struct{}{} + } + + var targets []auditTarget + err := filepath.WalkDir(filepath.Join(workspace, "servers"), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, "server.yaml") { + return nil + } + + relative := strings.TrimPrefix(path, workspace+string(filepath.Separator)) + doc, err := loadServerYAMLFromWorkspace(workspace, relative) + if err != nil || !isLocalServer(doc) { + return nil + } + + server := filepath.Base(filepath.Dir(path)) + if len(filterSet) > 0 { + if _, ok := filterSet[strings.ToLower(server)]; !ok { + return nil + } + } + + project := strings.TrimSpace(doc.Source.Project) + commit := strings.TrimSpace(doc.Source.Commit) + if project == "" || commit == "" { + return nil + } + + targets = append(targets, auditTarget{ + Server: server, + Project: project, + Commit: commit, + Directory: strings.TrimSpace(doc.Source.Directory), + }) + return nil + }) + if err != nil { + return nil, err + } + + return targets, nil +} diff --git a/cmd/ci/collect_new_servers.go b/cmd/ci/collect_new_servers.go new file mode 100644 index 000000000..c1bec9c3b --- /dev/null +++ b/cmd/ci/collect_new_servers.go @@ -0,0 +1,115 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// runCollectNewServers identifies newly added local servers between two git +// revisions. It accepts --base, --head, --workspace, --output-json, and +// --summary-md flags, writing machine-readable targets and a Markdown summary +// for reviewers. +func runCollectNewServers(args []string) error { + flags := flag.NewFlagSet("collect-new-servers", flag.ContinueOnError) + base := flags.String("base", "", "base git commit SHA") + head := flags.String("head", "", "head git commit SHA") + workspace := flags.String("workspace", ".", "path to repository workspace") + outputJSON := flags.String("output-json", "", "path to write JSON context") + summaryMD := flags.String("summary-md", "", "path to write Markdown summary") + if err := flags.Parse(args); err != nil { + return err + } + + if *base == "" || *head == "" || *outputJSON == "" || *summaryMD == "" { + return errors.New("base, head, output-json, and summary-md are required") + } + + targets, err := collectNewServerTargets(*workspace, *base, *head) + if err != nil { + return err + } + + if len(targets) == 0 { + removeIfPresent(*outputJSON) + removeIfPresent(*summaryMD) + return nil + } + + if err := writeJSONFile(*outputJSON, targets); err != nil { + return err + } + + summary := buildNewServerSummary(targets) + return os.WriteFile(*summaryMD, []byte(summary), 0o644) +} + +// collectNewServerTargets returns metadata for local servers that were added +// between the supplied git revisions. +func collectNewServerTargets(workspace, base, head string) ([]newServerTarget, error) { + lines, err := gitDiff(workspace, base, head, "--name-status") + if err != nil { + return nil, err + } + + var targets []newServerTarget + for _, line := range lines { + if !strings.HasPrefix(line, "A\t") { + continue + } + path := strings.TrimPrefix(line, "A\t") + if !strings.HasPrefix(path, "servers/") || !strings.HasSuffix(path, "server.yaml") { + continue + } + + doc, err := loadServerYAMLFromWorkspace(workspace, path) + if err != nil { + continue + } + + if !isLocalServer(doc) { + continue + } + + project := strings.TrimSpace(doc.Source.Project) + commit := strings.TrimSpace(doc.Source.Commit) + if project == "" || commit == "" { + continue + } + + targets = append(targets, newServerTarget{ + Server: filepath.Base(filepath.Dir(path)), + File: path, + Image: strings.TrimSpace(doc.Image), + Project: project, + Commit: commit, + Directory: strings.TrimSpace(doc.Source.Directory), + }) + } + + return targets, nil +} + +// buildNewServerSummary renders Markdown describing newly added servers for +// review prompts and human consumption. +func buildNewServerSummary(targets []newServerTarget) string { + builder := strings.Builder{} + builder.WriteString("## New Local Servers\n\n") + + for _, target := range targets { + builder.WriteString(fmt.Sprintf("### %s\n", target.Server)) + builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) + builder.WriteString(fmt.Sprintf("- Commit: `%s`\n", target.Commit)) + if target.Directory != "" { + builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) + } else { + builder.WriteString("- Directory: (repository root)\n") + } + builder.WriteString(fmt.Sprintf("- Checkout path: /tmp/security-review/new/%s/repo\n\n", target.Server)) + } + + return builder.String() +} diff --git a/cmd/ci/collect_updated_pins.go b/cmd/ci/collect_updated_pins.go new file mode 100644 index 000000000..67d054f53 --- /dev/null +++ b/cmd/ci/collect_updated_pins.go @@ -0,0 +1,119 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// runCollectUpdatedPins gathers metadata for servers that updated their commit +// pins between two git revisions. It expects --base, --head, --workspace, +// --output-json, and --summary-md arguments. The identified targets are written +// to the JSON file while a Markdown summary is produced for humans. +func runCollectUpdatedPins(args []string) error { + flags := flag.NewFlagSet("collect-updated-pins", flag.ContinueOnError) + base := flags.String("base", "", "base git commit SHA") + head := flags.String("head", "", "head git commit SHA") + workspace := flags.String("workspace", ".", "path to repository workspace") + outputJSON := flags.String("output-json", "", "path to write JSON context") + summaryMD := flags.String("summary-md", "", "path to write Markdown summary") + if err := flags.Parse(args); err != nil { + return err + } + + if *base == "" || *head == "" || *outputJSON == "" || *summaryMD == "" { + return errors.New("base, head, output-json, and summary-md are required") + } + + targets, err := collectUpdatedPinTargets(*workspace, *base, *head) + if err != nil { + return err + } + + if len(targets) == 0 { + removeIfPresent(*outputJSON) + removeIfPresent(*summaryMD) + return nil + } + + if err := writeJSONFile(*outputJSON, targets); err != nil { + return err + } + + summary := buildPinSummary(targets) + return os.WriteFile(*summaryMD, []byte(summary), 0o644) +} + +// collectUpdatedPinTargets identifies local servers whose pinned commits differ +// between the supplied git revisions and returns their metadata for further +// processing. +func collectUpdatedPinTargets(workspace, base, head string) ([]pinTarget, error) { + paths, err := gitDiff(workspace, base, head, "--name-only") + if err != nil { + return nil, err + } + + var targets []pinTarget + for _, relative := range paths { + if !strings.HasPrefix(relative, "servers/") || !strings.HasSuffix(relative, "server.yaml") { + continue + } + + baseDoc, err := loadServerYAMLAt(workspace, base, relative) + if err != nil { + continue + } + headDoc, err := loadServerYAMLFromWorkspace(workspace, relative) + if err != nil { + continue + } + + if !isLocalServer(headDoc) || !isLocalServer(baseDoc) { + continue + } + + oldCommit := strings.TrimSpace(baseDoc.Source.Commit) + newCommit := strings.TrimSpace(headDoc.Source.Commit) + project := strings.TrimSpace(headDoc.Source.Project) + if oldCommit == "" || newCommit == "" || oldCommit == newCommit || project == "" { + continue + } + + targets = append(targets, pinTarget{ + Server: filepath.Base(filepath.Dir(relative)), + File: relative, + Image: strings.TrimSpace(headDoc.Image), + Project: project, + Directory: strings.TrimSpace(headDoc.Source.Directory), + OldCommit: oldCommit, + NewCommit: newCommit, + }) + } + + return targets, nil +} + +// buildPinSummary renders a Markdown section describing updated pin targets so +// that review tooling and humans can understand what changed. +func buildPinSummary(targets []pinTarget) string { + builder := strings.Builder{} + builder.WriteString("## Updated Commit Pins\n\n") + + for _, target := range targets { + builder.WriteString(fmt.Sprintf("### %s\n", target.Server)) + builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) + if target.Directory != "" { + builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) + } else { + builder.WriteString("- Directory: (repository root)\n") + } + builder.WriteString(fmt.Sprintf("- Previous commit: `%s`\n", target.OldCommit)) + builder.WriteString(fmt.Sprintf("- New commit: `%s`\n", target.NewCommit)) + builder.WriteString(fmt.Sprintf("- Diff path: /tmp/security-review/pins/%s/diff.patch\n\n", target.Server)) + } + + return builder.String() +} diff --git a/cmd/ci/compose_pr_summary.go b/cmd/ci/compose_pr_summary.go new file mode 100644 index 000000000..c43a0ec26 --- /dev/null +++ b/cmd/ci/compose_pr_summary.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "flag" + "os" + "strings" +) + +// runComposePRSummary merges per-category summaries into a single Markdown +// document. It requires --pins-summary, --new-summary, and --output flags and +// tolerates missing summary files by emitting nothing. +func runComposePRSummary(args []string) error { + flags := flag.NewFlagSet("compose-pr-summary", flag.ContinueOnError) + pinsSummary := flags.String("pins-summary", "", "summary file for updated pins") + newSummary := flags.String("new-summary", "", "summary file for new servers") + output := flags.String("output", "", "path to write merged summary") + if err := flags.Parse(args); err != nil { + return err + } + + if *output == "" { + return errors.New("output is required") + } + + var sections []string + + if *pinsSummary != "" { + if content, err := os.ReadFile(*pinsSummary); err == nil { + if len(strings.TrimSpace(string(content))) > 0 { + sections = append(sections, string(content)) + } + } + } + + if *newSummary != "" { + if content, err := os.ReadFile(*newSummary); err == nil { + if len(strings.TrimSpace(string(content))) > 0 { + sections = append(sections, string(content)) + } + } + } + + if len(sections) == 0 { + removeIfPresent(*output) + return nil + } + + builder := strings.Builder{} + builder.WriteString("# Security Review Targets\n\n") + for _, section := range sections { + builder.WriteString(section) + if !strings.HasSuffix(section, "\n") { + builder.WriteRune('\n') + } + builder.WriteRune('\n') + } + + return os.WriteFile(*output, []byte(builder.String()), 0o644) +} diff --git a/cmd/ci/helpers.go b/cmd/ci/helpers.go new file mode 100644 index 000000000..9e23d4e76 --- /dev/null +++ b/cmd/ci/helpers.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// writeJSONFile stores the provided value as indented JSON at the given path. +func writeJSONFile(path string, value any) error { + payload, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, payload, 0o644) +} + +// readJSONFile populates value with JSON data read from the provided path. +func readJSONFile(path string, value any) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(content, value) +} + +// removeIfPresent deletes the file at the path when it exists. +func removeIfPresent(path string) { + if path == "" { + return + } + if _, err := os.Stat(path); err == nil { + _ = os.Remove(path) + } +} + +// loadServerYAMLFromWorkspace loads a server YAML file located in the workspace. +func loadServerYAMLFromWorkspace(workspace, relative string) (serverDocument, error) { + fullPath := filepath.Join(workspace, relative) + content, err := os.ReadFile(fullPath) + if err != nil { + return serverDocument{}, err + } + return decodeServerDocument(content) +} + +// loadServerYAMLAt loads a server YAML file from the git history at the commit. +func loadServerYAMLAt(workspace, commit, relative string) (serverDocument, error) { + out, err := runGitCommand(workspace, "show", fmt.Sprintf("%s:%s", commit, relative)) + if err != nil { + return serverDocument{}, err + } + return decodeServerDocument([]byte(out)) +} + +// decodeServerDocument converts raw YAML bytes into a serverDocument. +func decodeServerDocument(raw []byte) (serverDocument, error) { + var doc serverDocument + if err := yaml.Unmarshal(raw, &doc); err != nil { + return serverDocument{}, err + } + return doc, nil +} + +// isLocalServer returns true when the definition corresponds to a local server image. +func isLocalServer(doc serverDocument) bool { + if !strings.EqualFold(doc.Type, "server") { + return false + } + return strings.HasPrefix(strings.TrimSpace(doc.Image), "mcp/") +} + +// gitDiff runs git diff for server YAML files and returns the resulting paths. +func gitDiff(workspace, base, head, mode string) ([]string, error) { + args := []string{"diff", mode, base, head, "--", "servers/*/server.yaml"} + out, err := runGitCommand(workspace, args...) + if err != nil { + return nil, err + } + + var lines []string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + return lines, nil +} + +// runGitCommand executes git with the given arguments inside the directory. +func runGitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, string(output)) + } + return string(output), nil +} + +// initGitRepository creates or reuses a git repository rooted at dir with origin set. +func initGitRepository(dir, remote string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if _, err := runGitCommand(dir, "rev-parse", "--is-inside-work-tree"); err == nil { + return nil + } + if _, err := runGitCommand(dir, "init"); err != nil { + return err + } + if _, err := runGitCommand(dir, "remote", "remove", "origin"); err == nil { + // ignore error + } + _, err := runGitCommand(dir, "remote", "add", "origin", remote) + return err +} + +// fetchCommit retrieves a single commit from origin into the repository. +func fetchCommit(dir, commit string) error { + _, err := runGitCommand(dir, "fetch", "--depth", "1", "--no-tags", "origin", commit) + return err +} + +// splitList normalizes a delimited string into lowercase server names. +func splitList(raw string) []string { + if raw == "" { + return nil + } + var values []string + for _, segment := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == ' ' || r == '\t' + }) { + value := strings.TrimSpace(segment) + if value != "" { + values = append(values, strings.ToLower(value)) + } + } + return values +} diff --git a/cmd/ci/main.go b/cmd/ci/main.go new file mode 100644 index 000000000..a9de365e1 --- /dev/null +++ b/cmd/ci/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" +) + +// main dispatches the CLI to a specific sub-command implementation. +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: ci [options]") + os.Exit(2) + } + + cmd := os.Args[1] + args := os.Args[2:] + + var err error + switch cmd { + case "collect-updated-pins": + err = runCollectUpdatedPins(args) + case "prepare-updated-pins": + err = runPrepareUpdatedPins(args) + case "collect-new-servers": + err = runCollectNewServers(args) + case "prepare-new-servers": + err = runPrepareNewServers(args) + case "compose-pr-summary": + err = runComposePRSummary(args) + case "collect-full-audit": + err = runCollectFullAudit(args) + case "prepare-full-audit": + err = runPrepareFullAudit(args) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + os.Exit(2) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/ci/prepare_full_audit.go b/cmd/ci/prepare_full_audit.go new file mode 100644 index 000000000..86efccd26 --- /dev/null +++ b/cmd/ci/prepare_full_audit.go @@ -0,0 +1,77 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// runPrepareFullAudit clones source data for a single audit target specified by +// a JSON descriptor. It requires --target-file and --output-dir flags and +// prepares the repository checkout plus metadata. +func runPrepareFullAudit(args []string) error { + flags := flag.NewFlagSet("prepare-full-audit", flag.ContinueOnError) + targetFile := flags.String("target-file", "", "path to JSON target descriptor") + outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") + if err := flags.Parse(args); err != nil { + return err + } + + if *targetFile == "" || *outputDir == "" { + return errors.New("target-file and output-dir are required") + } + + var target auditTarget + if err := readJSONFile(*targetFile, &target); err != nil { + return err + } + + return prepareAuditTarget(*outputDir, target) +} + +// prepareAuditTarget materializes repository state and metadata for auditing a +// single server, storing artifacts beneath the provided output directory. +func prepareAuditTarget(outputDir string, target auditTarget) error { + serverDir := filepath.Join(outputDir, target.Server) + repoDir := filepath.Join(serverDir, "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return err + } + + if err := initGitRepository(repoDir, target.Project); err != nil { + return err + } + if err := fetchCommit(repoDir, target.Commit); err != nil { + return err + } + if _, err := runGitCommand(repoDir, "checkout", target.Commit); err != nil { + return err + } + + context := buildAuditContext(target, repoDir) + if err := os.WriteFile(filepath.Join(serverDir, "context.md"), []byte(context), 0o644); err != nil { + return err + } + + return writeJSONFile(filepath.Join(serverDir, "metadata.json"), target) +} + +// buildAuditContext produces Markdown describing the prepared audit checkout, +// which is used to prime review prompts. +func buildAuditContext(target auditTarget, repoDir string) string { + builder := strings.Builder{} + builder.WriteString("# Full Audit Target\n\n") + builder.WriteString(fmt.Sprintf("- Server: %s\n", target.Server)) + builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) + builder.WriteString(fmt.Sprintf("- Commit: %s\n", target.Commit)) + if target.Directory != "" { + builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) + } else { + builder.WriteString("- Directory: (repository root)\n") + } + builder.WriteString(fmt.Sprintf("- Checkout path: %s\n", repoDir)) + return builder.String() +} diff --git a/cmd/ci/prepare_new_servers.go b/cmd/ci/prepare_new_servers.go new file mode 100644 index 000000000..32e7b0e08 --- /dev/null +++ b/cmd/ci/prepare_new_servers.go @@ -0,0 +1,96 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// runPrepareNewServers checks out repositories for newly added local servers, +// given a JSON context file. It expects --context-file and --output-dir flags +// and prepares per-server metadata and source trees. +func runPrepareNewServers(args []string) error { + flags := flag.NewFlagSet("prepare-new-servers", flag.ContinueOnError) + contextFile := flags.String("context-file", "", "path to JSON context file") + outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") + if err := flags.Parse(args); err != nil { + return err + } + + if *contextFile == "" || *outputDir == "" { + return errors.New("context-file and output-dir are required") + } + + var targets []newServerTarget + if err := readJSONFile(*contextFile, &targets); err != nil { + return err + } + + if len(targets) == 0 { + return nil + } + + if err := os.MkdirAll(*outputDir, 0o755); err != nil { + return err + } + + for _, target := range targets { + if err := prepareNewServerTarget(*outputDir, target); err != nil { + return fmt.Errorf("prepare new server %s: %w", target.Server, err) + } + } + + return nil +} + +// prepareNewServerTarget clones the upstream repository at the pinned commit +// for a new server and records metadata for downstream review. +func prepareNewServerTarget(outputDir string, target newServerTarget) error { + serverDir := filepath.Join(outputDir, target.Server) + repoDir := filepath.Join(serverDir, "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return err + } + + if err := initGitRepository(repoDir, target.Project); err != nil { + return err + } + if err := fetchCommit(repoDir, target.Commit); err != nil { + return err + } + if _, err := runGitCommand(repoDir, "checkout", target.Commit); err != nil { + return err + } + + metadata := map[string]string{ + "server": target.Server, + "repository": target.Project, + "commit": target.Commit, + "directory": target.Directory, + } + if err := writeJSONFile(filepath.Join(serverDir, "metadata.json"), metadata); err != nil { + return err + } + + summary := buildNewServerDetail(target) + return os.WriteFile(filepath.Join(serverDir, "README.md"), []byte(summary), 0o644) +} + +// buildNewServerDetail returns a Markdown overview describing the cloned +// server, suitable for inclusion in review prompts. +func buildNewServerDetail(target newServerTarget) string { + builder := strings.Builder{} + builder.WriteString("# New Server Security Review\n\n") + builder.WriteString(fmt.Sprintf("- Server: %s\n", target.Server)) + builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) + builder.WriteString(fmt.Sprintf("- Commit: %s\n", target.Commit)) + if target.Directory != "" { + builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) + } else { + builder.WriteString("- Directory: (repository root)\n") + } + return builder.String() +} diff --git a/cmd/ci/prepare_updated_pins.go b/cmd/ci/prepare_updated_pins.go new file mode 100644 index 000000000..1a4d03dc6 --- /dev/null +++ b/cmd/ci/prepare_updated_pins.go @@ -0,0 +1,97 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" +) + +// runPrepareUpdatedPins fetches upstream repositories and prepares diff +// artifacts for each updated pin listed in the context file. It consumes +// --context-file and --output-dir flags and writes diffs, logs, and metadata +// for downstream analysis. +func runPrepareUpdatedPins(args []string) error { + flags := flag.NewFlagSet("prepare-updated-pins", flag.ContinueOnError) + contextFile := flags.String("context-file", "", "path to JSON context file") + outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") + if err := flags.Parse(args); err != nil { + return err + } + + if *contextFile == "" || *outputDir == "" { + return errors.New("context-file and output-dir are required") + } + + var targets []pinTarget + if err := readJSONFile(*contextFile, &targets); err != nil { + return err + } + + if len(targets) == 0 { + return nil + } + + if err := os.MkdirAll(*outputDir, 0o755); err != nil { + return err + } + + for _, target := range targets { + if err := preparePinTarget(*outputDir, target); err != nil { + return fmt.Errorf("prepare pin target %s: %w", target.Server, err) + } + } + + return nil +} + +// preparePinTarget materializes git diffs, commit logs, and metadata for a +// single commit pin update, storing the results under the provided output +// directory. +func preparePinTarget(outputDir string, target pinTarget) error { + serverDir := filepath.Join(outputDir, target.Server) + repoDir := filepath.Join(serverDir, "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return err + } + + if err := initGitRepository(repoDir, target.Project); err != nil { + return err + } + + for _, commit := range []string{target.OldCommit, target.NewCommit} { + if err := fetchCommit(repoDir, commit); err != nil { + return err + } + } + + diffArgs := []string{"diff", target.OldCommit, target.NewCommit} + if target.Directory != "" && target.Directory != "." { + diffArgs = append(diffArgs, "--", target.Directory) + } + diffOut, err := runGitCommand(repoDir, diffArgs...) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(serverDir, "diff.patch"), []byte(diffOut), 0o644); err != nil { + return err + } + + logOut, err := runGitCommand(repoDir, "log", "--oneline", "--stat", fmt.Sprintf("%s..%s", target.OldCommit, target.NewCommit)) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(serverDir, "changes.log"), []byte(logOut), 0o644); err != nil { + return err + } + + metadata := map[string]string{ + "server": target.Server, + "repository": target.Project, + "old_commit": target.OldCommit, + "new_commit": target.NewCommit, + "directory": target.Directory, + } + return writeJSONFile(filepath.Join(serverDir, "metadata.json"), metadata) +} diff --git a/cmd/ci/types.go b/cmd/ci/types.go new file mode 100644 index 000000000..9a3e06f3d --- /dev/null +++ b/cmd/ci/types.go @@ -0,0 +1,41 @@ +package main + +// pinTarget describes a server that updated its commit pin within a pull request. +type pinTarget struct { + Server string `json:"server"` + File string `json:"file"` + Image string `json:"image"` + Project string `json:"project"` + Directory string `json:"directory,omitempty"` + OldCommit string `json:"old_commit"` + NewCommit string `json:"new_commit"` +} + +// newServerTarget captures metadata for a newly added local server. +type newServerTarget struct { + Server string `json:"server"` + File string `json:"file"` + Image string `json:"image"` + Project string `json:"project"` + Commit string `json:"commit"` + Directory string `json:"directory,omitempty"` +} + +// auditTarget represents a server selected for a manual full audit. +type auditTarget struct { + Server string `json:"server"` + Project string `json:"project"` + Commit string `json:"commit"` + Directory string `json:"directory,omitempty"` +} + +// serverDocument is the decoded structure of a server YAML definition. +type serverDocument struct { + Type string `yaml:"type"` + Image string `yaml:"image"` + Source struct { + Project string `yaml:"project"` + Commit string `yaml:"commit"` + Directory string `yaml:"directory"` + } `yaml:"source"` +} diff --git a/prompts/security-review-diff.txt b/prompts/security-review-diff.txt new file mode 100644 index 000000000..3dfe61fb3 --- /dev/null +++ b/prompts/security-review-diff.txt @@ -0,0 +1,19 @@ +You are assisting the Docker MCP Registry maintainers with a pull request security evaluation. +Depending on the pull request content, review one or both of the following: + +1. Updated commit pins for existing local servers. These targets are documented in summary.md (section: Updated Commit Pins) and + their upstream diffs are available under `/tmp/security-review/pins//`. +2. Newly added local servers. These targets are documented in summary.md (section: New Local Servers) and their source trees are + checked out under `/tmp/security-review/new//repo`. + +Investigate the upstream changes at the pinned commits for signs of malicious or high-risk behavior such as credential exfiltration, +unauthorized network activity, privilege escalation, persistence mechanisms, or logic that deviates from the server's documented purpose. + +Produce a single Markdown report using the template in templates/security-review-diff.md. +Critical instructions: +- Write the final report to `/tmp/security-review.md` only. +- Apply `security:blocked` **only** when the final risk is HIGH or CRITICAL via `gh pr edit`. +- Apply exactly one of these labels via `gh pr edit`: `security:risk:critical`, `security:risk:high`, `security:risk:medium`, + `security:risk:low`, `security:risk:info`. +- Use fully qualified references when mentioning upstream issues or pull requests (`owner/repo#number`). +- Be precise, constructive, and actionable in your feedback. diff --git a/prompts/security-review-full.txt b/prompts/security-review-full.txt new file mode 100644 index 000000000..b7318645f --- /dev/null +++ b/prompts/security-review-full.txt @@ -0,0 +1,10 @@ +You are performing a scheduled security audit for the Docker MCP Registry. +Review every server described in full-audit-summary.md. Each server has its repository checkout in `/tmp/full-audit//repo` with +metadata captured in the summary file. + +Focus on supply-chain and security risks such as credential exfiltration, unauthorized network activity, privilege escalation, or +persistence mechanisms. + +Produce a Markdown report using the template in templates/security-review-full.md. +Critical instructions: +- Write the report to `/tmp/security-review.md` only. diff --git a/templates/security-review-diff.md b/templates/security-review-diff.md new file mode 100644 index 000000000..c82ed259d --- /dev/null +++ b/templates/security-review-diff.md @@ -0,0 +1,27 @@ +# Security Analysis Report + +## Executive Summary +[Overall assessment across all reviewed targets.] + +## Overall Security Risk Assessment +**Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] +[One paragraph justification.] + +

Per-Target Findings

+ +### [Server name] +- **Repository:** [URL] +- **Context:** [Updated commit pin / New local server] +- **Previous Commit:** [sha or N/A] +- **New Commit:** [sha] +- **Risk Assessment:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] +- **Findings:** + - [Finding title — severity — description, impact, recommendation] + +
+ +## Areas for Follow-Up +[Items requiring additional attention.] + +## Conclusion +[Closing remarks.] diff --git a/templates/security-review-full.md b/templates/security-review-full.md new file mode 100644 index 000000000..9e092d358 --- /dev/null +++ b/templates/security-review-full.md @@ -0,0 +1,25 @@ +# Security Analysis Report + +## Executive Summary +[Overall summary.] + +## Overall Security Risk Assessment +**Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] +[Justification.] + +

Per-Server Findings

+ +### [Server name] +- **Repository:** [URL] +- **Commit:** [sha] +- **Risk Assessment:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] +- **Findings:** + - [Finding title — severity — description, impact, recommendation] + +
+ +## Areas for Follow-Up +[Items requiring additional investigation.] + +## Conclusion +[Closing remarks.] From 2bf017d95ead5b57e7541cd646f2478b9e1476d3 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Thu, 23 Oct 2025 14:18:31 -0600 Subject: [PATCH 03/29] ci: relocate update-pins under cmd/ci and add Copyright statements Signed-off-by: Jacob Howard --- .github/workflows/update-pins.yaml | 2 +- Taskfile.yml | 4 -- cmd/ci/collect_full_audit.go | 22 ++++++ cmd/ci/collect_new_servers.go | 22 ++++++ cmd/ci/collect_updated_pins.go | 22 ++++++ cmd/ci/compose_pr_summary.go | 22 ++++++ cmd/ci/helpers.go | 22 ++++++ cmd/ci/main.go | 2 + cmd/ci/prepare_full_audit.go | 22 ++++++ cmd/ci/prepare_new_servers.go | 22 ++++++ cmd/ci/prepare_updated_pins.go | 22 ++++++ cmd/ci/types.go | 22 ++++++ .../main.go => ci/update_pins.go} | 71 ++++++------------- 13 files changed, 223 insertions(+), 54 deletions(-) rename cmd/{update-pins/main.go => ci/update_pins.go} (57%) diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml index 893e06d12..37c902920 100644 --- a/.github/workflows/update-pins.yaml +++ b/.github/workflows/update-pins.yaml @@ -38,7 +38,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - task update-pins + task ci -- update-pins - name: Collect per-server patches id: prepare diff --git a/Taskfile.yml b/Taskfile.yml index 4b724af8a..45eb118ff 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,10 +5,6 @@ tasks: desc: Create a new mcp server definition cmd: go run ./cmd/create {{.CLI_ARGS}} - update-pins: - desc: Refresh server commit pins from upstream repositories - cmd: go run ./cmd/update-pins {{.CLI_ARGS}} - build: desc: Build a server image cmd: go run ./cmd/build {{.CLI_ARGS}} diff --git a/cmd/ci/collect_full_audit.go b/cmd/ci/collect_full_audit.go index ade3aedbd..c8dbb804f 100644 --- a/cmd/ci/collect_full_audit.go +++ b/cmd/ci/collect_full_audit.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/collect_new_servers.go b/cmd/ci/collect_new_servers.go index c1bec9c3b..517aebefa 100644 --- a/cmd/ci/collect_new_servers.go +++ b/cmd/ci/collect_new_servers.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/collect_updated_pins.go b/cmd/ci/collect_updated_pins.go index 67d054f53..0b85c27c0 100644 --- a/cmd/ci/collect_updated_pins.go +++ b/cmd/ci/collect_updated_pins.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/compose_pr_summary.go b/cmd/ci/compose_pr_summary.go index c43a0ec26..defe18dca 100644 --- a/cmd/ci/compose_pr_summary.go +++ b/cmd/ci/compose_pr_summary.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/helpers.go b/cmd/ci/helpers.go index 9e23d4e76..42552ace6 100644 --- a/cmd/ci/helpers.go +++ b/cmd/ci/helpers.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/main.go b/cmd/ci/main.go index a9de365e1..daa4b26d0 100644 --- a/cmd/ci/main.go +++ b/cmd/ci/main.go @@ -31,6 +31,8 @@ func main() { err = runCollectFullAudit(args) case "prepare-full-audit": err = runPrepareFullAudit(args) + case "update-pins": + err = runUpdatePins(args) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) os.Exit(2) diff --git a/cmd/ci/prepare_full_audit.go b/cmd/ci/prepare_full_audit.go index 86efccd26..ee1f9287f 100644 --- a/cmd/ci/prepare_full_audit.go +++ b/cmd/ci/prepare_full_audit.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/prepare_new_servers.go b/cmd/ci/prepare_new_servers.go index 32e7b0e08..8daebcdb0 100644 --- a/cmd/ci/prepare_new_servers.go +++ b/cmd/ci/prepare_new_servers.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/prepare_updated_pins.go b/cmd/ci/prepare_updated_pins.go index 1a4d03dc6..64cd91a28 100644 --- a/cmd/ci/prepare_updated_pins.go +++ b/cmd/ci/prepare_updated_pins.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( diff --git a/cmd/ci/types.go b/cmd/ci/types.go index 9a3e06f3d..2b8816253 100644 --- a/cmd/ci/types.go +++ b/cmd/ci/types.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main // pinTarget describes a server that updated its commit pin within a pull request. diff --git a/cmd/update-pins/main.go b/cmd/ci/update_pins.go similarity index 57% rename from cmd/update-pins/main.go rename to cmd/ci/update_pins.go index 90aa69422..225c9d94e 100644 --- a/cmd/update-pins/main.go +++ b/cmd/ci/update_pins.go @@ -1,29 +1,8 @@ -/* -Copyright © 2025 Docker, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - package main import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -35,21 +14,25 @@ import ( "github.com/docker/mcp-registry/pkg/servers" ) -// main orchestrates the pin refresh process, updating server definitions when -// upstream branches advance. -func main() { +// runUpdatePins refreshes pinned commits for local servers by resolving the +// latest upstream revision on the tracked branch and updating server YAML +// definitions in place. It does not take any CLI flags and emits a summary of +// modified servers on stdout; errors are reported per server so that a single +// failure does not abort the entire sweep. +func runUpdatePins(args []string) error { + if len(args) != 0 { + return errors.New("update-pins does not accept additional arguments") + } + ctx := context.Background() - // Enumerate the server directories that contain YAML definitions. entries, err := os.ReadDir("servers") if err != nil { - fmt.Fprintf(os.Stderr, "reading servers directory: %v\n", err) - os.Exit(1) + return fmt.Errorf("reading servers directory: %w", err) } var updated []string for _, entry := range entries { - // Ignore any files that are not server directories. if !entry.IsDir() { continue } @@ -64,41 +47,33 @@ func main() { if server.Type != "server" { continue } - if !strings.HasPrefix(server.Image, "mcp/") { continue } - if server.Source.Project == "" { continue } - // Only GitHub repositories are supported by the current workflow. if !strings.Contains(server.Source.Project, "github.com/") { fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name) continue } - // Unpinned servers have to undergo a separate security audit first. existing := strings.ToLower(server.Source.Commit) if existing == "" { fmt.Printf("Skipping %s: no pinned commit present.\n", server.Name) continue } - // Resolve the current branch head for comparison. - branch := server.GetBranch() client := github.NewFromServer(server) - - latest, err := client.GetCommitSHA1(ctx, server.Source.Project, branch) + latest, err := client.GetCommitSHA1(ctx, server.Source.Project, server.GetBranch()) if err != nil { fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err) continue } - latest = strings.ToLower(latest) - changed, err := writeCommit(serverPath, latest) + changed, err := writePinnedCommit(serverPath, latest) if err != nil { fmt.Fprintf(os.Stderr, "updating %s: %v\n", server.Name, err) continue @@ -113,24 +88,22 @@ func main() { if changed { updated = append(updated, server.Name) } - if existing == latest && !changed { - continue - } } if len(updated) == 0 { fmt.Println("No commit updates required.") - return + return nil } sort.Strings(updated) fmt.Println("Servers with updated pins:", strings.Join(updated, ", ")) + return nil } -// writeCommit inserts or updates the commit field inside the source block of -// a server definition while preserving the surrounding formatting. The bool -// return value indicates whether the file contents were modified. -func writeCommit(path string, updated string) (bool, error) { +// writePinnedCommit replaces the commit field inside the source block with the +// provided SHA while preserving formatting. A boolean indicates whether the +// file changed. +func writePinnedCommit(path string, updated string) (bool, error) { content, err := os.ReadFile(path) if err != nil { return false, err @@ -145,7 +118,7 @@ func writeCommit(path string, updated string) (bool, error) { } } if sourceIndex == -1 { - return false, fmt.Errorf("no source block found") + return false, fmt.Errorf("no source block found in %s", path) } commitIndex := -1 @@ -165,7 +138,7 @@ func writeCommit(path string, updated string) (bool, error) { } if commitIndex < 0 { - return false, fmt.Errorf("no commit line found in source block") + return false, fmt.Errorf("no commit line found in %s", path) } newLine := indent + "commit: " + updated From 6c1b2b2b65f079754fe48eaf7ead611184dfcf21 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Thu, 23 Oct 2025 15:01:07 -0600 Subject: [PATCH 04/29] ci: clean up types for CI helper commands Signed-off-by: Jacob Howard --- cmd/ci/helpers.go | 20 ++++++++++-------- cmd/ci/types.go | 52 ++++++++++++++++++++++++++--------------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/cmd/ci/helpers.go b/cmd/ci/helpers.go index 42552ace6..93827ed58 100644 --- a/cmd/ci/helpers.go +++ b/cmd/ci/helpers.go @@ -31,6 +31,8 @@ import ( "strings" "gopkg.in/yaml.v3" + + "github.com/docker/mcp-registry/pkg/servers" ) // writeJSONFile stores the provided value as indented JSON at the given path. @@ -62,35 +64,35 @@ func removeIfPresent(path string) { } // loadServerYAMLFromWorkspace loads a server YAML file located in the workspace. -func loadServerYAMLFromWorkspace(workspace, relative string) (serverDocument, error) { +func loadServerYAMLFromWorkspace(workspace, relative string) (servers.Server, error) { fullPath := filepath.Join(workspace, relative) content, err := os.ReadFile(fullPath) if err != nil { - return serverDocument{}, err + return servers.Server{}, err } return decodeServerDocument(content) } // loadServerYAMLAt loads a server YAML file from the git history at the commit. -func loadServerYAMLAt(workspace, commit, relative string) (serverDocument, error) { +func loadServerYAMLAt(workspace, commit, relative string) (servers.Server, error) { out, err := runGitCommand(workspace, "show", fmt.Sprintf("%s:%s", commit, relative)) if err != nil { - return serverDocument{}, err + return servers.Server{}, err } return decodeServerDocument([]byte(out)) } -// decodeServerDocument converts raw YAML bytes into a serverDocument. -func decodeServerDocument(raw []byte) (serverDocument, error) { - var doc serverDocument +// decodeServerDocument converts raw YAML bytes into a servers.Server. +func decodeServerDocument(raw []byte) (servers.Server, error) { + var doc servers.Server if err := yaml.Unmarshal(raw, &doc); err != nil { - return serverDocument{}, err + return servers.Server{}, err } return doc, nil } // isLocalServer returns true when the definition corresponds to a local server image. -func isLocalServer(doc serverDocument) bool { +func isLocalServer(doc servers.Server) bool { if !strings.EqualFold(doc.Type, "server") { return false } diff --git a/cmd/ci/types.go b/cmd/ci/types.go index 2b8816253..98d95572a 100644 --- a/cmd/ci/types.go +++ b/cmd/ci/types.go @@ -24,40 +24,46 @@ package main // pinTarget describes a server that updated its commit pin within a pull request. type pinTarget struct { - Server string `json:"server"` - File string `json:"file"` - Image string `json:"image"` - Project string `json:"project"` + // Server is the registry entry name (directory) that was updated. + Server string `json:"server"` + // File is the relative YAML path that changed for the server. + File string `json:"file"` + // Image is the Docker image identifier associated with the server. + Image string `json:"image"` + // Project is the upstream repository URL for the server source. + Project string `json:"project"` + // Directory points to the subdirectory inside the upstream repository, when set. Directory string `json:"directory,omitempty"` + // OldCommit contains the previously pinned commit SHA. OldCommit string `json:"old_commit"` + // NewCommit contains the newly pinned commit SHA. NewCommit string `json:"new_commit"` } // newServerTarget captures metadata for a newly added local server. type newServerTarget struct { - Server string `json:"server"` - File string `json:"file"` - Image string `json:"image"` - Project string `json:"project"` - Commit string `json:"commit"` + // Server is the registry entry name for the newly added server. + Server string `json:"server"` + // File is the YAML file that defines the server in the registry. + File string `json:"file"` + // Image is the Docker image identifier associated with the new server. + Image string `json:"image"` + // Project is the upstream repository URL that hosts the server code. + Project string `json:"project"` + // Commit is the pinned commit SHA for the newly added server. + Commit string `json:"commit"` + // Directory specifies a subdirectory inside the upstream repository, when present. Directory string `json:"directory,omitempty"` } // auditTarget represents a server selected for a manual full audit. type auditTarget struct { - Server string `json:"server"` - Project string `json:"project"` - Commit string `json:"commit"` + // Server is the registry entry name included in the manual audit. + Server string `json:"server"` + // Project is the upstream repository URL for the audited server. + Project string `json:"project"` + // Commit is the pinned commit SHA to audit. + Commit string `json:"commit"` + // Directory is the subdirectory within the upstream repo to inspect, when applicable. Directory string `json:"directory,omitempty"` } - -// serverDocument is the decoded structure of a server YAML definition. -type serverDocument struct { - Type string `yaml:"type"` - Image string `yaml:"image"` - Source struct { - Project string `yaml:"project"` - Commit string `yaml:"commit"` - Directory string `yaml:"directory"` - } `yaml:"source"` -} From be61be60d33bb085b5f0bc5504fc2142257090ce Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Thu, 23 Oct 2025 16:31:23 -0600 Subject: [PATCH 05/29] ci: switch to manual triggers for differential reviews and pin updates This is just temporary - for testing purposes. Signed-off-by: Jacob Howard --- .github/workflows/security-review-diff.yaml | 61 +++++++++++++++++---- .github/workflows/update-pins.yaml | 4 +- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/.github/workflows/security-review-diff.yaml b/.github/workflows/security-review-diff.yaml index 785c4bac9..5ee9bea6e 100644 --- a/.github/workflows/security-review-diff.yaml +++ b/.github/workflows/security-review-diff.yaml @@ -1,13 +1,19 @@ name: Security Review (Diff) on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - - labeled + workflow_dispatch: + inputs: + pull_request_number: + description: "Optional pull request number to review" + required: false + default: "" + # pull_request: + # types: + # - opened + # - synchronize + # - reopened + # - ready_for_review + # - labeled concurrency: group: security-review-diff-${{ github.event.pull_request.number || github.run_id }} @@ -17,6 +23,7 @@ jobs: pr-security-review: name: Pull Request Security Review runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' permissions: contents: read pull-requests: write @@ -45,10 +52,26 @@ jobs: - name: Collect updated pin targets id: pins + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + base_sha="${{ github.event.pull_request.base.sha }}" + head_sha="${{ github.sha }}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then + pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) + base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') + head_sha=$(echo "$pr_json" | jq -r '.headRefOid') + fi + + if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then + echo "Unable to resolve base/head SHA for review." >&2 + exit 0 + fi + task ci -- collect-updated-pins \ - --base "${{ github.event.pull_request.base.sha }}" \ - --head "${{ github.sha }}" \ + --base "$base_sha" \ + --head "$head_sha" \ --workspace "${{ github.workspace }}" \ --output-json pins-context.json \ --summary-md pins-summary.md @@ -63,10 +86,26 @@ jobs: - name: Collect new local servers id: newservers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + base_sha="${{ github.event.pull_request.base.sha }}" + head_sha="${{ github.sha }}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then + pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) + base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') + head_sha=$(echo "$pr_json" | jq -r '.headRefOid') + fi + + if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then + echo "Unable to resolve base/head SHA for review." >&2 + exit 0 + fi + task ci -- collect-new-servers \ - --base "${{ github.event.pull_request.base.sha }}" \ - --head "${{ github.sha }}" \ + --base "$base_sha" \ + --head "$head_sha" \ --workspace "${{ github.workspace }}" \ --output-json new-servers-context.json \ --summary-md new-servers-summary.md diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml index 37c902920..7aea8eae5 100644 --- a/.github/workflows/update-pins.yaml +++ b/.github/workflows/update-pins.yaml @@ -1,8 +1,10 @@ name: Update MCP Server Version Pins on: + # schedule: + # - cron: "0 5 * * *" schedule: - - cron: "0 5 * * *" + - cron: "0 0 1 * *" workflow_dispatch: permissions: From d0ea2d8b8b7e053c1f66169001743be35fc6abdc Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sat, 25 Oct 2025 22:13:11 -0600 Subject: [PATCH 06/29] ci: add new security-reviewer agent Signed-off-by: Jacob Howard --- .github/workflows/security-review-diff.yaml | 109 ++-- .github/workflows/security-review-full.yaml | 57 +- Taskfile.yml | 4 + agents/security-reviewer/Dockerfile | 59 ++ agents/security-reviewer/compose.yml | 67 +++ agents/security-reviewer/entrypoint/agent.go | 37 ++ agents/security-reviewer/entrypoint/claude.go | 51 ++ agents/security-reviewer/entrypoint/codex.go | 51 ++ agents/security-reviewer/entrypoint/main.go | 359 ++++++++++++ agents/security-reviewer/go.mod | 3 + agents/security-reviewer/go.sum | 0 agents/security-reviewer/litellm.config.yaml | 20 + agents/security-reviewer/prompt.md | 41 ++ agents/security-reviewer/report-template.md | 37 ++ cmd/security-reviewer/main.go | 545 ++++++++++++++++++ go.mod | 3 + go.sum | 8 + prompts/security-review-diff.txt | 19 - prompts/security-review-full.txt | 10 - templates/security-review-diff.md | 27 - templates/security-review-full.md | 25 - 21 files changed, 1384 insertions(+), 148 deletions(-) create mode 100644 agents/security-reviewer/Dockerfile create mode 100644 agents/security-reviewer/compose.yml create mode 100644 agents/security-reviewer/entrypoint/agent.go create mode 100644 agents/security-reviewer/entrypoint/claude.go create mode 100644 agents/security-reviewer/entrypoint/codex.go create mode 100644 agents/security-reviewer/entrypoint/main.go create mode 100644 agents/security-reviewer/go.mod create mode 100644 agents/security-reviewer/go.sum create mode 100644 agents/security-reviewer/litellm.config.yaml create mode 100644 agents/security-reviewer/prompt.md create mode 100644 agents/security-reviewer/report-template.md create mode 100644 cmd/security-reviewer/main.go delete mode 100644 prompts/security-review-diff.txt delete mode 100644 prompts/security-review-full.txt delete mode 100644 templates/security-review-diff.md delete mode 100644 templates/security-review-full.md diff --git a/.github/workflows/security-review-diff.yaml b/.github/workflows/security-review-diff.yaml index 5ee9bea6e..a1a34d273 100644 --- a/.github/workflows/security-review-diff.yaml +++ b/.github/workflows/security-review-diff.yaml @@ -50,11 +50,12 @@ jobs: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Collect updated pin targets - id: pins + - name: Resolve comparison commits + id: revision env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail base_sha="${{ github.event.pull_request.base.sha }}" head_sha="${{ github.sha }}" @@ -64,8 +65,26 @@ jobs: head_sha=$(echo "$pr_json" | jq -r '.headRefOid') fi + if [ -n "$base_sha" ]; then + echo "base=$base_sha" >> "$GITHUB_OUTPUT" + fi + if [ -n "$head_sha" ]; then + echo "head=$head_sha" >> "$GITHUB_OUTPUT" + fi + + - name: Collect updated pin targets + id: pins + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_SHA: ${{ steps.revision.outputs.base }} + HEAD_SHA: ${{ steps.revision.outputs.head }} + run: | + set -euo pipefail + base_sha="$BASE_SHA" + head_sha="$HEAD_SHA" + if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then - echo "Unable to resolve base/head SHA for review." >&2 + echo "has_targets=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -88,18 +107,15 @@ jobs: id: newservers env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_SHA: ${{ steps.revision.outputs.base }} + HEAD_SHA: ${{ steps.revision.outputs.head }} run: | - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.sha }}" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then - pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) - base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') - head_sha=$(echo "$pr_json" | jq -r '.headRefOid') - fi + set -euo pipefail + base_sha="$BASE_SHA" + head_sha="$HEAD_SHA" if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then - echo "Unable to resolve base/head SHA for review." >&2 + echo "has_targets=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -123,6 +139,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail gh label create "${SECURITY_BLOCK_LABEL}" \ --color B60205 \ --description "Security automation detected blocking issues." \ @@ -141,13 +158,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail for label in "${SECURITY_BLOCK_LABEL}" \ "${SECURITY_RISK_LABEL_PREFIX}critical" \ "${SECURITY_RISK_LABEL_PREFIX}high" \ "${SECURITY_RISK_LABEL_PREFIX}medium" \ "${SECURITY_RISK_LABEL_PREFIX}low" \ - "${SECURITY_RISK_LABEL_PREFIX}info" - do + "${SECURITY_RISK_LABEL_PREFIX}info"; do gh pr edit "${{ github.event.pull_request.number }}" \ --repo "${{ github.repository }}" \ --remove-label "$label" || true @@ -156,8 +173,8 @@ jobs: - name: Prepare review context if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' run: | - mkdir -p /tmp/security-review/pins - mkdir -p /tmp/security-review/new + set -euo pipefail + mkdir -p /tmp/security-review/pins /tmp/security-review/new if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then task ci -- prepare-updated-pins \ @@ -176,42 +193,56 @@ jobs: --new-summary "${{ steps.newservers.outputs.summary }}" \ --output summary.md - - name: Load security review prompt + - name: Run security reviewer (Claude) if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + BASE_SHA: ${{ steps.revision.outputs.base }} + HEAD_SHA: ${{ steps.revision.outputs.head }} run: | - { - echo 'SECURITY_REVIEW_PROMPT<> "$GITHUB_ENV" + set -euo pipefail + INPUT_ROOT="${RUNNER_TEMP}/reviewer/input" + OUTPUT_ROOT="${RUNNER_TEMP}/reviewer/output" + mkdir -p "$INPUT_ROOT" "$OUTPUT_ROOT" + cp "${{ github.workspace }}/summary.md" "$INPUT_ROOT/summary.md" - - name: Run Claude security review - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - prompt: ${{ env.SECURITY_REVIEW_PROMPT }} - claude_args: | - --add-file ${{ github.workspace }}/summary.md - --add-file ${{ github.workspace }}/templates/security-review-diff.md - --add-dir /tmp/security-review/pins - --add-dir /tmp/security-review/new - --allowed-tools "Read,Write,Bash(git:*),Bash(gh:*),Bash(mkdir)" + if [ -d /tmp/security-review ]; then + mkdir -p "$INPUT_ROOT/contexts" + cp -R /tmp/security-review/. "$INPUT_ROOT/contexts" + export REVIEW_EXTRA_ALLOWED_DIRS="/input/contexts/pins /input/contexts/new" + fi + + pushd agents/security-reviewer >/dev/null + export REVIEW_INPUT_PATH="$INPUT_ROOT" + export REVIEW_OUTPUT_PATH_HOST="$OUTPUT_ROOT" + export REVIEW_SUMMARY_PATH="/input/summary.md" + export REVIEW_MODE="diff" + export REVIEW_HEAD_SHA="$HEAD_SHA" + export REVIEW_BASE_SHA="$BASE_SHA" + export REVIEW_TARGET_LABEL="PR-${{ github.event.pull_request.number || github.run_id }}" + export REVIEW_AGENT="claude" + cleanup() { docker compose down --volumes --remove-orphans || true; } + trap cleanup EXIT + docker compose up --build --abort-on-container-exit reviewer + popd >/dev/null + + mkdir -p "${{ github.workspace }}/review-output" + cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/review-output/security-review.md" - name: Post security review as PR comment if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ ! -f /tmp/security-review.md ]; then + set -euo pipefail + if [ ! -f "${{ github.workspace }}/review-output/security-review.md" ]; then echo "No security review report produced." exit 0 fi { - cat /tmp/security-review.md + cat "${{ github.workspace }}/review-output/security-review.md" echo "" echo "" } > security-review-comment.md @@ -226,7 +257,7 @@ jobs: -X PATCH \ -H "Accept: application/vnd.github+json" \ /repos/${{ github.repository }}/issues/comments/$comment_id \ - -F body="@security-review-comment.md" + -F body=@security-review-comment.md else gh pr comment ${{ github.event.pull_request.number }} \ --repo ${{ github.repository }} \ diff --git a/.github/workflows/security-review-full.yaml b/.github/workflows/security-review-full.yaml index 4c0aa9c6e..83fdc568c 100644 --- a/.github/workflows/security-review-full.yaml +++ b/.github/workflows/security-review-full.yaml @@ -38,6 +38,7 @@ jobs: - name: Collect audit targets run: | + set -euo pipefail task ci -- collect-full-audit \ --workspace "${{ github.workspace }}" \ --servers "${{ github.event.inputs.servers }}" \ @@ -53,6 +54,7 @@ jobs: - name: Prepare audit contexts if: env.AUDIT_HAS_TARGETS == 'true' run: | + set -euo pipefail mkdir -p /tmp/full-audit rm -f full-audit-summary.md echo "# Full Audit Targets" >> full-audit-summary.md @@ -85,38 +87,37 @@ jobs: echo "Prepared ${idx} audit targets." - - name: Load security review prompt + - name: Run security reviewer (Claude) if: env.AUDIT_HAS_TARGETS == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | - { - echo 'SECURITY_REVIEW_PROMPT<> "$GITHUB_ENV" - - - name: Run Claude security review - if: env.AUDIT_HAS_TARGETS == 'true' - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - prompt: ${{ env.SECURITY_REVIEW_PROMPT }} - claude_args: | - --add-file ${{ github.workspace }}/full-audit-summary.md - --add-file ${{ github.workspace }}/templates/security-review-full.md - --add-dir /tmp/full-audit - --allowed-tools "Read,Write,Bash(git:*),Bash(mkdir)" - - - name: Store security report - if: env.AUDIT_HAS_TARGETS == 'true' - run: | - if [ -f /tmp/security-review.md ]; then - mkdir -p reports - cp /tmp/security-review.md reports/full-audit-report.md - else - echo "warning: no security review produced" >&2 + set -euo pipefail + INPUT_ROOT="${RUNNER_TEMP}/reviewer/input" + OUTPUT_ROOT="${RUNNER_TEMP}/reviewer/output" + mkdir -p "$INPUT_ROOT" "$OUTPUT_ROOT" + cp "${{ github.workspace }}/full-audit-summary.md" "$INPUT_ROOT/summary.md" + if [ -d /tmp/full-audit ]; then + cp -R /tmp/full-audit "$INPUT_ROOT/full-audit" + export REVIEW_EXTRA_ALLOWED_DIRS="/input/full-audit" fi + pushd agents/security-reviewer >/dev/null + export REVIEW_INPUT_PATH="$INPUT_ROOT" + export REVIEW_OUTPUT_PATH_HOST="$OUTPUT_ROOT" + export REVIEW_SUMMARY_PATH="/input/summary.md" + export REVIEW_MODE="full" + export REVIEW_TARGET_LABEL="full-audit" + export REVIEW_AGENT="claude" + cleanup() { docker compose down --volumes --remove-orphans || true; } + trap cleanup EXIT + docker compose up --build --abort-on-container-exit reviewer + popd >/dev/null + + mkdir -p "${{ github.workspace }}/reports" + cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/reports/full-audit-report.md" + - name: Upload security reports if: env.AUDIT_HAS_TARGETS == 'true' uses: actions/upload-artifact@v4 diff --git a/Taskfile.yml b/Taskfile.yml index 45eb118ff..06241d363 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -42,3 +42,7 @@ tasks: unittest: desc: Run Go unit tests cmd: go test ./... + + security-reviewer: + desc: Run the security reviewer agent + cmd: go run ./cmd/security-reviewer {{.CLI_ARGS}} diff --git a/agents/security-reviewer/Dockerfile b/agents/security-reviewer/Dockerfile new file mode 100644 index 000000000..047df14d0 --- /dev/null +++ b/agents/security-reviewer/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1.7 + +# Use a golang image to build the agent entrypoint (with cgo disabled). +FROM golang:1.25-trixie AS gobuilder +ENV CGO_ENABLED=0 + +# Copy dependency specifications and ensure they're downloaded. +COPY go.mod go.sum ./ +RUN go mod download + +# Copy entrypoint sources and build the entrypoint +COPY entrypoint ./entrypoint +RUN go build -o /build/security-reviewer ./entrypoint + + +# Use a debian image to host the agent. +FROM debian:trixie-slim + +# Set up configuration. +ARG CLAUDE_CODE_VERSION=latest +ARG CODEX_CLI_VERSION=latest + +# Install core packages, developer tooling, and runtime dependencies. +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gnupg \ + jq \ + nodejs \ + npm \ + ripgrep \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude Code and Codex CLIs for headless operation. +RUN npm install --global \ + @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ + @openai/codex@${CODEX_CLI_VERSION} \ + && npm cache clean --force + +# Create a dedicated runtime user and working directories. +RUN useradd --create-home --shell /bin/bash agent \ + && install -d -o agent -g agent /workspace \ + && install -d -o agent -g agent /input \ + && install -d -o agent -g agent /output + +# Copy the entrypoint and report template. +COPY --from=gobuilder /build/security-reviewer /opt/security-reviewer/security-reviewer +COPY --chown=agent:agent report-template.md /opt/security-reviewer/report-template.md + +# Set the entrypoint user and working directory. +USER agent +WORKDIR /workspace + +# Set the entrypoint. +ENTRYPOINT ["/opt/security-reviewer/security-reviewer"] diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml new file mode 100644 index 000000000..24ab99e20 --- /dev/null +++ b/agents/security-reviewer/compose.yml @@ -0,0 +1,67 @@ +services: + reviewer: + build: + context: . + image: security-reviewer:latest + depends_on: + litellm: + condition: service_healthy + environment: + # Note: We want Claude Code to use a bearer token when making API requests + # to LiteLLM, not an X-Api-Key header. If we set ANTHROPIC_API_KEY, it + # will use an X-Api-Key header; ANTHROPIC_AUTH_TOKEN uses a bearer token. + ANTHROPIC_AUTH_TOKEN: "sk-compose-clients" + ANTHROPIC_BASE_URL: "http://litellm:4000" + CLAUDE_REVIEW_MODEL: ${CLAUDE_REVIEW_MODEL:-claude-sonnet-4-5-20250929} + OPENAI_API_KEY: "sk-compose-clients" + OPENAI_BASE_URL: "http://litellm:4000" + CODEX_REVIEW_MODEL: ${CODEX_REVIEW_MODEL:-gpt-5-codex} + REVIEW_AGENT: ${REVIEW_AGENT:-claude} + REVIEW_MODE: ${REVIEW_MODE:?Set REVIEW_MODE in environment or .env} + REVIEW_HEAD_SHA: ${REVIEW_HEAD_SHA:-} + REVIEW_BASE_SHA: ${REVIEW_BASE_SHA:-} + REVIEW_TARGET_LABEL: ${REVIEW_TARGET_LABEL:-} + REVIEW_PROMPT_PATH: ${REVIEW_PROMPT_PATH:-/input/prompt.md} + REVIEW_REPOSITORY_PATH: ${REVIEW_REPOSITORY_PATH:-/input/repository} + REVIEW_REPORT_PATH: ${REVIEW_REPORT_PATH:-/output/report.md} + REVIEW_LABELS_PATH: ${REVIEW_LABELS_PATH:-/output/labels.txt} + REVIEW_AGENT_ALLOWED_TOOLS: ${REVIEW_AGENT_ALLOWED_TOOLS:-} + REVIEW_AGENT_EXTRA_ARGS: ${REVIEW_AGENT_EXTRA_ARGS:---verbose} + REVIEW_EXTRA_ALLOWED_DIRS: ${REVIEW_EXTRA_ALLOWED_DIRS:-} + REVIEW_EXTRA_ALLOWED_FILES: ${REVIEW_EXTRA_ALLOWED_FILES:-} + volumes: + - type: bind + source: ${REVIEW_INPUT_PATH:?Set REVIEW_INPUT_PATH to bind the repository under review} + target: /input + read_only: true + - type: bind + source: ${REVIEW_OUTPUT_PATH_HOST:?Set REVIEW_OUTPUT_PATH_HOST for report output} + target: /output + networks: + - internal + + litellm: + image: ghcr.io/berriai/litellm:main-stable + command: ["--config", "/app/config.yaml", "--port", "4000"] + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + healthcheck: + test: [ "CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1" ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s + volumes: + - ./litellm.config.yaml:/app/config.yaml + networks: + - internal + - external + restart: unless-stopped + +networks: + internal: + driver: bridge + internal: true + external: + driver: bridge diff --git a/agents/security-reviewer/entrypoint/agent.go b/agents/security-reviewer/entrypoint/agent.go new file mode 100644 index 000000000..8f744013c --- /dev/null +++ b/agents/security-reviewer/entrypoint/agent.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "os/exec" +) + +const ( + // agentNameClaude identifies the Claude Code based reviewer. + agentNameClaude = "claude" + // agentNameCodex identifies the Codex based reviewer. + agentNameCodex = "codex" +) + +// reviewerAgent defines the behavior required by each agent implementation. +type reviewerAgent interface { + Name() string + // ModelEnvVar returns the environment variable that overrides the agent's model, or empty when not applicable. + ModelEnvVar() string + // DefaultAllowedTools returns the allowlist of tools that should be enabled when the caller does not specify one. + DefaultAllowedTools() string + // BuildCommand returns the configured command used to invoke the agent. + BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) +} + +// selectAgent resolves an agent by name. +func selectAgent(name string) (reviewerAgent, error) { + switch name { + case agentNameClaude: + return claudeAgent{}, nil + case agentNameCodex: + return codexAgent{}, nil + default: + return nil, fmt.Errorf("unsupported review agent: %s", name) + } +} diff --git a/agents/security-reviewer/entrypoint/claude.go b/agents/security-reviewer/entrypoint/claude.go new file mode 100644 index 000000000..5f7f798a6 --- /dev/null +++ b/agents/security-reviewer/entrypoint/claude.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "os/exec" + "strings" +) + +// claudeAgent implements reviewerAgent for Claude Code. +type claudeAgent struct{} + +func (claudeAgent) Name() string { + return agentNameClaude +} + +func (claudeAgent) ModelEnvVar() string { + // Claude Code reads its target model from CLAUDE_REVIEW_MODEL. + return "CLAUDE_REVIEW_MODEL" +} + +func (claudeAgent) DefaultAllowedTools() string { + // Mirror the default permissions granted in prior workflows. + return defaultClaudeAllowedTools +} + +func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { + args := []string{"--print", "--output-format", "text"} + if strings.TrimSpace(inv.AllowedTools) != "" { + args = append(args, "--allowed-tools", inv.AllowedTools) + } + if strings.TrimSpace(inv.Model) != "" { + args = append(args, "--model", inv.Model) + } + for _, dir := range inv.AllowedDirs { + if strings.TrimSpace(dir) == "" { + continue + } + args = append(args, "--add-dir", dir) + } + if strings.TrimSpace(inv.ExtraArgs) != "" { + args = append(args, strings.Fields(inv.ExtraArgs)...) + } + + cmd := exec.CommandContext(ctx, "claude", args...) + cmd.Stdin = strings.NewReader(inv.Prompt) + if inv.WorkingDir != "" { + cmd.Dir = inv.WorkingDir + } + + return cmd, nil +} diff --git a/agents/security-reviewer/entrypoint/codex.go b/agents/security-reviewer/entrypoint/codex.go new file mode 100644 index 000000000..9f1efdba1 --- /dev/null +++ b/agents/security-reviewer/entrypoint/codex.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "os" + "os/exec" + "strings" +) + +// codexAgent implements reviewerAgent for the OpenAI Codex CLI. +type codexAgent struct{} + +func (codexAgent) Name() string { + return agentNameCodex +} + +func (codexAgent) ModelEnvVar() string { + // Codex shells read from CODEX_REVIEW_MODEL when provided. + return "CODEX_REVIEW_MODEL" +} + +func (codexAgent) DefaultAllowedTools() string { + // Codex manages tool permissions internally, so we default to an empty allowlist. + return "" +} + +func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { + args := []string{"--quiet", "--json"} + if strings.TrimSpace(inv.Model) != "" { + args = append(args, "--model", inv.Model) + } + if strings.TrimSpace(inv.ExtraArgs) != "" { + args = append(args, strings.Fields(inv.ExtraArgs)...) + } + args = append(args, "exec", "--input", "-") + + cmd := exec.CommandContext(ctx, "codex", args...) + cmd.Stdin = strings.NewReader(inv.Prompt) + if inv.WorkingDir != "" { + cmd.Dir = inv.WorkingDir + } + + env := os.Environ() + env = append(env, envCodexQuiet+"=1", envCodexJson+"=1") + if inv.WorkingDir != "" { + env = append(env, envCodexWorkingDir+"="+inv.WorkingDir) + } + cmd.Env = env + + return cmd, nil +} diff --git a/agents/security-reviewer/entrypoint/main.go b/agents/security-reviewer/entrypoint/main.go new file mode 100644 index 000000000..d839ee711 --- /dev/null +++ b/agents/security-reviewer/entrypoint/main.go @@ -0,0 +1,359 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +const ( + reportTemplatePath = "/opt/security-reviewer/report-template.md" + defaultPromptPath = "/input/prompt.md" + defaultRepositoryPath = "/input/repository" + defaultReportPath = "/output/report.md" + defaultLabelsPath = "/output/labels.txt" + defaultClaudeAllowedTools = "Read,Write,Bash(git:*),Bash(mkdir),Bash(ls),Bash(cat)" + defaultReviewAgent = "claude" + defaultAgentWorkingDir = "/workspace" + + envReviewAgent = "REVIEW_AGENT" + envReviewPromptPath = "REVIEW_PROMPT_PATH" + envReviewRepositoryPath = "REVIEW_REPOSITORY_PATH" + envReviewReportPath = "REVIEW_REPORT_PATH" + envReviewLabelsPath = "REVIEW_LABELS_PATH" + envClaudeReviewModel = "CLAUDE_REVIEW_MODEL" + envCodexReviewModel = "CODEX_REVIEW_MODEL" + envAgentAllowedTools = "REVIEW_AGENT_ALLOWED_TOOLS" + envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" + envExtraDirs = "REVIEW_EXTRA_ALLOWED_DIRS" + envExtraFiles = "REVIEW_EXTRA_ALLOWED_FILES" + envCodexQuiet = "CODEX_QUIET_MODE" + envCodexJson = "CODEX_JSON_MODE" + envCodexWorkingDir = "CODEX_WORKDIR" +) + +// ReviewMode enumerates supported security review modes. +type ReviewMode string + +const ( + ReviewModeFull ReviewMode = "full" + ReviewModeDiff ReviewMode = "diff" +) + +// agentInvocation captures execution hints per reviewer agent. +type agentInvocation struct { + Prompt string + Model string + AllowedTools string + AllowedDirs []string + AllowedFiles []string + ExtraArgs string + WorkingDir string +} + +// main configures logging, resolves environment, and runs the selected agent. +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Run the review workflow and exit non-zero on failure so the container signals an error. + if err := run(ctx); err != nil { + logError(err) + os.Exit(1) + } +} + +// run orchestrates prompt generation and agent execution. +func run(ctx context.Context) error { + // Parse review configuration from the environment. + modeRaw, err := fetchEnv("REVIEW_MODE", true) + if err != nil { + return err + } + mode, err := normalizeMode(modeRaw) + if err != nil { + return err + } + + requireHead := mode != ReviewModeFull + headSHA, err := fetchEnv("REVIEW_HEAD_SHA", requireHead) + if err != nil { + return err + } + baseSHA, err := fetchEnv("REVIEW_BASE_SHA", false) + if err != nil { + return err + } + if mode == ReviewModeDiff && baseSHA == "" { + return errors.New("REVIEW_BASE_SHA is required when REVIEW_MODE=diff") + } + + targetLabel, err := fetchEnv("REVIEW_TARGET_LABEL", false) + if err != nil { + return err + } + + // Resolve concrete paths for prompt, repository, and outputs. + promptPath := strings.TrimSpace(firstNonEmpty( + mustFetchOptional(envReviewPromptPath), + defaultPromptPath, + )) + repositoryPath := strings.TrimSpace(firstNonEmpty( + mustFetchOptional(envReviewRepositoryPath), + defaultRepositoryPath, + )) + reportPath := strings.TrimSpace(firstNonEmpty( + mustFetchOptional(envReviewReportPath), + defaultReportPath, + )) + labelsPath := strings.TrimSpace(firstNonEmpty( + mustFetchOptional(envReviewLabelsPath), + defaultLabelsPath, + )) + + promptPath = filepath.Clean(promptPath) + repositoryPath = filepath.Clean(repositoryPath) + reportPath = filepath.Clean(reportPath) + labelsPath = filepath.Clean(labelsPath) + reportDir := filepath.Dir(reportPath) + labelsDir := filepath.Dir(labelsPath) + + // Read the rendered prompt and ensure the repository mount is present. + if err = ensureDirectory(repositoryPath); err != nil { + return err + } + + promptBytes, err := os.ReadFile(promptPath) + if err != nil { + return fmt.Errorf("read prompt %s: %w", promptPath, err) + } + logInfo(fmt.Sprintf("Loaded prompt from %s.", promptPath)) + + // Select the reviewer implementation and build invocation parameters. + agentName, err := fetchEnv(envReviewAgent, false) + if err != nil { + return err + } + if agentName == "" { + agentName = defaultReviewAgent + } + agentKey := strings.ToLower(strings.TrimSpace(agentName)) + agent, err := selectAgent(agentKey) + if err != nil { + return err + } + + var model string + if envName := agent.ModelEnvVar(); envName != "" { + model = mustFetchOptional(envName) + } + + allowedTools := firstNonEmpty( + mustFetchOptional(envAgentAllowedTools), + mustFetchOptional("CLAUDE_ALLOWED_TOOLS"), + agent.DefaultAllowedTools(), + ) + extraArgs := firstNonEmpty( + mustFetchOptional(envAgentExtraArgs), + mustFetchOptional("CLAUDE_EXTRA_ARGS"), + ) + + allowedDirs := []string{repositoryPath, defaultAgentWorkingDir, reportDir, labelsDir} + if extraDirs := mustFetchOptional(envExtraDirs); extraDirs != "" { + allowedDirs = append(allowedDirs, parseList(extraDirs)...) + } + + allowedFiles := []string{reportTemplatePath, promptPath} + if extraFiles := mustFetchOptional(envExtraFiles); extraFiles != "" { + allowedFiles = append(allowedFiles, parseList(extraFiles)...) + } + + inv := agentInvocation{ + Prompt: string(promptBytes), + Model: model, + AllowedTools: allowedTools, + AllowedDirs: allowedDirs, + AllowedFiles: allowedFiles, + ExtraArgs: extraArgs, + WorkingDir: defaultAgentWorkingDir, + } + + logInfo(fmt.Sprintf( + "Starting %s review (agent=%s head=%s base=%s label=%s).", + mode, agent.Name(), headSHA, baseSHA, targetLabel, + )) + + // Execute the agent command and relay its output streams. + stdout, stderr, runErr := runAgent(ctx, agent, inv) + if stderr != "" { + logError(errors.New(stderr)) + } + if stdout != "" { + fmt.Print(stdout) + } + if runErr != nil { + return runErr + } + + // Persist the report and labels outputs, falling back to stdout when needed. + if err = ensureParent(reportPath); err != nil { + return err + } + if !fileExists(reportPath) { + if err = os.WriteFile(reportPath, []byte(stdout), 0o644); err != nil { + return fmt.Errorf("write fallback report: %w", err) + } + logInfo("Report not found, wrote fallback using stdout output.") + } + + if err = ensureParent(labelsPath); err != nil { + return err + } + if err = ensureLabelsFile(labelsPath); err != nil { + return err + } + + logInfo("Security review completed successfully.") + logInfo(fmt.Sprintf("Report stored at %s.", reportPath)) + logInfo(fmt.Sprintf("Labels stored at %s.", labelsPath)) + return nil +} + +// fetchEnv reads an environment variable and validates presence when required. +func fetchEnv(name string, required bool) (string, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" && required { + return "", fmt.Errorf("missing required environment variable: %s", name) + } + return value, nil +} + +// mustFetchOptional retrieves an optional environment variable without error returns. +func mustFetchOptional(name string) string { + value, _ := fetchEnv(name, false) + return value +} + +// firstNonEmpty returns the first non-empty string from the provided list. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// ensureDirectory verifies that the provided path exists and is a directory. +func ensureDirectory(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("expected directory at %s", path) + } + return nil +} + +// normalizeMode validates mode strings and returns canonical values. +func normalizeMode(raw string) (ReviewMode, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "diff", "differential": + return ReviewModeDiff, nil + case "full": + return ReviewModeFull, nil + default: + return "", fmt.Errorf("invalid REVIEW_MODE: %s", raw) + } +} + +// ensureParent creates directories needed for the provided path. +func ensureParent(path string) error { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +// ensureLabelsFile guarantees the labels file exists as a regular file. +func ensureLabelsFile(path string) error { + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + return fmt.Errorf("expected file at %s", path) + } + return nil + } + if errors.Is(err, os.ErrNotExist) { + if writeErr := os.WriteFile(path, []byte{}, 0o644); writeErr != nil { + return fmt.Errorf("create labels file %s: %w", path, writeErr) + } + logInfo(fmt.Sprintf("Labels file not found, created empty file at %s.", path)) + return nil + } + return fmt.Errorf("stat labels file %s: %w", path, err) +} + +// parseList splits whitespace separated values into a slice. +func parseList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + return strings.Fields(raw) +} + +// runAgent executes the reviewer agent command and captures output streams. +func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (string, string, error) { + cmd, err := agent.BuildCommand(ctx, inv) + if err != nil { + return "", "", err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + + if err = cmd.Run(); err != nil { + return stdout.String(), stderr.String(), fmt.Errorf("%s invocation failed: %w", agent.Name(), err) + } + + return stdout.String(), stderr.String(), nil +} + +// fileExists returns true when a non-zero length file exists at path. +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if info.IsDir() { + return false + } + return info.Size() > 0 +} + +// logInfo prints informational messages prefixed for clarity. +func logInfo(msg string) { + fmt.Printf("[security-reviewer] %s\n", msg) +} + +// logError prints error messages prefixed for clarity. +func logError(err error) { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + fmt.Fprintf(os.Stderr, "[security-reviewer] ERROR: %s (%s)\n", pathErr.Path, pathErr.Err) + return + } + fmt.Fprintf(os.Stderr, "[security-reviewer] ERROR: %s\n", err) +} diff --git a/agents/security-reviewer/go.mod b/agents/security-reviewer/go.mod new file mode 100644 index 000000000..44ea69c34 --- /dev/null +++ b/agents/security-reviewer/go.mod @@ -0,0 +1,3 @@ +module github.com/docker/mcp-registry/agents/security-reviewer + +go 1.25.3 diff --git a/agents/security-reviewer/go.sum b/agents/security-reviewer/go.sum new file mode 100644 index 000000000..e69de29bb diff --git a/agents/security-reviewer/litellm.config.yaml b/agents/security-reviewer/litellm.config.yaml new file mode 100644 index 000000000..20777350d --- /dev/null +++ b/agents/security-reviewer/litellm.config.yaml @@ -0,0 +1,20 @@ +model_list: + - model_name: claude-sonnet-4-5-20250929 + litellm_params: + model: anthropic/claude-sonnet-4-5-20250929 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: claude-haiku-4-5-20251001 + litellm_params: + model: anthropic/claude-haiku-4-5-20251001 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: claude-opus-4-1-20250805 + litellm_params: + model: anthropic/claude-opus-4-1-20250805 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: gpt-5-codex + litellm_params: + model: openai/gpt-5-codex + api_key: os.environ/OPENAI_API_KEY + +litellm_settings: + master_key: "sk-compose-clients" diff --git a/agents/security-reviewer/prompt.md b/agents/security-reviewer/prompt.md new file mode 100644 index 000000000..80f899d97 --- /dev/null +++ b/agents/security-reviewer/prompt.md @@ -0,0 +1,41 @@ +# Docker MCP Security Review Instructions + +Mode: $MODE_LABEL + +$MODE_SUMMARY + +Repository metadata: +- Target label: $TARGET_LABEL +- Repository path: $REPOSITORY_PATH +- Head commit: $HEAD_COMMIT +- Base commit: $BASE_COMMIT +- Commit range: $COMMIT_RANGE + +Core analysis directives: +- $GIT_DIFF_HINT +- Hunt aggressively for intentionally malicious behavior (exfiltration, + persistence, destructive payloads) in addition to accidental security bugs. +- Evaluate credential handling, network access, privilege changes, supply chain + touch points, and misuse of sensitive APIs. +- Use only the tools provided (git, ripgrep, jq, etc.); outbound network access + is unavailable. +- Keep any files you create within /workspace or $REPOSITORY_PATH. + +Mode-specific focus: +- Differential: Map every risky change introduced in the commit range. Call out + suspicious files, shell commands, or configuration shifts. Note beneficial + security hardening too. +- Full: Examine the entire repository snapshot. Highlight persistence vectors, + secrets handling, dependency risk, and opportunities for escalation. + +Report expectations: +- Structure your findings using `/opt/security-reviewer/report-template.md`. +- Save the final report to $REPORT_PATH. +- Articulate severity, impact, evidence, and remediation for each issue. + +Labeling guidance: +- Write labels to $LABELS_PATH, one per line. +- Emit exactly one overall risk label in the form `security-risk:` where + `` is one of `critical`, `high`, `medium`, `low`, or `info`. +- Align the chosen label with the overall risk level declared in the report. +- Leave $LABELS_PATH empty only if the review cannot be completed. diff --git a/agents/security-reviewer/report-template.md b/agents/security-reviewer/report-template.md new file mode 100644 index 000000000..f5fcf9078 --- /dev/null +++ b/agents/security-reviewer/report-template.md @@ -0,0 +1,37 @@ +# Security Review Report + +## Executive Summary +[One to two paragraphs summarizing the repository's security posture.] + +## Scope Summary +- **Review Mode:** [Differential/Full] +- **Repository:** [URL or path] +- **Head Commit:** [SHA] +- **Base Commit:** [SHA or N/A] +- **Commit Range:** [base...head or single commit] +- **Overall Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] + +## Detailed Findings +### [Finding title — severity] +- **Impact:** [Describe the risk to users or systems.] +- **Evidence:** [Point to files, commits, or code snippets.] +- **Recommendation:** [Explain how to mitigate or remediate.] + +### [Finding title — severity] +- **Impact:** [...] +- **Evidence:** [...] +- **Recommendation:** [...] + +## Defense-In-Depth Observations +- [Optional notes about positive security patterns or remaining gaps.] + +## Recommended Labels +- [Label name — justification] +- [Label name — justification] + +## Next Steps +- [Follow-up task or owner] +- [Timeline or priority guidance] + +## Conclusion +[Final remarks and call-outs for reviewers.] diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go new file mode 100644 index 000000000..1e6ab4d5f --- /dev/null +++ b/cmd/security-reviewer/main.go @@ -0,0 +1,545 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +const ( + composeFileName = "compose.yml" + promptFileName = "prompt.md" + reportFileName = "report.md" + labelsFileName = "labels.txt" + repositoryDirName = "repository" + dockerExecutable = "docker" + gitExecutable = "git" + projectPrefix = "security-reviewer" + agentService = "reviewer" + composeRelativePath = "agents/security-reviewer" + containerPromptPath = "/input/prompt.md" + containerRepositoryPath = "/input/repository" + containerReportPath = "/output/report.md" + containerLabelsPath = "/output/labels.txt" + + envAnthropicAPIKey = "ANTHROPIC_API_KEY" + envOpenAIAPIKey = "OPENAI_API_KEY" + + agentNameClaude = "claude" + agentNameCodex = "codex" +) + +// ReviewMode enumerates supported security review modes. +type ReviewMode string + +const ( + // ReviewModeFull requests a full repository audit. + ReviewModeFull ReviewMode = "full" + // ReviewModeDiff requests a differential audit between two commits. + ReviewModeDiff ReviewMode = "diff" +) + +// options stores parsed CLI arguments. +type options struct { + // Agent selects the underlying reviewer agent implementation. + Agent string + // Mode is the requested review mode to execute. + Mode ReviewMode + // Repository is the Git repository URL or filesystem path. + Repository string + // HeadSHA is the commit under audit. + HeadSHA string + // BaseSHA is the comparison commit for differential reviews. + BaseSHA string + // TargetLabel is an optional human friendly descriptor. + TargetLabel string + // OutputPath is the destination for the final report. + OutputPath string + // LabelsOutput is the destination for the label list produced by the reviewer. + LabelsOutput string + // Model optionally overrides the reviewer model selection. + Model string + // AllowedTools optionally overrides the set of allowed tools. + AllowedTools string + // ExtraArgs optionally appends raw arguments to the agent CLI. + ExtraArgs string + // KeepWorkdir preserves the temporary workspace when true. + KeepWorkdir bool +} + +var ( + flagAgent string + flagMode string + flagRepo string + flagHead string + flagBase string + flagTarget string + flagOutput string + flagLabels string + flagModel string + flagAllowed string + flagExtraArgs string + flagKeepWorkdir bool +) + +var rootCmd = &cobra.Command{ + Use: "security-reviewer", + Short: "Run the security reviewer compose workflow", + RunE: func(cmd *cobra.Command, args []string) error { + agent := strings.ToLower(strings.TrimSpace(flagAgent)) + if agent == "" { + agent = agentNameClaude + } + if agent != agentNameClaude && agent != agentNameCodex { + return fmt.Errorf("invalid agent %q (supported: %s, %s)", flagAgent, agentNameClaude, agentNameCodex) + } + + modeValue := strings.ToLower(strings.TrimSpace(flagMode)) + if modeValue == "" { + modeValue = string(ReviewModeDiff) + } + var mode ReviewMode + switch modeValue { + case string(ReviewModeDiff): + mode = ReviewModeDiff + case string(ReviewModeFull): + mode = ReviewModeFull + default: + return fmt.Errorf("unknown review mode %q (supported: %s, %s)", flagMode, ReviewModeDiff, ReviewModeFull) + } + + labelsOutput := strings.TrimSpace(flagLabels) + if labelsOutput == "" { + labelsOutput = deriveDefaultLabelsPath(flagOutput) + } + + opts := options{ + Agent: agent, + Mode: mode, + Repository: strings.TrimSpace(flagRepo), + HeadSHA: strings.TrimSpace(flagHead), + BaseSHA: strings.TrimSpace(flagBase), + TargetLabel: strings.TrimSpace(flagTarget), + OutputPath: flagOutput, + LabelsOutput: labelsOutput, + Model: strings.TrimSpace(flagModel), + AllowedTools: strings.TrimSpace(flagAllowed), + ExtraArgs: strings.TrimSpace(flagExtraArgs), + KeepWorkdir: flagKeepWorkdir, + } + + if opts.Repository == "" { + return errors.New("--repo is required") + } + if opts.HeadSHA == "" { + return errors.New("--head is required") + } + if opts.Mode == ReviewModeDiff && opts.BaseSHA == "" { + return errors.New("--base is required when mode=diff") + } + + ctx := cmd.Context() + return run(ctx, opts) + }, +} + +func init() { + rootCmd.Flags().StringVar(&flagAgent, "agent", agentNameClaude, "Reviewer agent to use (claude or codex).") + rootCmd.Flags().StringVar(&flagMode, "mode", string(ReviewModeDiff), "Review mode: diff or full.") + rootCmd.Flags().StringVar(&flagRepo, "repo", "", "Git repository URL or local path to review.") + rootCmd.Flags().StringVar(&flagHead, "head", "", "Head commit SHA to review.") + rootCmd.Flags().StringVar(&flagBase, "base", "", "Base commit SHA for differential reviews.") + rootCmd.Flags().StringVar(&flagTarget, "target-label", "", "Human readable identifier for the target.") + rootCmd.Flags().StringVar(&flagOutput, "output", "security-review.md", "Destination for the rendered report.") + rootCmd.Flags().StringVar(&flagLabels, "labels-output", "", "Destination for the labels file (defaults alongside the report).") + rootCmd.Flags().StringVar(&flagModel, "model", "", "Override the reviewer model for the selected agent.") + rootCmd.Flags().StringVar(&flagAllowed, "allowed-tools", "", "Override the allowed tool list for the reviewer agent.") + rootCmd.Flags().StringVar(&flagExtraArgs, "extra-args", "", "Additional arguments passed to the reviewer agent.") + rootCmd.Flags().BoolVar(&flagKeepWorkdir, "keep-workdir", false, "Keep the temporary workspace after completion.") + + _ = rootCmd.MarkFlagRequired("repo") + _ = rootCmd.MarkFlagRequired("head") +} + +// main is the entry point for the security reviewer CLI. +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + rootCmd.SilenceUsage = true + if err := rootCmd.ExecuteContext(ctx); err != nil { + exitWithError(err) + } +} + +// run coordinates workspace preparation, compose execution, and cleanup. +func run(ctx context.Context, opts options) error { + // Make sure LiteLLM can authenticate before we stage any work. + switch opts.Agent { + case "claude": + if _, ok := os.LookupEnv(envAnthropicAPIKey); !ok { + return errors.New("ANTHROPIC_API_KEY environment variable is required for the Claude agent") + } + case "codex": + if _, ok := os.LookupEnv(envOpenAIAPIKey); !ok { + return errors.New("OPENAI_API_KEY environment variable is required for the Codex agent") + } + } + + // Prepare a temporary workspace to stage inputs and outputs. + workdir, err := os.MkdirTemp("", fmt.Sprintf("security-reviewer-%s-", opts.Agent)) + if err != nil { + return fmt.Errorf("create temporary directory: %w", err) + } + + if !opts.KeepWorkdir { + defer os.RemoveAll(workdir) + } else { + fmt.Printf("Temporary workspace preserved at %s\n", workdir) + } + + // Create standard input and output directories for the container mounts. + inputDir := filepath.Join(workdir, "input") + outputDir := filepath.Join(workdir, "output") + if err = os.MkdirAll(inputDir, 0o755); err != nil { + return fmt.Errorf("create input directory: %w", err) + } + if err = os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + // Materialize the repository commits required for the review. + repositoryDir := filepath.Join(inputDir, repositoryDirName) + if err = prepareRepository(ctx, opts, repositoryDir); err != nil { + return err + } + + // Render the prompt template specific to this review. + promptPath := filepath.Join(inputDir, promptFileName) + if err = renderPrompt(opts, promptPath); err != nil { + return err + } + + // Launch the compose project and wait for the reviewer to finish. + if err = runCompose(ctx, opts, workdir, inputDir, outputDir); err != nil { + return err + } + + // Copy the generated artifacts back to the requested destinations. + reportPath := filepath.Join(outputDir, reportFileName) + labelsPath := filepath.Join(outputDir, labelsFileName) + if _, err = os.Stat(reportPath); err != nil { + return fmt.Errorf("review report not produced: %w", err) + } + if _, err = os.Stat(labelsPath); err != nil { + return fmt.Errorf("labels file not produced: %w", err) + } + + if err = copyFile(reportPath, opts.OutputPath); err != nil { + return err + } + if err = copyFile(labelsPath, opts.LabelsOutput); err != nil { + return err + } + + fmt.Printf("Security review report copied to %s\n", opts.OutputPath) + fmt.Printf("Security review labels copied to %s\n", opts.LabelsOutput) + return nil +} + +// parseFlags parses and validates CLI arguments. +// normalizeMode returns a canonical ReviewMode value. +func normalizeMode(raw string) (ReviewMode, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(ReviewModeDiff), "differential": + return ReviewModeDiff, nil + case string(ReviewModeFull): + return ReviewModeFull, nil + default: + return "", fmt.Errorf("invalid review mode: %s", raw) + } +} + +// deriveDefaultLabelsPath produces a labels output path near the report path. +func deriveDefaultLabelsPath(reportPath string) string { + reportPath = strings.TrimSpace(reportPath) + if reportPath == "" { + return "security-review-labels.txt" + } + dir := filepath.Dir(reportPath) + base := filepath.Base(reportPath) + idx := strings.LastIndex(base, ".") + if idx > 0 { + base = base[:idx] + } + if strings.TrimSpace(base) == "" { + base = "security-review" + } + return filepath.Join(dir, base+"-labels.txt") +} + +// modeLabel returns a human readable label for the selected review mode. +func modeLabel(mode ReviewMode) string { + switch mode { + case ReviewModeDiff: + return "Differential" + case ReviewModeFull: + return "Full" + default: + return "Unknown" + } +} + +// modeSummary returns a short explanation of the review mode. +func modeSummary(mode ReviewMode) string { + switch mode { + case ReviewModeDiff: + return "You are reviewing the changes introduced between the base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." + case ReviewModeFull: + return "You are auditing the repository snapshot at the provided head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." + default: + return "The review mode is unknown." + } +} + +// sanitizeName converts arbitrary text into a slug. +func sanitizeName(text string) string { + lower := strings.ToLower(text) + pattern := regexp.MustCompile(`[^a-z0-9]+`) + cleaned := pattern.ReplaceAllString(lower, "-") + trimmed := strings.Trim(cleaned, "-") + if trimmed == "" { + return "target" + } + return trimmed +} + +// prepareRepository clones the repository and materializes commits for review. + +func prepareRepository(ctx context.Context, opts options, repositoryDir string) error { + parentDir := filepath.Dir(repositoryDir) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("create repository parent directory: %w", err) + } + if err := os.RemoveAll(repositoryDir); err != nil { + return fmt.Errorf("reset repository directory: %w", err) + } + + if err := runCommand(ctx, "", gitExecutable, "clone", opts.Repository, repositoryDir); err != nil { + return fmt.Errorf("clone repository: %w", err) + } + + if err := ensureCommit(ctx, repositoryDir, opts.HeadSHA); err != nil { + return err + } + if err := runCommand(ctx, repositoryDir, gitExecutable, "checkout", "--detach", opts.HeadSHA); err != nil { + return fmt.Errorf("checkout head commit: %w", err) + } + + if opts.Mode == ReviewModeDiff { + if err := ensureCommit(ctx, repositoryDir, opts.BaseSHA); err != nil { + return err + } + } + + return nil +} + +// renderPrompt generates the review prompt rendered with scope details. +func renderPrompt(opts options, promptPath string) error { + templatePath := filepath.Join(composeRelativePath, promptFileName) + data, err := os.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("read prompt template: %w", err) + } + targetLabel := opts.TargetLabel + if strings.TrimSpace(targetLabel) == "" { + targetLabel = "Not provided" + } + headCommit := opts.HeadSHA + if strings.TrimSpace(headCommit) == "" { + headCommit = "Not provided" + } + baseCommit := "Not applicable" + commitRange := "Not applicable" + gitDiffHint := "Audit the entire working tree at the head commit." + if opts.Mode == ReviewModeDiff { + baseCommit = opts.BaseSHA + if strings.TrimSpace(baseCommit) == "" { + baseCommit = "Not provided" + } + commitRange = fmt.Sprintf("%s...%s", opts.BaseSHA, opts.HeadSHA) + gitDiffHint = fmt.Sprintf("Run `git diff %s...%s` (and related commands) inside %s to inspect the change set.", opts.BaseSHA, opts.HeadSHA, containerRepositoryPath) + } + replacer := strings.NewReplacer( + "$MODE_LABEL", modeLabel(opts.Mode), + "$MODE_SUMMARY", modeSummary(opts.Mode), + "$TARGET_LABEL", targetLabel, + "$REPOSITORY_PATH", containerRepositoryPath, + "$HEAD_COMMIT", headCommit, + "$BASE_COMMIT", baseCommit, + "$COMMIT_RANGE", commitRange, + "$GIT_DIFF_HINT", gitDiffHint, + "$REPORT_PATH", containerReportPath, + "$LABELS_PATH", containerLabelsPath, + ) + prompt := replacer.Replace(string(data)) + return os.WriteFile(promptPath, []byte(prompt), 0o644) +} + +// ensureCommit verifies that a commit exists locally, fetching if needed. +func ensureCommit(ctx context.Context, repoDir, sha string) error { + if sha == "" { + return nil + } + if err := runCommand(ctx, repoDir, gitExecutable, "rev-parse", "--verify", sha); err == nil { + return nil + } + if err := runCommand(ctx, repoDir, gitExecutable, "fetch", "origin", sha); err != nil { + return fmt.Errorf("fetch commit %s: %w", sha, err) + } + if err := runCommand(ctx, repoDir, gitExecutable, "rev-parse", "--verify", sha); err != nil { + return fmt.Errorf("verify commit %s: %w", sha, err) + } + return nil +} + +// copyFile copies a file from src to dst, creating parent directories. +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("read file %s: %w", src, err) + } + if err = os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("create directory for %s: %w", dst, err) + } + return os.WriteFile(dst, data, 0o644) +} + +// runCompose executes the docker compose workflow for the review. +func runCompose(ctx context.Context, opts options, workdir, inputDir, outputDir string) error { + // Compose assumes relative paths, so stage a copy inside the temp workspace. + composeDir := filepath.Join(workdir, composeRelativePath) + if err := copyDir(composeRelativePath, composeDir); err != nil { + return err + } + + env := buildComposeEnv(opts, inputDir, outputDir) + up := exec.CommandContext(ctx, dockerExecutable, "compose", "-f", composeFileName, "up", "--build", "--abort-on-container-exit", "--exit-code-from", agentService) + up.Dir = composeDir + up.Env = env + up.Stdout = os.Stdout + up.Stderr = os.Stderr + + down := exec.CommandContext(context.Background(), dockerExecutable, "compose", "-f", composeFileName, "down", "--volumes", "--remove-orphans") + down.Dir = composeDir + down.Env = env + + if err := up.Run(); err != nil { + _ = down.Run() + return fmt.Errorf("docker compose up: %w", err) + } + if err := down.Run(); err != nil { + return fmt.Errorf("docker compose down: %w", err) + } + return nil +} + +// buildComposeEnv prepares environment variables for docker compose. +func buildComposeEnv(opts options, inputDir, outputDir string) []string { + env := os.Environ() + // Generate a stable slug to keep compose project names readable. + slug := sanitizeName(opts.TargetLabel) + if slug == "target" { + repoBase := filepath.Base(strings.TrimSuffix(opts.Repository, ".git")) + slug = sanitizeName(repoBase) + } + if slug == "" { + slug = "target" + } + projectName := fmt.Sprintf("%s-%s-%d", projectPrefix, slug, time.Now().Unix()) + env = append(env, + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", projectName), + fmt.Sprintf("REVIEW_AGENT=%s", opts.Agent), + fmt.Sprintf("REVIEW_MODE=%s", opts.Mode), + fmt.Sprintf("REVIEW_HEAD_SHA=%s", opts.HeadSHA), + fmt.Sprintf("REVIEW_BASE_SHA=%s", opts.BaseSHA), + fmt.Sprintf("REVIEW_TARGET_LABEL=%s", opts.TargetLabel), + fmt.Sprintf("REVIEW_PROMPT_PATH=%s", containerPromptPath), + fmt.Sprintf("REVIEW_REPOSITORY_PATH=%s", containerRepositoryPath), + fmt.Sprintf("REVIEW_REPORT_PATH=%s", containerReportPath), + fmt.Sprintf("REVIEW_LABELS_PATH=%s", containerLabelsPath), + fmt.Sprintf("REVIEW_INPUT_PATH=%s", inputDir), + fmt.Sprintf("REVIEW_OUTPUT_PATH_HOST=%s", outputDir), + ) + if opts.Model != "" { + // Route custom models to the right environment variable per agent. + switch strings.ToLower(opts.Agent) { + case agentNameClaude: + env = append(env, fmt.Sprintf("CLAUDE_REVIEW_MODEL=%s", opts.Model)) + case agentNameCodex: + env = append(env, fmt.Sprintf("CODEX_REVIEW_MODEL=%s", opts.Model)) + } + } + if opts.AllowedTools != "" { + env = append(env, fmt.Sprintf("REVIEW_AGENT_ALLOWED_TOOLS=%s", opts.AllowedTools)) + } + if opts.ExtraArgs != "" { + env = append(env, fmt.Sprintf("REVIEW_AGENT_EXTRA_ARGS=%s", opts.ExtraArgs)) + } + if key := strings.TrimSpace(os.Getenv(envAnthropicAPIKey)); key != "" { + env = append(env, fmt.Sprintf("%s=%s", envAnthropicAPIKey, key)) + } + if key := strings.TrimSpace(os.Getenv(envOpenAIAPIKey)); key != "" { + env = append(env, fmt.Sprintf("%s=%s", envOpenAIAPIKey, key)) + } + return env +} + +// copyDir performs a recursive directory copy. +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode()) + }) +} + +// runCommand executes a command within an optional directory. +func runCommand(ctx context.Context, dir, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// exitWithError prints an error and terminates the process. +func exitWithError(err error) { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) +} diff --git a/go.mod b/go.mod index 32f974adb..ff589edb2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/google/go-github/v70 v70.0.0 github.com/mark3labs/mcp-go v0.25.0 + github.com/spf13/cobra v1.8.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -25,6 +26,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -35,6 +37,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sync v0.12.0 // indirect diff --git a/go.sum b/go.sum index 493a8b02c..24ad4f3a2 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,7 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -55,6 +56,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -84,8 +87,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/prompts/security-review-diff.txt b/prompts/security-review-diff.txt deleted file mode 100644 index 3dfe61fb3..000000000 --- a/prompts/security-review-diff.txt +++ /dev/null @@ -1,19 +0,0 @@ -You are assisting the Docker MCP Registry maintainers with a pull request security evaluation. -Depending on the pull request content, review one or both of the following: - -1. Updated commit pins for existing local servers. These targets are documented in summary.md (section: Updated Commit Pins) and - their upstream diffs are available under `/tmp/security-review/pins//`. -2. Newly added local servers. These targets are documented in summary.md (section: New Local Servers) and their source trees are - checked out under `/tmp/security-review/new//repo`. - -Investigate the upstream changes at the pinned commits for signs of malicious or high-risk behavior such as credential exfiltration, -unauthorized network activity, privilege escalation, persistence mechanisms, or logic that deviates from the server's documented purpose. - -Produce a single Markdown report using the template in templates/security-review-diff.md. -Critical instructions: -- Write the final report to `/tmp/security-review.md` only. -- Apply `security:blocked` **only** when the final risk is HIGH or CRITICAL via `gh pr edit`. -- Apply exactly one of these labels via `gh pr edit`: `security:risk:critical`, `security:risk:high`, `security:risk:medium`, - `security:risk:low`, `security:risk:info`. -- Use fully qualified references when mentioning upstream issues or pull requests (`owner/repo#number`). -- Be precise, constructive, and actionable in your feedback. diff --git a/prompts/security-review-full.txt b/prompts/security-review-full.txt deleted file mode 100644 index b7318645f..000000000 --- a/prompts/security-review-full.txt +++ /dev/null @@ -1,10 +0,0 @@ -You are performing a scheduled security audit for the Docker MCP Registry. -Review every server described in full-audit-summary.md. Each server has its repository checkout in `/tmp/full-audit//repo` with -metadata captured in the summary file. - -Focus on supply-chain and security risks such as credential exfiltration, unauthorized network activity, privilege escalation, or -persistence mechanisms. - -Produce a Markdown report using the template in templates/security-review-full.md. -Critical instructions: -- Write the report to `/tmp/security-review.md` only. diff --git a/templates/security-review-diff.md b/templates/security-review-diff.md deleted file mode 100644 index c82ed259d..000000000 --- a/templates/security-review-diff.md +++ /dev/null @@ -1,27 +0,0 @@ -# Security Analysis Report - -## Executive Summary -[Overall assessment across all reviewed targets.] - -## Overall Security Risk Assessment -**Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] -[One paragraph justification.] - -

Per-Target Findings

- -### [Server name] -- **Repository:** [URL] -- **Context:** [Updated commit pin / New local server] -- **Previous Commit:** [sha or N/A] -- **New Commit:** [sha] -- **Risk Assessment:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] -- **Findings:** - - [Finding title — severity — description, impact, recommendation] - -
- -## Areas for Follow-Up -[Items requiring additional attention.] - -## Conclusion -[Closing remarks.] diff --git a/templates/security-review-full.md b/templates/security-review-full.md deleted file mode 100644 index 9e092d358..000000000 --- a/templates/security-review-full.md +++ /dev/null @@ -1,25 +0,0 @@ -# Security Analysis Report - -## Executive Summary -[Overall summary.] - -## Overall Security Risk Assessment -**Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] -[Justification.] - -

Per-Server Findings

- -### [Server name] -- **Repository:** [URL] -- **Commit:** [sha] -- **Risk Assessment:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] -- **Findings:** - - [Finding title — severity — description, impact, recommendation] - -
- -## Areas for Follow-Up -[Items requiring additional investigation.] - -## Conclusion -[Closing remarks.] From 47f9dfebd375e454e84bbbe42028b4508a8e971c Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sat, 25 Oct 2025 23:06:38 -0600 Subject: [PATCH 07/29] ci: reorder security-reviewer report template Signed-off-by: Jacob Howard --- agents/security-reviewer/report-template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agents/security-reviewer/report-template.md b/agents/security-reviewer/report-template.md index f5fcf9078..f2be42fe3 100644 --- a/agents/security-reviewer/report-template.md +++ b/agents/security-reviewer/report-template.md @@ -1,8 +1,5 @@ # Security Review Report -## Executive Summary -[One to two paragraphs summarizing the repository's security posture.] - ## Scope Summary - **Review Mode:** [Differential/Full] - **Repository:** [URL or path] @@ -11,6 +8,9 @@ - **Commit Range:** [base...head or single commit] - **Overall Risk Level:** [CRITICAL/HIGH/MEDIUM/LOW/INFO] +## Executive Summary +[One to two paragraphs summarizing the repository's security posture.] + ## Detailed Findings ### [Finding title — severity] - **Impact:** [Describe the risk to users or systems.] From 6cc2f111b1e216020adf63e338b544d6ecf3dc45 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sat, 25 Oct 2025 23:07:06 -0600 Subject: [PATCH 08/29] ci: fixup security-reviewer label handling Signed-off-by: Jacob Howard --- .github/workflows/security-review-diff.yaml | 48 +++++++++++++++++++-- agents/security-reviewer/prompt.md | 2 + 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security-review-diff.yaml b/.github/workflows/security-review-diff.yaml index a1a34d273..15d4b32e2 100644 --- a/.github/workflows/security-review-diff.yaml +++ b/.github/workflows/security-review-diff.yaml @@ -30,8 +30,8 @@ jobs: issues: write env: - SECURITY_BLOCK_LABEL: "security:blocked" - SECURITY_RISK_LABEL_PREFIX: "security:risk:" + SECURITY_BLOCK_LABEL: "security-blocked" + SECURITY_RISK_LABEL_PREFIX: "security-risk:" steps: - name: Checkout repository @@ -228,7 +228,49 @@ jobs: popd >/dev/null mkdir -p "${{ github.workspace }}/review-output" - cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/review-output/security-review.md" + if [ -f "$OUTPUT_ROOT/security-review.md" ]; then + cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/review-output/security-review.md" + fi + if [ -f "$OUTPUT_ROOT/labels.txt" ]; then + cp "$OUTPUT_ROOT/labels.txt" "${{ github.workspace }}/review-output/labels.txt" + fi + + - name: Apply security labels + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + labels_file="${{ github.workspace }}/review-output/labels.txt" + pr_number="${{ github.event.pull_request.number }}" + + if [ -z "$pr_number" ]; then + echo "No pull request number available; skipping label application." >&2 + exit 0 + fi + + if [ ! -f "$labels_file" ]; then + echo "Labels file not produced; skipping label application." >&2 + exit 0 + fi + + mapfile -t labels < <(grep -Ev '^\s*$' "$labels_file" || true) + if [ "${#labels[@]}" -eq 0 ]; then + echo "Labels file empty; skipping label application." >&2 + exit 0 + fi + + for label in "${labels[@]}"; do + gh label create "$label" \ + --color 0E8A16 \ + --description "Security automation label." \ + --repo "${{ github.repository }}" \ + || echo "Label $label already exists." + + gh pr edit "$pr_number" \ + --repo "${{ github.repository }}" \ + --add-label "$label" + done - name: Post security review as PR comment if: always() diff --git a/agents/security-reviewer/prompt.md b/agents/security-reviewer/prompt.md index 80f899d97..ca1131d1a 100644 --- a/agents/security-reviewer/prompt.md +++ b/agents/security-reviewer/prompt.md @@ -38,4 +38,6 @@ Labeling guidance: - Emit exactly one overall risk label in the form `security-risk:` where `` is one of `critical`, `high`, `medium`, `low`, or `info`. - Align the chosen label with the overall risk level declared in the report. +- If you identify blocking or critical issues that must halt release, also + include the label `security-blocked` on a separate line. - Leave $LABELS_PATH empty only if the review cannot be completed. From f651d2e625f4af8246e9b2efc54b45ca69fc4860 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sat, 25 Oct 2025 23:09:27 -0600 Subject: [PATCH 09/29] ci: remove stale environment variable constants Signed-off-by: Jacob Howard --- agents/security-reviewer/entrypoint/main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/agents/security-reviewer/entrypoint/main.go b/agents/security-reviewer/entrypoint/main.go index d839ee711..90579f1a0 100644 --- a/agents/security-reviewer/entrypoint/main.go +++ b/agents/security-reviewer/entrypoint/main.go @@ -29,8 +29,6 @@ const ( envReviewRepositoryPath = "REVIEW_REPOSITORY_PATH" envReviewReportPath = "REVIEW_REPORT_PATH" envReviewLabelsPath = "REVIEW_LABELS_PATH" - envClaudeReviewModel = "CLAUDE_REVIEW_MODEL" - envCodexReviewModel = "CODEX_REVIEW_MODEL" envAgentAllowedTools = "REVIEW_AGENT_ALLOWED_TOOLS" envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" envExtraDirs = "REVIEW_EXTRA_ALLOWED_DIRS" From f2fd09d1d15241582b3468e360146d184f88e7ef Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sat, 25 Oct 2025 23:45:10 -0600 Subject: [PATCH 10/29] ci: rename and simplify security-review workflows Signed-off-by: Jacob Howard --- .../workflows/security-review-changes.yaml | 378 ++++++++++++++++++ .github/workflows/security-review-diff.yaml | 307 -------------- .github/workflows/security-review-full.yaml | 127 ------ .github/workflows/security-review-manual.yaml | 132 ++++++ 4 files changed, 510 insertions(+), 434 deletions(-) create mode 100644 .github/workflows/security-review-changes.yaml delete mode 100644 .github/workflows/security-review-diff.yaml delete mode 100644 .github/workflows/security-review-full.yaml create mode 100644 .github/workflows/security-review-manual.yaml diff --git a/.github/workflows/security-review-changes.yaml b/.github/workflows/security-review-changes.yaml new file mode 100644 index 000000000..a5605ed1a --- /dev/null +++ b/.github/workflows/security-review-changes.yaml @@ -0,0 +1,378 @@ +name: Security Review (Changes) + +on: + workflow_dispatch: + inputs: + pull_request_number: + description: "Optional pull request number to review" + required: false + default: "" + # pull_request: + # types: + # - opened + # - synchronize + # - reopened + # - ready_for_review + # - labeled + +concurrency: + group: security-review-changes-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + pr-security-review: + name: Pull Request Security Review + runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + pull-requests: write + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve comparison commits + id: revision + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + base_sha="${{ github.event.pull_request.base.sha }}" + head_sha="${{ github.sha }}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then + pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) + base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') + head_sha=$(echo "$pr_json" | jq -r '.headRefOid') + fi + + if [ -n "$base_sha" ]; then + echo "base=$base_sha" >> "$GITHUB_OUTPUT" + fi + if [ -n "$head_sha" ]; then + echo "head=$head_sha" >> "$GITHUB_OUTPUT" + fi + + - name: Collect updated pin targets + id: pins + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + base_sha="${{ steps.revision.outputs.base }}" + head_sha="${{ steps.revision.outputs.head }}" + + if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + task ci -- collect-updated-pins \ + --base "$base_sha" \ + --head "$head_sha" \ + --workspace "${{ github.workspace }}" \ + --output-json pins-context.json \ + --summary-md pins-summary.md + + if [ -s pins-context.json ]; then + echo "has_targets=true" >> "$GITHUB_OUTPUT" + echo "context=pins-context.json" >> "$GITHUB_OUTPUT" + else + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + + - name: Collect new local servers + id: newservers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + base_sha="${{ steps.revision.outputs.base }}" + head_sha="${{ steps.revision.outputs.head }}" + + if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + task ci -- collect-new-servers \ + --base "$base_sha" \ + --head "$head_sha" \ + --workspace "${{ github.workspace }}" \ + --output-json new-servers-context.json \ + --summary-md new-servers-summary.md + + if [ -s new-servers-context.json ]; then + echo "has_targets=true" >> "$GITHUB_OUTPUT" + echo "context=new-servers-context.json" >> "$GITHUB_OUTPUT" + else + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + + - name: Run security reviewer (Claude) + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + set -euo pipefail + agent="claude" + + mkdir -p review-output + combined_body="review-output/security-review.body" + combined_report="review-output/security-review.md" + combined_labels="review-output/labels.txt" + + : > "$combined_body" + : > "$combined_labels" + + if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + base_commit=$(echo "$target" | jq -r '.old_commit') + head_commit=$(echo "$target" | jq -r '.new_commit') + + if [ -z "$project" ] || [ "$project" = "null" ]; then + echo "Skipping $server: missing project URL." >&2 + continue + fi + if [ -z "$base_commit" ] || [ "$base_commit" = "null" ] || [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then + echo "Skipping $server: missing commit information." >&2 + continue + fi + + report_path="review-output/${server}-diff.md" + labels_path="review-output/${server}-diff-labels.txt" + + task security-reviewer -- \ + --agent "$agent" \ + --mode diff \ + --repo "$project" \ + --base "$base_commit" \ + --head "$head_commit" \ + --target-label "$server" \ + --output "$report_path" \ + --labels-output "$labels_path" + + { + echo "## ${server} (updated pin)" + echo "" + cat "$report_path" + echo "" + } >> "$combined_body" + + if [ -s "$labels_path" ]; then + cat "$labels_path" >> "$combined_labels" + fi + done < <(jq -c '.[]' "${{ steps.pins.outputs.context }}") + fi + + if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + head_commit=$(echo "$target" | jq -r '.commit') + + if [ -z "$project" ] || [ "$project" = "null" ]; then + echo "Skipping $server: missing project URL." >&2 + continue + fi + if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then + echo "Skipping $server: missing commit information." >&2 + continue + fi + + report_path="review-output/${server}-new.md" + labels_path="review-output/${server}-new-labels.txt" + + task security-reviewer -- \ + --agent "$agent" \ + --mode full \ + --repo "$project" \ + --head "$head_commit" \ + --target-label "$server" \ + --output "$report_path" \ + --labels-output "$labels_path" + + { + echo "## ${server} (new server)" + echo "" + cat "$report_path" + echo "" + } >> "$combined_body" + + if [ -s "$labels_path" ]; then + cat "$labels_path" >> "$combined_labels" + fi + done < <(jq -c '.[]' "${{ steps.newservers.outputs.context }}") + fi + + summary_tmp="review-output/change-summary.body" + : > "$summary_tmp" + if [ -s pins-summary.md ]; then + { + echo "## Updated Commit Pins" + echo "" + cat pins-summary.md + echo "" + } >> "$summary_tmp" + fi + if [ -s new-servers-summary.md ]; then + { + echo "## New Local Servers" + echo "" + cat new-servers-summary.md + echo "" + } >> "$summary_tmp" + fi + + if [ -s "$combined_body" ] || [ -s "$summary_tmp" ]; then + { + if [ -s "$summary_tmp" ]; then + echo "# Security Review Targets" + echo "" + cat "$summary_tmp" + echo "" + fi + if [ -s "$combined_body" ]; then + echo "# Automated Security Review" + echo "" + cat "$combined_body" + fi + } > "$combined_report" + else + rm -f "$combined_report" + fi + rm -f "$combined_body" "$summary_tmp" + + if [ -s "$combined_labels" ]; then + sort -u "$combined_labels" -o "$combined_labels" + else + rm -f "$combined_labels" + fi + + - name: Apply security labels + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + labels_file="${{ github.workspace }}/review-output/labels.txt" + pr_number="${{ github.event.pull_request.number }}" + repo="${{ github.repository }}" + standard_labels=(security-blocked security-risk:critical security-risk:high security-risk:medium security-risk:low security-risk:info) + + if [ -z "$pr_number" ]; then + echo "No pull request number available; skipping label application." >&2 + exit 0 + fi + + for label in "${standard_labels[@]}"; do + gh pr edit "$pr_number" --repo "$repo" --remove-label "$label" || true + done + + if [ ! -f "$labels_file" ] || ! grep -qEv '^\s*$' "$labels_file"; then + echo "Labels file absent or empty; no labels applied." >&2 + exit 0 + fi + + mapfile -t labels < <(grep -Ev '^\s*$' "$labels_file" | sort -u) + + blocked=false + highest="" + for label in "${labels[@]}"; do + if [ "$label" = "security-blocked" ]; then + blocked=true + fi + done + + for severity in critical high medium low info; do + candidate="security-risk:${severity}" + for label in "${labels[@]}"; do + if [ "$label" = "$candidate" ]; then + highest="$candidate" + break 2 + fi + done + done + + final_labels=() + if [ "$blocked" = true ]; then + final_labels+=("security-blocked") + fi + if [ -n "$highest" ]; then + final_labels+=("$highest") + fi + + if [ "${#final_labels[@]}" -eq 0 ]; then + echo "No recognized labels to apply." >&2 + exit 0 + fi + + for label in "${final_labels[@]}"; do + if [ "$label" = "security-blocked" ]; then + gh label create "$label" \ + --color B60205 \ + --description "Security automation detected blocking issues." \ + --repo "$repo" \ + || echo "Label $label already exists." + else + gh label create "$label" \ + --color 0E8A16 \ + --description "Security automation risk assessment." \ + --repo "$repo" \ + || echo "Label $label already exists." + fi + + gh pr edit "$pr_number" --repo "$repo" --add-label "$label" + done + + - name: Post security review as PR comment + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ ! -f "${{ github.workspace }}/review-output/security-review.md" ]; then + echo "No security review report produced." + exit 0 + fi + + { + cat "${{ github.workspace }}/review-output/security-review.md" + echo "" + echo "" + } > security-review-comment.md + + comment_id=$(gh api \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '.[] | select(.body | contains("")) | .id' \ + || true) + + if [ -n "$comment_id" ]; then + gh api \ + -X PATCH \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/issues/comments/$comment_id \ + -F body=@security-review-comment.md + else + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body-file security-review-comment.md + fi diff --git a/.github/workflows/security-review-diff.yaml b/.github/workflows/security-review-diff.yaml deleted file mode 100644 index 15d4b32e2..000000000 --- a/.github/workflows/security-review-diff.yaml +++ /dev/null @@ -1,307 +0,0 @@ -name: Security Review (Diff) - -on: - workflow_dispatch: - inputs: - pull_request_number: - description: "Optional pull request number to review" - required: false - default: "" - # pull_request: - # types: - # - opened - # - synchronize - # - reopened - # - ready_for_review - # - labeled - -concurrency: - group: security-review-diff-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -jobs: - pr-security-review: - name: Pull Request Security Review - runs-on: ubuntu-24.04 - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - permissions: - contents: read - pull-requests: write - issues: write - - env: - SECURITY_BLOCK_LABEL: "security-blocked" - SECURITY_RISK_LABEL_PREFIX: "security-risk:" - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Resolve comparison commits - id: revision - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.sha }}" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then - pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) - base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') - head_sha=$(echo "$pr_json" | jq -r '.headRefOid') - fi - - if [ -n "$base_sha" ]; then - echo "base=$base_sha" >> "$GITHUB_OUTPUT" - fi - if [ -n "$head_sha" ]; then - echo "head=$head_sha" >> "$GITHUB_OUTPUT" - fi - - - name: Collect updated pin targets - id: pins - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BASE_SHA: ${{ steps.revision.outputs.base }} - HEAD_SHA: ${{ steps.revision.outputs.head }} - run: | - set -euo pipefail - base_sha="$BASE_SHA" - head_sha="$HEAD_SHA" - - if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then - echo "has_targets=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - task ci -- collect-updated-pins \ - --base "$base_sha" \ - --head "$head_sha" \ - --workspace "${{ github.workspace }}" \ - --output-json pins-context.json \ - --summary-md pins-summary.md - - if [ -s pins-context.json ]; then - echo "has_targets=true" >> "$GITHUB_OUTPUT" - echo "context=pins-context.json" >> "$GITHUB_OUTPUT" - echo "summary=pins-summary.md" >> "$GITHUB_OUTPUT" - else - echo "has_targets=false" >> "$GITHUB_OUTPUT" - fi - - - name: Collect new local servers - id: newservers - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BASE_SHA: ${{ steps.revision.outputs.base }} - HEAD_SHA: ${{ steps.revision.outputs.head }} - run: | - set -euo pipefail - base_sha="$BASE_SHA" - head_sha="$HEAD_SHA" - - if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then - echo "has_targets=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - task ci -- collect-new-servers \ - --base "$base_sha" \ - --head "$head_sha" \ - --workspace "${{ github.workspace }}" \ - --output-json new-servers-context.json \ - --summary-md new-servers-summary.md - - if [ -s new-servers-context.json ]; then - echo "has_targets=true" >> "$GITHUB_OUTPUT" - echo "context=new-servers-context.json" >> "$GITHUB_OUTPUT" - echo "summary=new-servers-summary.md" >> "$GITHUB_OUTPUT" - else - echo "has_targets=false" >> "$GITHUB_OUTPUT" - fi - - - name: Ensure security labels exist - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh label create "${SECURITY_BLOCK_LABEL}" \ - --color B60205 \ - --description "Security automation detected blocking issues." \ - || echo "Label ${SECURITY_BLOCK_LABEL} already exists." - - for risk in critical high medium low info; do - label="${SECURITY_RISK_LABEL_PREFIX}${risk}" - gh label create "$label" \ - --color 0E8A16 \ - --description "Security automation risk assessment: ${risk}." \ - || echo "Label $label already exists." - done - - - name: Remove stale security labels - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - for label in "${SECURITY_BLOCK_LABEL}" \ - "${SECURITY_RISK_LABEL_PREFIX}critical" \ - "${SECURITY_RISK_LABEL_PREFIX}high" \ - "${SECURITY_RISK_LABEL_PREFIX}medium" \ - "${SECURITY_RISK_LABEL_PREFIX}low" \ - "${SECURITY_RISK_LABEL_PREFIX}info"; do - gh pr edit "${{ github.event.pull_request.number }}" \ - --repo "${{ github.repository }}" \ - --remove-label "$label" || true - done - - - name: Prepare review context - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - run: | - set -euo pipefail - mkdir -p /tmp/security-review/pins /tmp/security-review/new - - if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then - task ci -- prepare-updated-pins \ - --context-file "${{ steps.pins.outputs.context }}" \ - --output-dir /tmp/security-review/pins - fi - - if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then - task ci -- prepare-new-servers \ - --context-file "${{ steps.newservers.outputs.context }}" \ - --output-dir /tmp/security-review/new - fi - - task ci -- compose-pr-summary \ - --pins-summary "${{ steps.pins.outputs.summary }}" \ - --new-summary "${{ steps.newservers.outputs.summary }}" \ - --output summary.md - - - name: Run security reviewer (Claude) - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - BASE_SHA: ${{ steps.revision.outputs.base }} - HEAD_SHA: ${{ steps.revision.outputs.head }} - run: | - set -euo pipefail - INPUT_ROOT="${RUNNER_TEMP}/reviewer/input" - OUTPUT_ROOT="${RUNNER_TEMP}/reviewer/output" - mkdir -p "$INPUT_ROOT" "$OUTPUT_ROOT" - cp "${{ github.workspace }}/summary.md" "$INPUT_ROOT/summary.md" - - if [ -d /tmp/security-review ]; then - mkdir -p "$INPUT_ROOT/contexts" - cp -R /tmp/security-review/. "$INPUT_ROOT/contexts" - export REVIEW_EXTRA_ALLOWED_DIRS="/input/contexts/pins /input/contexts/new" - fi - - pushd agents/security-reviewer >/dev/null - export REVIEW_INPUT_PATH="$INPUT_ROOT" - export REVIEW_OUTPUT_PATH_HOST="$OUTPUT_ROOT" - export REVIEW_SUMMARY_PATH="/input/summary.md" - export REVIEW_MODE="diff" - export REVIEW_HEAD_SHA="$HEAD_SHA" - export REVIEW_BASE_SHA="$BASE_SHA" - export REVIEW_TARGET_LABEL="PR-${{ github.event.pull_request.number || github.run_id }}" - export REVIEW_AGENT="claude" - cleanup() { docker compose down --volumes --remove-orphans || true; } - trap cleanup EXIT - docker compose up --build --abort-on-container-exit reviewer - popd >/dev/null - - mkdir -p "${{ github.workspace }}/review-output" - if [ -f "$OUTPUT_ROOT/security-review.md" ]; then - cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/review-output/security-review.md" - fi - if [ -f "$OUTPUT_ROOT/labels.txt" ]; then - cp "$OUTPUT_ROOT/labels.txt" "${{ github.workspace }}/review-output/labels.txt" - fi - - - name: Apply security labels - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - labels_file="${{ github.workspace }}/review-output/labels.txt" - pr_number="${{ github.event.pull_request.number }}" - - if [ -z "$pr_number" ]; then - echo "No pull request number available; skipping label application." >&2 - exit 0 - fi - - if [ ! -f "$labels_file" ]; then - echo "Labels file not produced; skipping label application." >&2 - exit 0 - fi - - mapfile -t labels < <(grep -Ev '^\s*$' "$labels_file" || true) - if [ "${#labels[@]}" -eq 0 ]; then - echo "Labels file empty; skipping label application." >&2 - exit 0 - fi - - for label in "${labels[@]}"; do - gh label create "$label" \ - --color 0E8A16 \ - --description "Security automation label." \ - --repo "${{ github.repository }}" \ - || echo "Label $label already exists." - - gh pr edit "$pr_number" \ - --repo "${{ github.repository }}" \ - --add-label "$label" - done - - - name: Post security review as PR comment - if: always() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - if [ ! -f "${{ github.workspace }}/review-output/security-review.md" ]; then - echo "No security review report produced." - exit 0 - fi - - { - cat "${{ github.workspace }}/review-output/security-review.md" - echo "" - echo "" - } > security-review-comment.md - - comment_id=$(gh api \ - repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ - --jq '.[] | select(.body | contains("")) | .id' \ - || true) - - if [ -n "$comment_id" ]; then - gh api \ - -X PATCH \ - -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository }}/issues/comments/$comment_id \ - -F body=@security-review-comment.md - else - gh pr comment ${{ github.event.pull_request.number }} \ - --repo ${{ github.repository }} \ - --body-file security-review-comment.md - fi diff --git a/.github/workflows/security-review-full.yaml b/.github/workflows/security-review-full.yaml deleted file mode 100644 index 83fdc568c..000000000 --- a/.github/workflows/security-review-full.yaml +++ /dev/null @@ -1,127 +0,0 @@ -name: Security Review (Full) - -on: - workflow_dispatch: - inputs: - servers: - description: "Comma-separated list of local server names to audit (leave blank for all)." - required: false - default: "" - -concurrency: - group: security-review-full-${{ github.run_id }} - cancel-in-progress: false - -jobs: - full-audit: - name: Execute Full Audit - runs-on: ubuntu-24.04 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Collect audit targets - run: | - set -euo pipefail - task ci -- collect-full-audit \ - --workspace "${{ github.workspace }}" \ - --servers "${{ github.event.inputs.servers }}" \ - --output-json audit-targets.json - - if jq -e '. | length > 0' audit-targets.json >/dev/null; then - echo "AUDIT_HAS_TARGETS=true" >> "$GITHUB_ENV" - else - echo "No audit targets identified; exiting." >&2 - echo "AUDIT_HAS_TARGETS=false" >> "$GITHUB_ENV" - fi - - - name: Prepare audit contexts - if: env.AUDIT_HAS_TARGETS == 'true' - run: | - set -euo pipefail - mkdir -p /tmp/full-audit - rm -f full-audit-summary.md - echo "# Full Audit Targets" >> full-audit-summary.md - echo "" >> full-audit-summary.md - - idx=0 - jq -c '.[]' audit-targets.json | while read -r target; do - server=$(echo "$target" | jq -r '.server') - echo "$target" > target.json - task ci -- prepare-full-audit \ - --target-file target.json \ - --output-dir /tmp/full-audit - - repo=$(echo "$target" | jq -r '.project') - commit=$(echo "$target" | jq -r '.commit') - directory=$(echo "$target" | jq -r '.directory') - if [ -z "$directory" ] || [ "$directory" = "null" ]; then - directory="(repository root)" - fi - - { - echo "## ${server}" - echo "- Repository: ${repo}" - echo "- Commit: \`${commit}\`" - echo "- Directory: ${directory}" - echo "" - } >> full-audit-summary.md - idx=$((idx+1)) - done - - echo "Prepared ${idx} audit targets." - - - name: Run security reviewer (Claude) - if: env.AUDIT_HAS_TARGETS == 'true' - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - set -euo pipefail - INPUT_ROOT="${RUNNER_TEMP}/reviewer/input" - OUTPUT_ROOT="${RUNNER_TEMP}/reviewer/output" - mkdir -p "$INPUT_ROOT" "$OUTPUT_ROOT" - cp "${{ github.workspace }}/full-audit-summary.md" "$INPUT_ROOT/summary.md" - if [ -d /tmp/full-audit ]; then - cp -R /tmp/full-audit "$INPUT_ROOT/full-audit" - export REVIEW_EXTRA_ALLOWED_DIRS="/input/full-audit" - fi - - pushd agents/security-reviewer >/dev/null - export REVIEW_INPUT_PATH="$INPUT_ROOT" - export REVIEW_OUTPUT_PATH_HOST="$OUTPUT_ROOT" - export REVIEW_SUMMARY_PATH="/input/summary.md" - export REVIEW_MODE="full" - export REVIEW_TARGET_LABEL="full-audit" - export REVIEW_AGENT="claude" - cleanup() { docker compose down --volumes --remove-orphans || true; } - trap cleanup EXIT - docker compose up --build --abort-on-container-exit reviewer - popd >/dev/null - - mkdir -p "${{ github.workspace }}/reports" - cp "$OUTPUT_ROOT/security-review.md" "${{ github.workspace }}/reports/full-audit-report.md" - - - name: Upload security reports - if: env.AUDIT_HAS_TARGETS == 'true' - uses: actions/upload-artifact@v4 - with: - name: security-reports - path: reports/ - if-no-files-found: warn diff --git a/.github/workflows/security-review-manual.yaml b/.github/workflows/security-review-manual.yaml new file mode 100644 index 000000000..dc7b704bc --- /dev/null +++ b/.github/workflows/security-review-manual.yaml @@ -0,0 +1,132 @@ +name: Security Review (Manual) + +on: + workflow_dispatch: + inputs: + servers: + description: "Comma-separated list of local server names to audit (leave blank for all)." + required: false + default: "" + +concurrency: + group: security-review-manual-${{ github.run_id }} + cancel-in-progress: false + +jobs: + full-audit: + name: Execute Full Audit + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Collect audit targets + run: | + set -euo pipefail + task ci -- collect-full-audit \ + --workspace "${{ github.workspace }}" \ + --servers "${{ github.event.inputs.servers }}" \ + --output-json audit-targets.json + + if jq -e '. | length > 0' audit-targets.json >/dev/null; then + echo "AUDIT_HAS_TARGETS=true" >> "$GITHUB_ENV" + else + echo "No audit targets identified; exiting." >&2 + echo "AUDIT_HAS_TARGETS=false" >> "$GITHUB_ENV" + fi + + - name: Run security reviewer (Claude) + if: env.AUDIT_HAS_TARGETS == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + set -euo pipefail + agent="claude" + + mkdir -p reports + combined_body="reports/full-audit.body" + combined_report="reports/full-audit-report.md" + combined_labels="reports/full-audit-labels.txt" + + : > "$combined_body" + : > "$combined_labels" + + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + head_commit=$(echo "$target" | jq -r '.commit') + + if [ -z "$project" ] || [ "$project" = "null" ]; then + echo "Skipping $server: missing project URL." >&2 + continue + fi + if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then + echo "Skipping $server: missing commit information." >&2 + continue + fi + + report_path="reports/${server}.md" + labels_path="reports/${server}-labels.txt" + + task security-reviewer -- \ + --agent "$agent" \ + --mode full \ + --repo "$project" \ + --head "$head_commit" \ + --target-label "$server" \ + --output "$report_path" \ + --labels-output "$labels_path" + + { + echo "## ${server}" + echo "" + cat "$report_path" + echo "" + } >> "$combined_body" + + if [ -s "$labels_path" ]; then + cat "$labels_path" >> "$combined_labels" + fi + done < <(jq -c '.[]' audit-targets.json) + + if [ -s "$combined_body" ]; then + { + echo "# Full Security Audit" + echo "" + cat "$combined_body" + } > "$combined_report" + else + rm -f "$combined_report" + fi + rm -f "$combined_body" + + if [ -s "$combined_labels" ]; then + sort -u "$combined_labels" -o "$combined_labels" + else + rm -f "$combined_labels" + fi + + - name: Upload security reports + if: env.AUDIT_HAS_TARGETS == 'true' + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: reports/ + if-no-files-found: warn From 35484259d00d6c3faaee9c6033d3cb58efaa0d68 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sun, 26 Oct 2025 16:41:10 -0600 Subject: [PATCH 11/29] agents/sec-rev: adjust Go build directories Signed-off-by: Jacob Howard --- agents/security-reviewer/Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agents/security-reviewer/Dockerfile b/agents/security-reviewer/Dockerfile index 047df14d0..541f88c5a 100644 --- a/agents/security-reviewer/Dockerfile +++ b/agents/security-reviewer/Dockerfile @@ -4,13 +4,17 @@ FROM golang:1.25-trixie AS gobuilder ENV CGO_ENABLED=0 +# Create and set a working directory for the code. +RUN mkdir -p /security-reviewer +WORKDIR /security-reviewer + # Copy dependency specifications and ensure they're downloaded. COPY go.mod go.sum ./ RUN go mod download # Copy entrypoint sources and build the entrypoint COPY entrypoint ./entrypoint -RUN go build -o /build/security-reviewer ./entrypoint +RUN go build -o /security-reviewer/security-reviewer ./entrypoint # Use a debian image to host the agent. @@ -48,7 +52,7 @@ RUN useradd --create-home --shell /bin/bash agent \ && install -d -o agent -g agent /output # Copy the entrypoint and report template. -COPY --from=gobuilder /build/security-reviewer /opt/security-reviewer/security-reviewer +COPY --from=gobuilder /security-reviewer/security-reviewer /opt/security-reviewer/security-reviewer COPY --chown=agent:agent report-template.md /opt/security-reviewer/report-template.md # Set the entrypoint user and working directory. From bcf2e49550c7b87045c9efd3053dd4031a0742bd Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sun, 26 Oct 2025 18:39:21 -0600 Subject: [PATCH 12/29] agents/sec-rev: refactor prompt rendering and simplify config surface Signed-off-by: Jacob Howard --- agents/security-reviewer/Dockerfile | 5 +- agents/security-reviewer/compose.yml | 17 +- agents/security-reviewer/entrypoint/claude.go | 4 + agents/security-reviewer/entrypoint/codex.go | 4 + agents/security-reviewer/entrypoint/main.go | 282 ++++++++++++------ .../{prompt.md => prompt-template.md} | 4 +- cmd/security-reviewer/main.go | 145 ++------- 7 files changed, 222 insertions(+), 239 deletions(-) rename agents/security-reviewer/{prompt.md => prompt-template.md} (89%) diff --git a/agents/security-reviewer/Dockerfile b/agents/security-reviewer/Dockerfile index 541f88c5a..3596be8e4 100644 --- a/agents/security-reviewer/Dockerfile +++ b/agents/security-reviewer/Dockerfile @@ -48,11 +48,12 @@ RUN npm install --global \ # Create a dedicated runtime user and working directories. RUN useradd --create-home --shell /bin/bash agent \ && install -d -o agent -g agent /workspace \ - && install -d -o agent -g agent /input \ - && install -d -o agent -g agent /output + && install -d -o agent -g agent /workspace/input \ + && install -d -o agent -g agent /workspace/output # Copy the entrypoint and report template. COPY --from=gobuilder /security-reviewer/security-reviewer /opt/security-reviewer/security-reviewer +COPY --chown=agent:agent prompt-template.md /opt/security-reviewer/prompt-template.md COPY --chown=agent:agent report-template.md /opt/security-reviewer/report-template.md # Set the entrypoint user and working directory. diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index 24ab99e20..d30cea0a5 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -21,22 +21,15 @@ services: REVIEW_HEAD_SHA: ${REVIEW_HEAD_SHA:-} REVIEW_BASE_SHA: ${REVIEW_BASE_SHA:-} REVIEW_TARGET_LABEL: ${REVIEW_TARGET_LABEL:-} - REVIEW_PROMPT_PATH: ${REVIEW_PROMPT_PATH:-/input/prompt.md} - REVIEW_REPOSITORY_PATH: ${REVIEW_REPOSITORY_PATH:-/input/repository} - REVIEW_REPORT_PATH: ${REVIEW_REPORT_PATH:-/output/report.md} - REVIEW_LABELS_PATH: ${REVIEW_LABELS_PATH:-/output/labels.txt} - REVIEW_AGENT_ALLOWED_TOOLS: ${REVIEW_AGENT_ALLOWED_TOOLS:-} - REVIEW_AGENT_EXTRA_ARGS: ${REVIEW_AGENT_EXTRA_ARGS:---verbose} - REVIEW_EXTRA_ALLOWED_DIRS: ${REVIEW_EXTRA_ALLOWED_DIRS:-} - REVIEW_EXTRA_ALLOWED_FILES: ${REVIEW_EXTRA_ALLOWED_FILES:-} + REVIEW_AGENT_EXTRA_ARGS: ${REVIEW_AGENT_EXTRA_ARGS:-} volumes: - type: bind - source: ${REVIEW_INPUT_PATH:?Set REVIEW_INPUT_PATH to bind the repository under review} - target: /input + source: ${REVIEW_REPOSITORY_PATH:?Set REVIEW_REPOSITORY_PATH to bind the repository under review} + target: /workspace/input/repository read_only: true - type: bind - source: ${REVIEW_OUTPUT_PATH_HOST:?Set REVIEW_OUTPUT_PATH_HOST for report output} - target: /output + source: ${REVIEW_OUTPUT_PATH:?Set REVIEW_OUTPUT_PATH for report output} + target: /workspace/output networks: - internal diff --git a/agents/security-reviewer/entrypoint/claude.go b/agents/security-reviewer/entrypoint/claude.go index 5f7f798a6..e37cda34a 100644 --- a/agents/security-reviewer/entrypoint/claude.go +++ b/agents/security-reviewer/entrypoint/claude.go @@ -9,20 +9,24 @@ import ( // claudeAgent implements reviewerAgent for Claude Code. type claudeAgent struct{} +// Name returns the stable identifier for the Claude agent implementation. func (claudeAgent) Name() string { return agentNameClaude } +// ModelEnvVar exposes the environment variable used to override the model. func (claudeAgent) ModelEnvVar() string { // Claude Code reads its target model from CLAUDE_REVIEW_MODEL. return "CLAUDE_REVIEW_MODEL" } +// DefaultAllowedTools returns the default Claude tool allowlist. func (claudeAgent) DefaultAllowedTools() string { // Mirror the default permissions granted in prior workflows. return defaultClaudeAllowedTools } +// BuildCommand constructs the Claude CLI invocation for a review run. func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { args := []string{"--print", "--output-format", "text"} if strings.TrimSpace(inv.AllowedTools) != "" { diff --git a/agents/security-reviewer/entrypoint/codex.go b/agents/security-reviewer/entrypoint/codex.go index 9f1efdba1..9ff72b7e7 100644 --- a/agents/security-reviewer/entrypoint/codex.go +++ b/agents/security-reviewer/entrypoint/codex.go @@ -10,20 +10,24 @@ import ( // codexAgent implements reviewerAgent for the OpenAI Codex CLI. type codexAgent struct{} +// Name returns the stable identifier for the Codex agent implementation. func (codexAgent) Name() string { return agentNameCodex } +// ModelEnvVar exposes the environment variable used to override Codex models. func (codexAgent) ModelEnvVar() string { // Codex shells read from CODEX_REVIEW_MODEL when provided. return "CODEX_REVIEW_MODEL" } +// DefaultAllowedTools returns the default tool allowlist for Codex. func (codexAgent) DefaultAllowedTools() string { // Codex manages tool permissions internally, so we default to an empty allowlist. return "" } +// BuildCommand constructs the Codex CLI invocation for a review run. func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { args := []string{"--quiet", "--json"} if strings.TrimSpace(inv.Model) != "" { diff --git a/agents/security-reviewer/entrypoint/main.go b/agents/security-reviewer/entrypoint/main.go index 90579f1a0..441da7459 100644 --- a/agents/security-reviewer/entrypoint/main.go +++ b/agents/security-reviewer/entrypoint/main.go @@ -15,46 +15,73 @@ import ( ) const ( + promptTemplatePath = "/opt/security-reviewer/prompt-template.md" reportTemplatePath = "/opt/security-reviewer/report-template.md" - defaultPromptPath = "/input/prompt.md" - defaultRepositoryPath = "/input/repository" - defaultReportPath = "/output/report.md" - defaultLabelsPath = "/output/labels.txt" + defaultPromptPath = "/workspace/input/prompt.md" + defaultRepositoryPath = "/workspace/input/repository" + defaultReportPath = "/workspace/output/report.md" + defaultLabelsPath = "/workspace/output/labels.txt" defaultClaudeAllowedTools = "Read,Write,Bash(git:*),Bash(mkdir),Bash(ls),Bash(cat)" defaultReviewAgent = "claude" defaultAgentWorkingDir = "/workspace" - envReviewAgent = "REVIEW_AGENT" - envReviewPromptPath = "REVIEW_PROMPT_PATH" - envReviewRepositoryPath = "REVIEW_REPOSITORY_PATH" - envReviewReportPath = "REVIEW_REPORT_PATH" - envReviewLabelsPath = "REVIEW_LABELS_PATH" - envAgentAllowedTools = "REVIEW_AGENT_ALLOWED_TOOLS" - envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" - envExtraDirs = "REVIEW_EXTRA_ALLOWED_DIRS" - envExtraFiles = "REVIEW_EXTRA_ALLOWED_FILES" - envCodexQuiet = "CODEX_QUIET_MODE" - envCodexJson = "CODEX_JSON_MODE" - envCodexWorkingDir = "CODEX_WORKDIR" + envReviewAgent = "REVIEW_AGENT" + envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" + envCodexQuiet = "CODEX_QUIET_MODE" + envCodexJson = "CODEX_JSON_MODE" + envCodexWorkingDir = "CODEX_WORKDIR" ) // ReviewMode enumerates supported security review modes. type ReviewMode string const ( + // ReviewModeFull requests a full repository audit. ReviewModeFull ReviewMode = "full" + // ReviewModeDiff requests a differential review between two commits. ReviewModeDiff ReviewMode = "diff" ) // agentInvocation captures execution hints per reviewer agent. type agentInvocation struct { - Prompt string - Model string + // Prompt is the rendered instruction text passed over stdin. + Prompt string + // Model identifies the model to invoke, when the agent supports overrides. + Model string + // AllowedTools enumerates tool permissions for agents that honor them. AllowedTools string - AllowedDirs []string + // AllowedDirs lists directories the agent should be allowed to traverse. + AllowedDirs []string + // AllowedFiles lists specific files the agent may read or write. AllowedFiles []string - ExtraArgs string - WorkingDir string + // ExtraArgs contains caller-supplied CLI arguments for the agent. + ExtraArgs string + // WorkingDir specifies the directory where the agent command executes. + WorkingDir string +} + +// promptPlaceholders stores values substituted into the static prompt template. +type promptPlaceholders struct { + // ModeLabel is the human friendly descriptor for the review mode. + ModeLabel string + // ModeSummary highlights the responsibilities for the current mode. + ModeSummary string + // TargetLabel is an identifier referencing the repository under review. + TargetLabel string + // RepositoryPath points to the checked-out repository mount. + RepositoryPath string + // HeadCommit is the commit under audit. + HeadCommit string + // BaseCommit is the comparison commit for diff reviews. + BaseCommit string + // CommitRange renders the ... spec for diff reviews. + CommitRange string + // GitDiffHint guides the agent on how to inspect the change set. + GitDiffHint string + // ReportPath denotes where the agent should write its report. + ReportPath string + // LabelsPath denotes where the agent should write labels for automation. + LabelsPath string } // main configures logging, resolves environment, and runs the selected agent. @@ -100,40 +127,32 @@ func run(ctx context.Context) error { } // Resolve concrete paths for prompt, repository, and outputs. - promptPath := strings.TrimSpace(firstNonEmpty( - mustFetchOptional(envReviewPromptPath), - defaultPromptPath, - )) - repositoryPath := strings.TrimSpace(firstNonEmpty( - mustFetchOptional(envReviewRepositoryPath), - defaultRepositoryPath, - )) - reportPath := strings.TrimSpace(firstNonEmpty( - mustFetchOptional(envReviewReportPath), - defaultReportPath, - )) - labelsPath := strings.TrimSpace(firstNonEmpty( - mustFetchOptional(envReviewLabelsPath), - defaultLabelsPath, - )) + promptPath := defaultPromptPath + repositoryPath := defaultRepositoryPath + reportPath := defaultReportPath + labelsPath := defaultLabelsPath promptPath = filepath.Clean(promptPath) repositoryPath = filepath.Clean(repositoryPath) reportPath = filepath.Clean(reportPath) labelsPath = filepath.Clean(labelsPath) - reportDir := filepath.Dir(reportPath) - labelsDir := filepath.Dir(labelsPath) // Read the rendered prompt and ensure the repository mount is present. if err = ensureDirectory(repositoryPath); err != nil { return err } - promptBytes, err := os.ReadFile(promptPath) + promptContent, err := buildPromptContent(mode, targetLabel, headSHA, baseSHA) if err != nil { - return fmt.Errorf("read prompt %s: %w", promptPath, err) + return err + } + if err = ensureParent(promptPath); err != nil { + return err + } + if err = os.WriteFile(promptPath, []byte(promptContent), 0o644); err != nil { + return fmt.Errorf("write prompt: %w", err) } - logInfo(fmt.Sprintf("Loaded prompt from %s.", promptPath)) + logInfo(fmt.Sprintf("Rendered prompt to %s.", promptPath)) // Select the reviewer implementation and build invocation parameters. agentName, err := fetchEnv(envReviewAgent, false) @@ -154,28 +173,15 @@ func run(ctx context.Context) error { model = mustFetchOptional(envName) } - allowedTools := firstNonEmpty( - mustFetchOptional(envAgentAllowedTools), - mustFetchOptional("CLAUDE_ALLOWED_TOOLS"), - agent.DefaultAllowedTools(), - ) - extraArgs := firstNonEmpty( - mustFetchOptional(envAgentExtraArgs), - mustFetchOptional("CLAUDE_EXTRA_ARGS"), - ) + allowedTools := agent.DefaultAllowedTools() + extraArgs := mustFetchOptional(envAgentExtraArgs) - allowedDirs := []string{repositoryPath, defaultAgentWorkingDir, reportDir, labelsDir} - if extraDirs := mustFetchOptional(envExtraDirs); extraDirs != "" { - allowedDirs = append(allowedDirs, parseList(extraDirs)...) - } + allowedDirs := []string{defaultAgentWorkingDir} allowedFiles := []string{reportTemplatePath, promptPath} - if extraFiles := mustFetchOptional(envExtraFiles); extraFiles != "" { - allowedFiles = append(allowedFiles, parseList(extraFiles)...) - } inv := agentInvocation{ - Prompt: string(promptBytes), + Prompt: promptContent, Model: model, AllowedTools: allowedTools, AllowedDirs: allowedDirs, @@ -240,40 +246,6 @@ func mustFetchOptional(name string) string { return value } -// firstNonEmpty returns the first non-empty string from the provided list. -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -// ensureDirectory verifies that the provided path exists and is a directory. -func ensureDirectory(path string) error { - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("stat %s: %w", path, err) - } - if !info.IsDir() { - return fmt.Errorf("expected directory at %s", path) - } - return nil -} - -// normalizeMode validates mode strings and returns canonical values. -func normalizeMode(raw string) (ReviewMode, error) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "diff", "differential": - return ReviewModeDiff, nil - case "full": - return ReviewModeFull, nil - default: - return "", fmt.Errorf("invalid REVIEW_MODE: %s", raw) - } -} - // ensureParent creates directories needed for the provided path. func ensureParent(path string) error { dir := filepath.Dir(path) @@ -302,12 +274,16 @@ func ensureLabelsFile(path string) error { return fmt.Errorf("stat labels file %s: %w", path, err) } -// parseList splits whitespace separated values into a slice. -func parseList(raw string) []string { - if strings.TrimSpace(raw) == "" { - return nil +// normalizeMode converts raw user input into a canonical ReviewMode value. +func normalizeMode(raw string) (ReviewMode, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(ReviewModeDiff), "differential": + return ReviewModeDiff, nil + case string(ReviewModeFull): + return ReviewModeFull, nil + default: + return "", fmt.Errorf("invalid review mode: %s", raw) } - return strings.Fields(raw) } // runAgent executes the reviewer agent command and captures output streams. @@ -329,6 +305,104 @@ func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (st return stdout.String(), stderr.String(), nil } +// buildPromptContent renders a concrete prompt for the selected review mode. +func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) (string, error) { + displayLabel := strings.TrimSpace(targetLabel) + if displayLabel == "" { + displayLabel = "Not provided" + } + displayHead := strings.TrimSpace(headSHA) + if displayHead == "" { + displayHead = "Not provided" + } + displayBase := "Not applicable" + commitRange := "Not applicable" + if mode == ReviewModeDiff { + cleanBase := strings.TrimSpace(baseSHA) + cleanHead := strings.TrimSpace(headSHA) + if cleanBase == "" { + displayBase = "Not provided" + } else { + displayBase = cleanBase + } + if cleanBase != "" && cleanHead != "" { + commitRange = fmt.Sprintf("%s...%s", baseSHA, headSHA) + } + } + + ph := promptPlaceholders{ + ModeLabel: modeLabel(mode), + ModeSummary: modeSummary(mode), + TargetLabel: displayLabel, + RepositoryPath: defaultRepositoryPath, + HeadCommit: displayHead, + BaseCommit: displayBase, + CommitRange: commitRange, + GitDiffHint: gitDiffHint(mode, baseSHA, headSHA), + ReportPath: defaultReportPath, + LabelsPath: defaultLabelsPath, + } + return renderPrompt(ph) +} + +// renderPrompt injects placeholder values into the prompt template. +func renderPrompt(ph promptPlaceholders) (string, error) { + templateBytes, err := os.ReadFile(promptTemplatePath) + if err != nil { + return "", fmt.Errorf("read prompt template: %w", err) + } + replacer := strings.NewReplacer( + "$MODE_LABEL", ph.ModeLabel, + "$MODE_SUMMARY", ph.ModeSummary, + "$TARGET_LABEL", ph.TargetLabel, + "$REPOSITORY_PATH", ph.RepositoryPath, + "$HEAD_COMMIT", ph.HeadCommit, + "$BASE_COMMIT", ph.BaseCommit, + "$COMMIT_RANGE", ph.CommitRange, + "$GIT_DIFF_HINT", ph.GitDiffHint, + "$REPORT_PATH", ph.ReportPath, + "$LABELS_PATH", ph.LabelsPath, + ) + return replacer.Replace(string(templateBytes)), nil +} + +// gitDiffHint conveys how the agent should inspect the repository state. +func gitDiffHint(mode ReviewMode, baseSHA, headSHA string) string { + if mode == ReviewModeDiff { + cleanBase := strings.TrimSpace(baseSHA) + cleanHead := strings.TrimSpace(headSHA) + if cleanBase == "" || cleanHead == "" { + return fmt.Sprintf("Run `git diff` inside %s to inspect the change set.", defaultRepositoryPath) + } + return fmt.Sprintf("Run `git diff %s...%s` (and related commands) inside %s to inspect the change set.", baseSHA, headSHA, defaultRepositoryPath) + } + return "Audit the entire working tree at the head commit." +} + +// modeLabel converts a review mode to a user friendly label. +func modeLabel(mode ReviewMode) string { + switch mode { + case ReviewModeDiff: + return "Differential" + case ReviewModeFull: + return "Full" + default: + return "Unknown" + } +} + +// modeSummary explains the responsibilities associated with a review mode. +func modeSummary(mode ReviewMode) string { + switch mode { + case ReviewModeDiff: + return "You are reviewing the changes introduced between the base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." + case ReviewModeFull: + return "You are auditing the repository snapshot at the provided head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." + default: + return "The review mode is unknown." + } +} + // fileExists returns true when a non-zero length file exists at path. func fileExists(path string) bool { info, err := os.Stat(path) @@ -355,3 +429,15 @@ func logError(err error) { } fmt.Fprintf(os.Stderr, "[security-reviewer] ERROR: %s\n", err) } + +// ensureDirectory verifies that the supplied path exists and is a directory. +func ensureDirectory(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat directory %s: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + return nil +} diff --git a/agents/security-reviewer/prompt.md b/agents/security-reviewer/prompt-template.md similarity index 89% rename from agents/security-reviewer/prompt.md rename to agents/security-reviewer/prompt-template.md index ca1131d1a..db97ebe62 100644 --- a/agents/security-reviewer/prompt.md +++ b/agents/security-reviewer/prompt-template.md @@ -29,7 +29,9 @@ Mode-specific focus: secrets handling, dependency risk, and opportunities for escalation. Report expectations: -- Structure your findings using `/opt/security-reviewer/report-template.md`. +- Reproduce every heading, section order, and field exactly as written in + `/opt/security-reviewer/report-template.md`; replace bracketed placeholders + with concrete content but do not add or remove sections. - Save the final report to $REPORT_PATH. - Articulate severity, impact, evidence, and remediation for each issue. diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go index 1e6ab4d5f..ba90c1237 100644 --- a/cmd/security-reviewer/main.go +++ b/cmd/security-reviewer/main.go @@ -17,20 +17,15 @@ import ( ) const ( - composeFileName = "compose.yml" - promptFileName = "prompt.md" - reportFileName = "report.md" - labelsFileName = "labels.txt" - repositoryDirName = "repository" - dockerExecutable = "docker" - gitExecutable = "git" - projectPrefix = "security-reviewer" - agentService = "reviewer" - composeRelativePath = "agents/security-reviewer" - containerPromptPath = "/input/prompt.md" - containerRepositoryPath = "/input/repository" - containerReportPath = "/output/report.md" - containerLabelsPath = "/output/labels.txt" + composeFileName = "compose.yml" + reportFileName = "report.md" + labelsFileName = "labels.txt" + repositoryDirName = "repository" + dockerExecutable = "docker" + gitExecutable = "git" + projectPrefix = "security-reviewer" + agentService = "reviewer" + composeRelativePath = "agents/security-reviewer" envAnthropicAPIKey = "ANTHROPIC_API_KEY" envOpenAIAPIKey = "OPENAI_API_KEY" @@ -69,8 +64,6 @@ type options struct { LabelsOutput string // Model optionally overrides the reviewer model selection. Model string - // AllowedTools optionally overrides the set of allowed tools. - AllowedTools string // ExtraArgs optionally appends raw arguments to the agent CLI. ExtraArgs string // KeepWorkdir preserves the temporary workspace when true. @@ -87,7 +80,6 @@ var ( flagOutput string flagLabels string flagModel string - flagAllowed string flagExtraArgs string flagKeepWorkdir bool ) @@ -133,7 +125,6 @@ var rootCmd = &cobra.Command{ OutputPath: flagOutput, LabelsOutput: labelsOutput, Model: strings.TrimSpace(flagModel), - AllowedTools: strings.TrimSpace(flagAllowed), ExtraArgs: strings.TrimSpace(flagExtraArgs), KeepWorkdir: flagKeepWorkdir, } @@ -163,7 +154,6 @@ func init() { rootCmd.Flags().StringVar(&flagOutput, "output", "security-review.md", "Destination for the rendered report.") rootCmd.Flags().StringVar(&flagLabels, "labels-output", "", "Destination for the labels file (defaults alongside the report).") rootCmd.Flags().StringVar(&flagModel, "model", "", "Override the reviewer model for the selected agent.") - rootCmd.Flags().StringVar(&flagAllowed, "allowed-tools", "", "Override the allowed tool list for the reviewer agent.") rootCmd.Flags().StringVar(&flagExtraArgs, "extra-args", "", "Additional arguments passed to the reviewer agent.") rootCmd.Flags().BoolVar(&flagKeepWorkdir, "keep-workdir", false, "Keep the temporary workspace after completion.") @@ -207,30 +197,19 @@ func run(ctx context.Context, opts options) error { fmt.Printf("Temporary workspace preserved at %s\n", workdir) } - // Create standard input and output directories for the container mounts. - inputDir := filepath.Join(workdir, "input") - outputDir := filepath.Join(workdir, "output") - if err = os.MkdirAll(inputDir, 0o755); err != nil { - return fmt.Errorf("create input directory: %w", err) - } - if err = os.MkdirAll(outputDir, 0o755); err != nil { - return fmt.Errorf("create output directory: %w", err) - } - // Materialize the repository commits required for the review. - repositoryDir := filepath.Join(inputDir, repositoryDirName) + repositoryDir := filepath.Join(workdir, repositoryDirName) if err = prepareRepository(ctx, opts, repositoryDir); err != nil { return err } - // Render the prompt template specific to this review. - promptPath := filepath.Join(inputDir, promptFileName) - if err = renderPrompt(opts, promptPath); err != nil { - return err + outputDir := filepath.Join(workdir, "output") + if err = os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) } // Launch the compose project and wait for the reviewer to finish. - if err = runCompose(ctx, opts, workdir, inputDir, outputDir); err != nil { + if err = runCompose(ctx, opts, workdir, repositoryDir, outputDir); err != nil { return err } @@ -256,19 +235,6 @@ func run(ctx context.Context, opts options) error { return nil } -// parseFlags parses and validates CLI arguments. -// normalizeMode returns a canonical ReviewMode value. -func normalizeMode(raw string) (ReviewMode, error) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case string(ReviewModeDiff), "differential": - return ReviewModeDiff, nil - case string(ReviewModeFull): - return ReviewModeFull, nil - default: - return "", fmt.Errorf("invalid review mode: %s", raw) - } -} - // deriveDefaultLabelsPath produces a labels output path near the report path. func deriveDefaultLabelsPath(reportPath string) string { reportPath = strings.TrimSpace(reportPath) @@ -287,30 +253,6 @@ func deriveDefaultLabelsPath(reportPath string) string { return filepath.Join(dir, base+"-labels.txt") } -// modeLabel returns a human readable label for the selected review mode. -func modeLabel(mode ReviewMode) string { - switch mode { - case ReviewModeDiff: - return "Differential" - case ReviewModeFull: - return "Full" - default: - return "Unknown" - } -} - -// modeSummary returns a short explanation of the review mode. -func modeSummary(mode ReviewMode) string { - switch mode { - case ReviewModeDiff: - return "You are reviewing the changes introduced between the base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." - case ReviewModeFull: - return "You are auditing the repository snapshot at the provided head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." - default: - return "The review mode is unknown." - } -} - // sanitizeName converts arbitrary text into a slug. func sanitizeName(text string) string { lower := strings.ToLower(text) @@ -354,48 +296,6 @@ func prepareRepository(ctx context.Context, opts options, repositoryDir string) return nil } -// renderPrompt generates the review prompt rendered with scope details. -func renderPrompt(opts options, promptPath string) error { - templatePath := filepath.Join(composeRelativePath, promptFileName) - data, err := os.ReadFile(templatePath) - if err != nil { - return fmt.Errorf("read prompt template: %w", err) - } - targetLabel := opts.TargetLabel - if strings.TrimSpace(targetLabel) == "" { - targetLabel = "Not provided" - } - headCommit := opts.HeadSHA - if strings.TrimSpace(headCommit) == "" { - headCommit = "Not provided" - } - baseCommit := "Not applicable" - commitRange := "Not applicable" - gitDiffHint := "Audit the entire working tree at the head commit." - if opts.Mode == ReviewModeDiff { - baseCommit = opts.BaseSHA - if strings.TrimSpace(baseCommit) == "" { - baseCommit = "Not provided" - } - commitRange = fmt.Sprintf("%s...%s", opts.BaseSHA, opts.HeadSHA) - gitDiffHint = fmt.Sprintf("Run `git diff %s...%s` (and related commands) inside %s to inspect the change set.", opts.BaseSHA, opts.HeadSHA, containerRepositoryPath) - } - replacer := strings.NewReplacer( - "$MODE_LABEL", modeLabel(opts.Mode), - "$MODE_SUMMARY", modeSummary(opts.Mode), - "$TARGET_LABEL", targetLabel, - "$REPOSITORY_PATH", containerRepositoryPath, - "$HEAD_COMMIT", headCommit, - "$BASE_COMMIT", baseCommit, - "$COMMIT_RANGE", commitRange, - "$GIT_DIFF_HINT", gitDiffHint, - "$REPORT_PATH", containerReportPath, - "$LABELS_PATH", containerLabelsPath, - ) - prompt := replacer.Replace(string(data)) - return os.WriteFile(promptPath, []byte(prompt), 0o644) -} - // ensureCommit verifies that a commit exists locally, fetching if needed. func ensureCommit(ctx context.Context, repoDir, sha string) error { if sha == "" { @@ -426,14 +326,14 @@ func copyFile(src, dst string) error { } // runCompose executes the docker compose workflow for the review. -func runCompose(ctx context.Context, opts options, workdir, inputDir, outputDir string) error { +func runCompose(ctx context.Context, opts options, workdir, repositoryDir, outputDir string) error { // Compose assumes relative paths, so stage a copy inside the temp workspace. composeDir := filepath.Join(workdir, composeRelativePath) if err := copyDir(composeRelativePath, composeDir); err != nil { return err } - env := buildComposeEnv(opts, inputDir, outputDir) + env := buildComposeEnv(opts, repositoryDir, outputDir) up := exec.CommandContext(ctx, dockerExecutable, "compose", "-f", composeFileName, "up", "--build", "--abort-on-container-exit", "--exit-code-from", agentService) up.Dir = composeDir up.Env = env @@ -455,7 +355,7 @@ func runCompose(ctx context.Context, opts options, workdir, inputDir, outputDir } // buildComposeEnv prepares environment variables for docker compose. -func buildComposeEnv(opts options, inputDir, outputDir string) []string { +func buildComposeEnv(opts options, repositoryDir, outputDir string) []string { env := os.Environ() // Generate a stable slug to keep compose project names readable. slug := sanitizeName(opts.TargetLabel) @@ -474,12 +374,8 @@ func buildComposeEnv(opts options, inputDir, outputDir string) []string { fmt.Sprintf("REVIEW_HEAD_SHA=%s", opts.HeadSHA), fmt.Sprintf("REVIEW_BASE_SHA=%s", opts.BaseSHA), fmt.Sprintf("REVIEW_TARGET_LABEL=%s", opts.TargetLabel), - fmt.Sprintf("REVIEW_PROMPT_PATH=%s", containerPromptPath), - fmt.Sprintf("REVIEW_REPOSITORY_PATH=%s", containerRepositoryPath), - fmt.Sprintf("REVIEW_REPORT_PATH=%s", containerReportPath), - fmt.Sprintf("REVIEW_LABELS_PATH=%s", containerLabelsPath), - fmt.Sprintf("REVIEW_INPUT_PATH=%s", inputDir), - fmt.Sprintf("REVIEW_OUTPUT_PATH_HOST=%s", outputDir), + fmt.Sprintf("REVIEW_REPOSITORY_PATH=%s", repositoryDir), + fmt.Sprintf("REVIEW_OUTPUT_PATH=%s", outputDir), ) if opts.Model != "" { // Route custom models to the right environment variable per agent. @@ -490,9 +386,6 @@ func buildComposeEnv(opts options, inputDir, outputDir string) []string { env = append(env, fmt.Sprintf("CODEX_REVIEW_MODEL=%s", opts.Model)) } } - if opts.AllowedTools != "" { - env = append(env, fmt.Sprintf("REVIEW_AGENT_ALLOWED_TOOLS=%s", opts.AllowedTools)) - } if opts.ExtraArgs != "" { env = append(env, fmt.Sprintf("REVIEW_AGENT_EXTRA_ARGS=%s", opts.ExtraArgs)) } From 7537df36903561735da3ce15ec909fe5c0e680de Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sun, 26 Oct 2025 19:52:22 -0600 Subject: [PATCH 13/29] agents/sec-rev: make mode concept implicit Signed-off-by: Jacob Howard --- agents/security-reviewer/compose.yml | 1 - agents/security-reviewer/entrypoint/main.go | 42 +++++----------- cmd/security-reviewer/main.go | 53 +++++++-------------- 3 files changed, 29 insertions(+), 67 deletions(-) diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index d30cea0a5..873e67bc6 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -17,7 +17,6 @@ services: OPENAI_BASE_URL: "http://litellm:4000" CODEX_REVIEW_MODEL: ${CODEX_REVIEW_MODEL:-gpt-5-codex} REVIEW_AGENT: ${REVIEW_AGENT:-claude} - REVIEW_MODE: ${REVIEW_MODE:?Set REVIEW_MODE in environment or .env} REVIEW_HEAD_SHA: ${REVIEW_HEAD_SHA:-} REVIEW_BASE_SHA: ${REVIEW_BASE_SHA:-} REVIEW_TARGET_LABEL: ${REVIEW_TARGET_LABEL:-} diff --git a/agents/security-reviewer/entrypoint/main.go b/agents/security-reviewer/entrypoint/main.go index 441da7459..a0ec54045 100644 --- a/agents/security-reviewer/entrypoint/main.go +++ b/agents/security-reviewer/entrypoint/main.go @@ -38,8 +38,8 @@ type ReviewMode string const ( // ReviewModeFull requests a full repository audit. ReviewModeFull ReviewMode = "full" - // ReviewModeDiff requests a differential review between two commits. - ReviewModeDiff ReviewMode = "diff" + // ReviewModeDifferential requests a differential review between two commits. + ReviewModeDifferential ReviewMode = "differential" ) // agentInvocation captures execution hints per reviewer agent. @@ -99,17 +99,7 @@ func main() { // run orchestrates prompt generation and agent execution. func run(ctx context.Context) error { // Parse review configuration from the environment. - modeRaw, err := fetchEnv("REVIEW_MODE", true) - if err != nil { - return err - } - mode, err := normalizeMode(modeRaw) - if err != nil { - return err - } - - requireHead := mode != ReviewModeFull - headSHA, err := fetchEnv("REVIEW_HEAD_SHA", requireHead) + headSHA, err := fetchEnv("REVIEW_HEAD_SHA", true) if err != nil { return err } @@ -117,8 +107,10 @@ func run(ctx context.Context) error { if err != nil { return err } - if mode == ReviewModeDiff && baseSHA == "" { - return errors.New("REVIEW_BASE_SHA is required when REVIEW_MODE=diff") + + mode := ReviewModeFull + if baseSHA != "" { + mode = ReviewModeDifferential } targetLabel, err := fetchEnv("REVIEW_TARGET_LABEL", false) @@ -274,18 +266,6 @@ func ensureLabelsFile(path string) error { return fmt.Errorf("stat labels file %s: %w", path, err) } -// normalizeMode converts raw user input into a canonical ReviewMode value. -func normalizeMode(raw string) (ReviewMode, error) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case string(ReviewModeDiff), "differential": - return ReviewModeDiff, nil - case string(ReviewModeFull): - return ReviewModeFull, nil - default: - return "", fmt.Errorf("invalid review mode: %s", raw) - } -} - // runAgent executes the reviewer agent command and captures output streams. func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (string, string, error) { cmd, err := agent.BuildCommand(ctx, inv) @@ -317,7 +297,7 @@ func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) ( } displayBase := "Not applicable" commitRange := "Not applicable" - if mode == ReviewModeDiff { + if mode == ReviewModeDifferential { cleanBase := strings.TrimSpace(baseSHA) cleanHead := strings.TrimSpace(headSHA) if cleanBase == "" { @@ -368,7 +348,7 @@ func renderPrompt(ph promptPlaceholders) (string, error) { // gitDiffHint conveys how the agent should inspect the repository state. func gitDiffHint(mode ReviewMode, baseSHA, headSHA string) string { - if mode == ReviewModeDiff { + if mode == ReviewModeDifferential { cleanBase := strings.TrimSpace(baseSHA) cleanHead := strings.TrimSpace(headSHA) if cleanBase == "" || cleanHead == "" { @@ -382,7 +362,7 @@ func gitDiffHint(mode ReviewMode, baseSHA, headSHA string) string { // modeLabel converts a review mode to a user friendly label. func modeLabel(mode ReviewMode) string { switch mode { - case ReviewModeDiff: + case ReviewModeDifferential: return "Differential" case ReviewModeFull: return "Full" @@ -394,7 +374,7 @@ func modeLabel(mode ReviewMode) string { // modeSummary explains the responsibilities associated with a review mode. func modeSummary(mode ReviewMode) string { switch mode { - case ReviewModeDiff: + case ReviewModeDifferential: return "You are reviewing the changes introduced between the base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." case ReviewModeFull: return "You are auditing the repository snapshot at the provided head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go index ba90c1237..8851c1ee7 100644 --- a/cmd/security-reviewer/main.go +++ b/cmd/security-reviewer/main.go @@ -34,22 +34,10 @@ const ( agentNameCodex = "codex" ) -// ReviewMode enumerates supported security review modes. -type ReviewMode string - -const ( - // ReviewModeFull requests a full repository audit. - ReviewModeFull ReviewMode = "full" - // ReviewModeDiff requests a differential audit between two commits. - ReviewModeDiff ReviewMode = "diff" -) - // options stores parsed CLI arguments. type options struct { // Agent selects the underlying reviewer agent implementation. Agent string - // Mode is the requested review mode to execute. - Mode ReviewMode // Repository is the Git repository URL or filesystem path. Repository string // HeadSHA is the commit under audit. @@ -72,7 +60,6 @@ type options struct { var ( flagAgent string - flagMode string flagRepo string flagHead string flagBase string @@ -96,20 +83,6 @@ var rootCmd = &cobra.Command{ return fmt.Errorf("invalid agent %q (supported: %s, %s)", flagAgent, agentNameClaude, agentNameCodex) } - modeValue := strings.ToLower(strings.TrimSpace(flagMode)) - if modeValue == "" { - modeValue = string(ReviewModeDiff) - } - var mode ReviewMode - switch modeValue { - case string(ReviewModeDiff): - mode = ReviewModeDiff - case string(ReviewModeFull): - mode = ReviewModeFull - default: - return fmt.Errorf("unknown review mode %q (supported: %s, %s)", flagMode, ReviewModeDiff, ReviewModeFull) - } - labelsOutput := strings.TrimSpace(flagLabels) if labelsOutput == "" { labelsOutput = deriveDefaultLabelsPath(flagOutput) @@ -117,7 +90,6 @@ var rootCmd = &cobra.Command{ opts := options{ Agent: agent, - Mode: mode, Repository: strings.TrimSpace(flagRepo), HeadSHA: strings.TrimSpace(flagHead), BaseSHA: strings.TrimSpace(flagBase), @@ -135,10 +107,6 @@ var rootCmd = &cobra.Command{ if opts.HeadSHA == "" { return errors.New("--head is required") } - if opts.Mode == ReviewModeDiff && opts.BaseSHA == "" { - return errors.New("--base is required when mode=diff") - } - ctx := cmd.Context() return run(ctx, opts) }, @@ -146,7 +114,6 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.Flags().StringVar(&flagAgent, "agent", agentNameClaude, "Reviewer agent to use (claude or codex).") - rootCmd.Flags().StringVar(&flagMode, "mode", string(ReviewModeDiff), "Review mode: diff or full.") rootCmd.Flags().StringVar(&flagRepo, "repo", "", "Git repository URL or local path to review.") rootCmd.Flags().StringVar(&flagHead, "head", "", "Head commit SHA to review.") rootCmd.Flags().StringVar(&flagBase, "base", "", "Base commit SHA for differential reviews.") @@ -173,6 +140,23 @@ func main() { // run coordinates workspace preparation, compose execution, and cleanup. func run(ctx context.Context, opts options) error { + if opts.BaseSHA != "" { + fmt.Printf( + "Starting differential security review (agent=%s head=%s base=%s target=%s)\n", + opts.Agent, + opts.HeadSHA, + opts.BaseSHA, + opts.TargetLabel, + ) + } else { + fmt.Printf( + "Starting full security review (agent=%s head=%s target=%s)\n", + opts.Agent, + opts.HeadSHA, + opts.TargetLabel, + ) + } + // Make sure LiteLLM can authenticate before we stage any work. switch opts.Agent { case "claude": @@ -287,7 +271,7 @@ func prepareRepository(ctx context.Context, opts options, repositoryDir string) return fmt.Errorf("checkout head commit: %w", err) } - if opts.Mode == ReviewModeDiff { + if opts.BaseSHA != "" { if err := ensureCommit(ctx, repositoryDir, opts.BaseSHA); err != nil { return err } @@ -370,7 +354,6 @@ func buildComposeEnv(opts options, repositoryDir, outputDir string) []string { env = append(env, fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", projectName), fmt.Sprintf("REVIEW_AGENT=%s", opts.Agent), - fmt.Sprintf("REVIEW_MODE=%s", opts.Mode), fmt.Sprintf("REVIEW_HEAD_SHA=%s", opts.HeadSHA), fmt.Sprintf("REVIEW_BASE_SHA=%s", opts.BaseSHA), fmt.Sprintf("REVIEW_TARGET_LABEL=%s", opts.TargetLabel), From b78a31ae7e156db6bc3a72e0a3fcada28f8cd062 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Sun, 26 Oct 2025 21:09:52 -0600 Subject: [PATCH 14/29] agents/sec-rev: replace LiteLLM with a custom proxy Signed-off-by: Jacob Howard --- agents/security-reviewer/Dockerfile | 39 ++- agents/security-reviewer/compose.yml | 26 +- agents/security-reviewer/litellm.config.yaml | 20 -- agents/security-reviewer/proxy/main.go | 287 ++++++++++++++++++ .../{entrypoint => reviewer}/agent.go | 0 .../{entrypoint => reviewer}/claude.go | 0 .../{entrypoint => reviewer}/codex.go | 0 .../{entrypoint => reviewer}/main.go | 0 cmd/security-reviewer/main.go | 2 +- 9 files changed, 333 insertions(+), 41 deletions(-) delete mode 100644 agents/security-reviewer/litellm.config.yaml create mode 100644 agents/security-reviewer/proxy/main.go rename agents/security-reviewer/{entrypoint => reviewer}/agent.go (100%) rename agents/security-reviewer/{entrypoint => reviewer}/claude.go (100%) rename agents/security-reviewer/{entrypoint => reviewer}/codex.go (100%) rename agents/security-reviewer/{entrypoint => reviewer}/main.go (100%) diff --git a/agents/security-reviewer/Dockerfile b/agents/security-reviewer/Dockerfile index 3596be8e4..d406ea616 100644 --- a/agents/security-reviewer/Dockerfile +++ b/agents/security-reviewer/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -# Use a golang image to build the agent entrypoint (with cgo disabled). +# Use a golang image to build the reviewer and proxy binaries (with cgo disabled). FROM golang:1.25-trixie AS gobuilder ENV CGO_ENABLED=0 @@ -12,13 +12,16 @@ WORKDIR /security-reviewer COPY go.mod go.sum ./ RUN go mod download -# Copy entrypoint sources and build the entrypoint -COPY entrypoint ./entrypoint -RUN go build -o /security-reviewer/security-reviewer ./entrypoint +# Copy reviewer and proxy sources, then build both binaries. +COPY reviewer ./reviewer +COPY proxy ./proxy +RUN mkdir -p /security-reviewer/build +RUN go build -o /security-reviewer/build/reviewer ./reviewer +RUN go build -o /security-reviewer/build/proxy ./proxy # Use a debian image to host the agent. -FROM debian:trixie-slim +FROM debian:trixie-slim AS reviewer-image # Set up configuration. ARG CLAUDE_CODE_VERSION=latest @@ -51,14 +54,30 @@ RUN useradd --create-home --shell /bin/bash agent \ && install -d -o agent -g agent /workspace/input \ && install -d -o agent -g agent /workspace/output -# Copy the entrypoint and report template. -COPY --from=gobuilder /security-reviewer/security-reviewer /opt/security-reviewer/security-reviewer +# Copy the reviewer assets and report template. +COPY --from=gobuilder /security-reviewer/build/reviewer /opt/security-reviewer/reviewer COPY --chown=agent:agent prompt-template.md /opt/security-reviewer/prompt-template.md COPY --chown=agent:agent report-template.md /opt/security-reviewer/report-template.md -# Set the entrypoint user and working directory. +# Set the reviewer user and working directory. USER agent WORKDIR /workspace -# Set the entrypoint. -ENTRYPOINT ["/opt/security-reviewer/security-reviewer"] +# Set the reviewer entrypoint. +ENTRYPOINT ["/opt/security-reviewer/reviewer"] + + +# Lightweight image hosting the API proxy. +FROM debian:trixie-slim AS proxy-image + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=gobuilder /security-reviewer/build/proxy /opt/security-reviewer/proxy + +EXPOSE 4000 +ENTRYPOINT ["/opt/security-reviewer/proxy"] diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index 873e67bc6..882a0d8f3 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -2,19 +2,21 @@ services: reviewer: build: context: . + target: reviewer-image image: security-reviewer:latest depends_on: - litellm: + proxy: condition: service_healthy environment: + # Configure the reviewer CLIs to authenticate against the local proxy. # Note: We want Claude Code to use a bearer token when making API requests - # to LiteLLM, not an X-Api-Key header. If we set ANTHROPIC_API_KEY, it + # to the proxy, not an X-Api-Key header. If we set ANTHROPIC_API_KEY, it # will use an X-Api-Key header; ANTHROPIC_AUTH_TOKEN uses a bearer token. ANTHROPIC_AUTH_TOKEN: "sk-compose-clients" - ANTHROPIC_BASE_URL: "http://litellm:4000" + ANTHROPIC_BASE_URL: "http://proxy:4000/anthropic" CLAUDE_REVIEW_MODEL: ${CLAUDE_REVIEW_MODEL:-claude-sonnet-4-5-20250929} OPENAI_API_KEY: "sk-compose-clients" - OPENAI_BASE_URL: "http://litellm:4000" + OPENAI_BASE_URL: "http://proxy:4000/openai" CODEX_REVIEW_MODEL: ${CODEX_REVIEW_MODEL:-gpt-5-codex} REVIEW_AGENT: ${REVIEW_AGENT:-claude} REVIEW_HEAD_SHA: ${REVIEW_HEAD_SHA:-} @@ -32,20 +34,24 @@ services: networks: - internal - litellm: - image: ghcr.io/berriai/litellm:main-stable - command: ["--config", "/app/config.yaml", "--port", "4000"] + proxy: + build: + context: . + target: proxy-image + image: security-reviewer-proxy:latest environment: + PROXY_LISTEN_ADDR: ":4000" + PROXY_OPENAI_BASE_URL: ${PROXY_OPENAI_BASE_URL:-https://api.openai.com} + PROXY_ANTHROPIC_BASE_URL: ${PROXY_ANTHROPIC_BASE_URL:-https://api.anthropic.com} + PROXY_API_KEY: "sk-compose-clients" OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} healthcheck: - test: [ "CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1" ] + test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1"] interval: 5s timeout: 5s retries: 5 start_period: 5s - volumes: - - ./litellm.config.yaml:/app/config.yaml networks: - internal - external diff --git a/agents/security-reviewer/litellm.config.yaml b/agents/security-reviewer/litellm.config.yaml deleted file mode 100644 index 20777350d..000000000 --- a/agents/security-reviewer/litellm.config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -model_list: - - model_name: claude-sonnet-4-5-20250929 - litellm_params: - model: anthropic/claude-sonnet-4-5-20250929 - api_key: os.environ/ANTHROPIC_API_KEY - - model_name: claude-haiku-4-5-20251001 - litellm_params: - model: anthropic/claude-haiku-4-5-20251001 - api_key: os.environ/ANTHROPIC_API_KEY - - model_name: claude-opus-4-1-20250805 - litellm_params: - model: anthropic/claude-opus-4-1-20250805 - api_key: os.environ/ANTHROPIC_API_KEY - - model_name: gpt-5-codex - litellm_params: - model: openai/gpt-5-codex - api_key: os.environ/OPENAI_API_KEY - -litellm_settings: - master_key: "sk-compose-clients" diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go new file mode 100644 index 000000000..bc1430e70 --- /dev/null +++ b/agents/security-reviewer/proxy/main.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" +) + +const ( + defaultListenAddr = ":4000" + defaultOpenAIBaseURL = "https://api.openai.com" + defaultAnthropicBaseURL = "https://api.anthropic.com" + openAIInboundPrefix = "/openai/" + anthropicInboundPrefix = "/anthropic/" + healthPath = "/health/liveliness" + headerAuthorization = "Authorization" + headerAnthropicAPIKey = "X-Api-Key" +) + +// providerProxy defines how to forward requests to a specific upstream API. +type providerProxy struct { + Prefix string + Target *url.URL + HeaderName string + HeaderValue string + DisplayName string +} + +// main configures the proxy service and starts the HTTP server. +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("proxy configuration error: %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc(healthPath, handleHealth) + + mountProxy(mux, cfg.openAIProxy, cfg.clientToken) + mountProxy(mux, cfg.anthropicProxy, cfg.clientToken) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + + server := &http.Server{ + Addr: cfg.listenAddr, + Handler: withLogging(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("proxy listening on %s (OpenAI -> %s, Anthropic -> %s)", + cfg.listenAddr, cfg.openAIProxy.Target.String(), cfg.anthropicProxy.Target.String()) + + if err = server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("proxy server error: %v", err) + } +} + +// proxyConfig captures runtime settings for the reverse proxy. +type proxyConfig struct { + listenAddr string + openAIProxy providerProxy + anthropicProxy providerProxy + clientToken string +} + +// loadConfig reads environment variables and constructs the proxy configuration. +func loadConfig() (proxyConfig, error) { + listen := firstNonEmpty(os.Getenv("PROXY_LISTEN_ADDR"), defaultListenAddr) + + clientToken := strings.TrimSpace(os.Getenv("PROXY_API_KEY")) + if clientToken == "" { + return proxyConfig{}, errors.New("PROXY_API_KEY must be set") + } + + openAIBase, err := parseBaseURL(firstNonEmpty(os.Getenv("PROXY_OPENAI_BASE_URL"), defaultOpenAIBaseURL)) + if err != nil { + return proxyConfig{}, fmt.Errorf("parse OpenAI base URL: %w", err) + } + anthropicBase, err := parseBaseURL(firstNonEmpty(os.Getenv("PROXY_ANTHROPIC_BASE_URL"), defaultAnthropicBaseURL)) + if err != nil { + return proxyConfig{}, fmt.Errorf("parse Anthropic base URL: %w", err) + } + + openAIKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + anthropicKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) + + openAIProxy := providerProxy{ + Prefix: openAIInboundPrefix, + Target: openAIBase, + HeaderName: headerAuthorization, + HeaderValue: bearerValue(openAIKey), + DisplayName: "OpenAI", + } + anthropicProxy := providerProxy{ + Prefix: anthropicInboundPrefix, + Target: anthropicBase, + HeaderName: headerAnthropicAPIKey, + HeaderValue: anthropicKey, + DisplayName: "Anthropic", + } + + return proxyConfig{ + listenAddr: listen, + openAIProxy: openAIProxy, + anthropicProxy: anthropicProxy, + clientToken: clientToken, + }, nil +} + +// mountProxy attaches a provider proxy to the HTTP mux. +func mountProxy(mux *http.ServeMux, provider providerProxy, clientToken string) { + handler := buildProviderHandler(provider, clientToken) + mux.Handle(provider.Prefix, handler) +} + +// buildProviderHandler creates an HTTP handler that forwards requests to the provider. +func buildProviderHandler(provider providerProxy, clientToken string) http.Handler { + proxy := httputil.NewSingleHostReverseProxy(provider.Target) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + rewriteRequest(req, provider) + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("proxy error [%s]: %v", provider.DisplayName, err) + http.Error(w, "upstream request failed", http.StatusBadGateway) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, provider.Prefix) { + http.NotFound(w, r) + return + } + if provider.HeaderValue == "" { + log.Printf("proxy warning [%s]: request rejected due to missing API key", provider.DisplayName) + http.Error(w, "upstream API key is not configured", http.StatusServiceUnavailable) + return + } + if !validateClientToken(r.Header.Get(headerAuthorization), clientToken) { + log.Printf("proxy warning [%s]: request rejected due to missing or invalid client bearer token", provider.DisplayName) + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return + } + + proxy.ServeHTTP(w, r) + }) +} + +// rewriteRequest adjusts the outbound request before it is sent upstream. +func rewriteRequest(req *http.Request, provider providerProxy) { + req.URL.Scheme = provider.Target.Scheme + req.URL.Host = provider.Target.Host + req.Host = provider.Target.Host + + // Strip the provider prefix from the incoming path. + trimmedPath := strings.TrimPrefix(req.URL.Path, provider.Prefix) + if !strings.HasPrefix(trimmedPath, "/") { + trimmedPath = "/" + trimmedPath + } + + // Combine the target's base path with the trimmed path. + basePath := provider.Target.Path + req.URL.Path = joinURLPath(basePath, trimmedPath) + req.URL.RawPath = req.URL.EscapedPath() + + stripSensitiveHeaders(req.Header) + + if provider.HeaderName == headerAuthorization { + req.Header.Set(headerAuthorization, provider.HeaderValue) + } else if provider.HeaderName != "" { + req.Header.Set(provider.HeaderName, provider.HeaderValue) + } +} + +// stripSensitiveHeaders removes inbound authentication headers that should not propagate upstream. +func stripSensitiveHeaders(header http.Header) { + header.Del(headerAuthorization) + header.Del(headerAnthropicAPIKey) +} + +// joinURLPath concatenates base and additional path segments. +func joinURLPath(basePath, extraPath string) string { + switch { + case basePath == "" || basePath == "/": + return singleLeadingSlash(extraPath) + case extraPath == "" || extraPath == "/": + return singleLeadingSlash(basePath) + default: + return singleLeadingSlash(strings.TrimSuffix(basePath, "/") + "/" + strings.TrimPrefix(extraPath, "/")) + } +} + +// singleLeadingSlash ensures the provided path has a leading slash. +func singleLeadingSlash(path string) string { + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +// withLogging wraps the handler with structured request logging. +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + duration := time.Since(start) + remote := remoteAddr(r.Context(), r.RemoteAddr) + log.Printf("proxy request method=%s path=%s remote=%s duration=%s", + r.Method, r.URL.Path, remote, duration) + }) +} + +// remoteAddr normalizes the remote address for logging. +func remoteAddr(ctx context.Context, fallback string) string { + if peer, ok := ctx.Value(http.LocalAddrContextKey).(net.Addr); ok { + return peer.String() + } + return fallback +} + +// handleHealth responds to health check requests. +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// parseBaseURL validates and normalizes the upstream base URL. +func parseBaseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid URL %q (must include scheme and host)", raw) + } + if !strings.HasSuffix(parsed.Path, "/") { + parsed.Path += "/" + } + return parsed, nil +} + +// bearerValue formats the bearer token header. +func bearerValue(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + return "Bearer " + key +} + +// firstNonEmpty returns the first non-empty string in candidates. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func validateClientToken(headerValue, expectedToken string) bool { + if expectedToken == "" { + return false + } + parts := strings.SplitN(headerValue, " ", 2) + if len(parts) != 2 { + return false + } + if !strings.EqualFold(parts[0], "bearer") { + return false + } + return strings.TrimSpace(parts[1]) == expectedToken +} diff --git a/agents/security-reviewer/entrypoint/agent.go b/agents/security-reviewer/reviewer/agent.go similarity index 100% rename from agents/security-reviewer/entrypoint/agent.go rename to agents/security-reviewer/reviewer/agent.go diff --git a/agents/security-reviewer/entrypoint/claude.go b/agents/security-reviewer/reviewer/claude.go similarity index 100% rename from agents/security-reviewer/entrypoint/claude.go rename to agents/security-reviewer/reviewer/claude.go diff --git a/agents/security-reviewer/entrypoint/codex.go b/agents/security-reviewer/reviewer/codex.go similarity index 100% rename from agents/security-reviewer/entrypoint/codex.go rename to agents/security-reviewer/reviewer/codex.go diff --git a/agents/security-reviewer/entrypoint/main.go b/agents/security-reviewer/reviewer/main.go similarity index 100% rename from agents/security-reviewer/entrypoint/main.go rename to agents/security-reviewer/reviewer/main.go diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go index 8851c1ee7..e7fd22125 100644 --- a/cmd/security-reviewer/main.go +++ b/cmd/security-reviewer/main.go @@ -157,7 +157,7 @@ func run(ctx context.Context, opts options) error { ) } - // Make sure LiteLLM can authenticate before we stage any work. + // Ensure the credential proxy has the API keys it needs before staging work. switch opts.Agent { case "claude": if _, ok := os.LookupEnv(envAnthropicAPIKey); !ok { From 7d7b4b099120f354dfeaa7a589747ee8b92ec6a4 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 10:01:47 -0600 Subject: [PATCH 15/29] agents/sec-rev: hide healthcheck logging in proxy Signed-off-by: Jacob Howard --- agents/security-reviewer/proxy/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go index bc1430e70..4cff8193c 100644 --- a/agents/security-reviewer/proxy/main.go +++ b/agents/security-reviewer/proxy/main.go @@ -219,8 +219,10 @@ func withLogging(next http.Handler) http.Handler { next.ServeHTTP(w, r) duration := time.Since(start) remote := remoteAddr(r.Context(), r.RemoteAddr) - log.Printf("proxy request method=%s path=%s remote=%s duration=%s", - r.Method, r.URL.Path, remote, duration) + if r.URL.Path != healthPath { + log.Printf("proxy request method=%s path=%s remote=%s duration=%s", + r.Method, r.URL.Path, remote, duration) + } }) } From b402ff627525826865bc7de65eebfe3e714f4c3a Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 14:23:15 -0600 Subject: [PATCH 16/29] agents/sec-rev: minor tweaks to Claude/Codex invocation and prompts Signed-off-by: Jacob Howard --- agents/security-reviewer/compose.yml | 5 ++++- agents/security-reviewer/prompt-template.md | 7 +++---- agents/security-reviewer/reviewer/claude.go | 3 ++- agents/security-reviewer/reviewer/codex.go | 7 +++++-- agents/security-reviewer/reviewer/main.go | 14 +++++++------- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index 882a0d8f3..252709d86 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -15,7 +15,10 @@ services: ANTHROPIC_AUTH_TOKEN: "sk-compose-clients" ANTHROPIC_BASE_URL: "http://proxy:4000/anthropic" CLAUDE_REVIEW_MODEL: ${CLAUDE_REVIEW_MODEL:-claude-sonnet-4-5-20250929} - OPENAI_API_KEY: "sk-compose-clients" + # Note: Codex won't respect the OPENAI_API_KEY environment variable when + # running in non-interactive mode, it will only use the CODEX_API_KEY + # environment variable. + CODEX_API_KEY: "sk-compose-clients" OPENAI_BASE_URL: "http://proxy:4000/openai" CODEX_REVIEW_MODEL: ${CODEX_REVIEW_MODEL:-gpt-5-codex} REVIEW_AGENT: ${REVIEW_AGENT:-claude} diff --git a/agents/security-reviewer/prompt-template.md b/agents/security-reviewer/prompt-template.md index db97ebe62..93d1a7804 100644 --- a/agents/security-reviewer/prompt-template.md +++ b/agents/security-reviewer/prompt-template.md @@ -1,11 +1,10 @@ # Docker MCP Security Review Instructions -Mode: $MODE_LABEL - $MODE_SUMMARY -Repository metadata: -- Target label: $TARGET_LABEL +Security review metadata: +- Mode: $MODE_LABEL +- Repository name: $TARGET_LABEL - Repository path: $REPOSITORY_PATH - Head commit: $HEAD_COMMIT - Base commit: $BASE_COMMIT diff --git a/agents/security-reviewer/reviewer/claude.go b/agents/security-reviewer/reviewer/claude.go index e37cda34a..7da4bdd2e 100644 --- a/agents/security-reviewer/reviewer/claude.go +++ b/agents/security-reviewer/reviewer/claude.go @@ -28,7 +28,8 @@ func (claudeAgent) DefaultAllowedTools() string { // BuildCommand constructs the Claude CLI invocation for a review run. func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - args := []string{"--print", "--output-format", "text"} + // https://github.com/anthropics/claude-code/issues/4346 + args := []string{"--print", "--verbose", "--output-format", "stream-json"} if strings.TrimSpace(inv.AllowedTools) != "" { args = append(args, "--allowed-tools", inv.AllowedTools) } diff --git a/agents/security-reviewer/reviewer/codex.go b/agents/security-reviewer/reviewer/codex.go index 9ff72b7e7..6fbb95054 100644 --- a/agents/security-reviewer/reviewer/codex.go +++ b/agents/security-reviewer/reviewer/codex.go @@ -29,14 +29,17 @@ func (codexAgent) DefaultAllowedTools() string { // BuildCommand constructs the Codex CLI invocation for a review run. func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - args := []string{"--quiet", "--json"} + args := []string{"exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write"} if strings.TrimSpace(inv.Model) != "" { args = append(args, "--model", inv.Model) } + if inv.WorkingDir != "" { + args = append(args, "--cd", inv.WorkingDir) + } if strings.TrimSpace(inv.ExtraArgs) != "" { args = append(args, strings.Fields(inv.ExtraArgs)...) } - args = append(args, "exec", "--input", "-") + args = append(args, "-") cmd := exec.CommandContext(ctx, "codex", args...) cmd.Stdin = strings.NewReader(inv.Prompt) diff --git a/agents/security-reviewer/reviewer/main.go b/agents/security-reviewer/reviewer/main.go index a0ec54045..fe47688d8 100644 --- a/agents/security-reviewer/reviewer/main.go +++ b/agents/security-reviewer/reviewer/main.go @@ -289,19 +289,19 @@ func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (st func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) (string, error) { displayLabel := strings.TrimSpace(targetLabel) if displayLabel == "" { - displayLabel = "Not provided" + displayLabel = "[Not provided]" } displayHead := strings.TrimSpace(headSHA) if displayHead == "" { - displayHead = "Not provided" + displayHead = "[Not provided]" } - displayBase := "Not applicable" - commitRange := "Not applicable" + displayBase := "[Not applicable]" + commitRange := "[Not applicable]" if mode == ReviewModeDifferential { cleanBase := strings.TrimSpace(baseSHA) cleanHead := strings.TrimSpace(headSHA) if cleanBase == "" { - displayBase = "Not provided" + displayBase = "[Not provided]" } else { displayBase = cleanBase } @@ -375,9 +375,9 @@ func modeLabel(mode ReviewMode) string { func modeSummary(mode ReviewMode) string { switch mode { case ReviewModeDifferential: - return "You are reviewing the changes introduced between the base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." + return "You are reviewing the changes introduced in a Git repository between the specified base and head commits. Prioritize spotting deliberately malicious additions alongside accidental vulnerabilities." case ReviewModeFull: - return "You are auditing the repository snapshot at the provided head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." + return "You are auditing a Git repository snapshot at the specified head commit. Assume attackers may have hidden malicious logic and hunt for both intentional and accidental risks." default: return "The review mode is unknown." } From 9239ef395d81ce939795feb5af147bf2354a0ca8 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 14:47:19 -0600 Subject: [PATCH 17/29] agents/sec-rev: simplify proxy URL handling Signed-off-by: Jacob Howard --- agents/security-reviewer/compose.yml | 5 +++- agents/security-reviewer/proxy/main.go | 33 ++++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index 252709d86..be5dc12d9 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -44,7 +44,10 @@ services: image: security-reviewer-proxy:latest environment: PROXY_LISTEN_ADDR: ":4000" - PROXY_OPENAI_BASE_URL: ${PROXY_OPENAI_BASE_URL:-https://api.openai.com} + PROXY_OPENAI_BASE_URL: ${PROXY_OPENAI_BASE_URL:-https://api.openai.com/v1} + # NOTE: Anthropic clients such as Claude Code will implicitly include a + # /v1 component in their request URLs, so if you override this environment + # variable, be sure not to include a /v1 path component. PROXY_ANTHROPIC_BASE_URL: ${PROXY_ANTHROPIC_BASE_URL:-https://api.anthropic.com} PROXY_API_KEY: "sk-compose-clients" OPENAI_API_KEY: ${OPENAI_API_KEY:-} diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go index 4cff8193c..026d47443 100644 --- a/agents/security-reviewer/proxy/main.go +++ b/agents/security-reviewer/proxy/main.go @@ -16,7 +16,7 @@ import ( const ( defaultListenAddr = ":4000" - defaultOpenAIBaseURL = "https://api.openai.com" + defaultOpenAIBaseURL = "https://api.openai.com/v1" defaultAnthropicBaseURL = "https://api.anthropic.com" openAIInboundPrefix = "/openai/" anthropicInboundPrefix = "/anthropic/" @@ -129,8 +129,10 @@ func buildProviderHandler(provider providerProxy, clientToken string) http.Handl proxy := httputil.NewSingleHostReverseProxy(provider.Target) originalDirector := proxy.Director proxy.Director = func(req *http.Request) { + inboundPath := req.URL.Path + inboundRawPath := req.URL.RawPath originalDirector(req) - rewriteRequest(req, provider) + rewriteRequest(req, inboundPath, inboundRawPath, provider) } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("proxy error [%s]: %v", provider.DisplayName, err) @@ -158,21 +160,32 @@ func buildProviderHandler(provider providerProxy, clientToken string) http.Handl } // rewriteRequest adjusts the outbound request before it is sent upstream. -func rewriteRequest(req *http.Request, provider providerProxy) { +func rewriteRequest(req *http.Request, inboundPath, inboundRawPath string, provider providerProxy) { req.URL.Scheme = provider.Target.Scheme req.URL.Host = provider.Target.Host req.Host = provider.Target.Host - // Strip the provider prefix from the incoming path. - trimmedPath := strings.TrimPrefix(req.URL.Path, provider.Prefix) - if !strings.HasPrefix(trimmedPath, "/") { - trimmedPath = "/" + trimmedPath + trimmedPath := strings.TrimPrefix(inboundPath, provider.Prefix) + if trimmedPath == inboundPath { + trimmedPath = "" } - // Combine the target's base path with the trimmed path. basePath := provider.Target.Path - req.URL.Path = joinURLPath(basePath, trimmedPath) - req.URL.RawPath = req.URL.EscapedPath() + extraPath := singleLeadingSlash(trimmedPath) + req.URL.Path = joinURLPath(basePath, extraPath) + + trimmedRaw := "" + if inboundRawPath != "" { + trimmedRaw = strings.TrimPrefix(inboundRawPath, provider.Prefix) + if trimmedRaw == inboundRawPath { + trimmedRaw = "" + } + } + if trimmedRaw != "" { + req.URL.RawPath = joinURLPath(basePath, singleLeadingSlash(trimmedRaw)) + } else { + req.URL.RawPath = req.URL.Path + } stripSensitiveHeaders(req.Header) From 331764a13ca03e4328e90d07b50b664367656c29 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 15:06:09 -0600 Subject: [PATCH 18/29] agents/sec-rev: rely solely on container sandboxing for Codex Signed-off-by: Jacob Howard --- agents/security-reviewer/reviewer/codex.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/security-reviewer/reviewer/codex.go b/agents/security-reviewer/reviewer/codex.go index 6fbb95054..f6b5d0c1b 100644 --- a/agents/security-reviewer/reviewer/codex.go +++ b/agents/security-reviewer/reviewer/codex.go @@ -29,7 +29,7 @@ func (codexAgent) DefaultAllowedTools() string { // BuildCommand constructs the Codex CLI invocation for a review run. func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - args := []string{"exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write"} + args := []string{"exec", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"} if strings.TrimSpace(inv.Model) != "" { args = append(args, "--model", inv.Model) } From 0a7a5e2993d9f1f9705887f694a09a3097553cf8 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 16:54:03 -0600 Subject: [PATCH 19/29] agents/sec-rev: use textual output for Codex and add Claude output note Signed-off-by: Jacob Howard --- agents/security-reviewer/reviewer/claude.go | 9 ++++++++- agents/security-reviewer/reviewer/codex.go | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/agents/security-reviewer/reviewer/claude.go b/agents/security-reviewer/reviewer/claude.go index 7da4bdd2e..3d39d1ef4 100644 --- a/agents/security-reviewer/reviewer/claude.go +++ b/agents/security-reviewer/reviewer/claude.go @@ -28,7 +28,14 @@ func (claudeAgent) DefaultAllowedTools() string { // BuildCommand constructs the Claude CLI invocation for a review run. func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - // https://github.com/anthropics/claude-code/issues/4346 + // When running Claude Code in non-interactive mode, the only output format + // that gives regular progress updates is stream-json - anything else waits + // for the full analysis to complete and then provides all the output at + // once. It would be nice if Claude Code had something like a stream-text + // mode, and there's a request for that here: + // https://github.com/anthropics/claude-code/issues/4346 + // In the meantime, I think we'll just live with the JSON output, since at + // least that gives some indication of progress and what's happening. args := []string{"--print", "--verbose", "--output-format", "stream-json"} if strings.TrimSpace(inv.AllowedTools) != "" { args = append(args, "--allowed-tools", inv.AllowedTools) diff --git a/agents/security-reviewer/reviewer/codex.go b/agents/security-reviewer/reviewer/codex.go index f6b5d0c1b..7b77f8752 100644 --- a/agents/security-reviewer/reviewer/codex.go +++ b/agents/security-reviewer/reviewer/codex.go @@ -29,7 +29,7 @@ func (codexAgent) DefaultAllowedTools() string { // BuildCommand constructs the Codex CLI invocation for a review run. func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - args := []string{"exec", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"} + args := []string{"exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"} if strings.TrimSpace(inv.Model) != "" { args = append(args, "--model", inv.Model) } From adfadf7b5e6f84e3d6e0611f294e428436321584 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 17:04:58 -0600 Subject: [PATCH 20/29] agents/sec-rev: parse extra arguments more robustly Signed-off-by: Jacob Howard --- agents/security-reviewer/go.mod | 2 ++ agents/security-reviewer/go.sum | 2 ++ agents/security-reviewer/reviewer/claude.go | 9 ++++++++- agents/security-reviewer/reviewer/codex.go | 9 ++++++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/agents/security-reviewer/go.mod b/agents/security-reviewer/go.mod index 44ea69c34..f48fd52a1 100644 --- a/agents/security-reviewer/go.mod +++ b/agents/security-reviewer/go.mod @@ -1,3 +1,5 @@ module github.com/docker/mcp-registry/agents/security-reviewer go 1.25.3 + +require github.com/mattn/go-shellwords v1.0.12 diff --git a/agents/security-reviewer/go.sum b/agents/security-reviewer/go.sum index e69de29bb..da8b56c39 100644 --- a/agents/security-reviewer/go.sum +++ b/agents/security-reviewer/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= diff --git a/agents/security-reviewer/reviewer/claude.go b/agents/security-reviewer/reviewer/claude.go index 3d39d1ef4..f31d4ec81 100644 --- a/agents/security-reviewer/reviewer/claude.go +++ b/agents/security-reviewer/reviewer/claude.go @@ -2,8 +2,11 @@ package main import ( "context" + "fmt" "os/exec" "strings" + + "github.com/mattn/go-shellwords" ) // claudeAgent implements reviewerAgent for Claude Code. @@ -50,7 +53,11 @@ func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec args = append(args, "--add-dir", dir) } if strings.TrimSpace(inv.ExtraArgs) != "" { - args = append(args, strings.Fields(inv.ExtraArgs)...) + parsed, err := shellwords.Parse(inv.ExtraArgs) + if err != nil { + return nil, fmt.Errorf("parse extra args: %w", err) + } + args = append(args, parsed...) } cmd := exec.CommandContext(ctx, "claude", args...) diff --git a/agents/security-reviewer/reviewer/codex.go b/agents/security-reviewer/reviewer/codex.go index 7b77f8752..bb4348c13 100644 --- a/agents/security-reviewer/reviewer/codex.go +++ b/agents/security-reviewer/reviewer/codex.go @@ -2,9 +2,12 @@ package main import ( "context" + "fmt" "os" "os/exec" "strings" + + "github.com/mattn/go-shellwords" ) // codexAgent implements reviewerAgent for the OpenAI Codex CLI. @@ -37,7 +40,11 @@ func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec. args = append(args, "--cd", inv.WorkingDir) } if strings.TrimSpace(inv.ExtraArgs) != "" { - args = append(args, strings.Fields(inv.ExtraArgs)...) + parsed, err := shellwords.Parse(inv.ExtraArgs) + if err != nil { + return nil, fmt.Errorf("parse extra args: %w", err) + } + args = append(args, parsed...) } args = append(args, "-") From 9aa96fd46c370fdce4f3444231dd88daaf57e40d Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 17:32:27 -0600 Subject: [PATCH 21/29] agents/sec-rev: additional consistency and cleanup fixes for reviewer Signed-off-by: Jacob Howard --- agents/security-reviewer/prompt-template.md | 4 +- agents/security-reviewer/reviewer/agent.go | 2 - agents/security-reviewer/reviewer/claude.go | 19 +---- agents/security-reviewer/reviewer/codex.go | 23 ++---- agents/security-reviewer/reviewer/main.go | 82 +++++++++------------ 5 files changed, 46 insertions(+), 84 deletions(-) diff --git a/agents/security-reviewer/prompt-template.md b/agents/security-reviewer/prompt-template.md index 93d1a7804..ee46660be 100644 --- a/agents/security-reviewer/prompt-template.md +++ b/agents/security-reviewer/prompt-template.md @@ -29,8 +29,8 @@ Mode-specific focus: Report expectations: - Reproduce every heading, section order, and field exactly as written in - `/opt/security-reviewer/report-template.md`; replace bracketed placeholders - with concrete content but do not add or remove sections. + `$REPORT_TEMPLATE_PATH`; replace bracketed placeholders with concrete + content but do not add or remove sections. - Save the final report to $REPORT_PATH. - Articulate severity, impact, evidence, and remediation for each issue. diff --git a/agents/security-reviewer/reviewer/agent.go b/agents/security-reviewer/reviewer/agent.go index 8f744013c..77ccd611e 100644 --- a/agents/security-reviewer/reviewer/agent.go +++ b/agents/security-reviewer/reviewer/agent.go @@ -18,8 +18,6 @@ type reviewerAgent interface { Name() string // ModelEnvVar returns the environment variable that overrides the agent's model, or empty when not applicable. ModelEnvVar() string - // DefaultAllowedTools returns the allowlist of tools that should be enabled when the caller does not specify one. - DefaultAllowedTools() string // BuildCommand returns the configured command used to invoke the agent. BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) } diff --git a/agents/security-reviewer/reviewer/claude.go b/agents/security-reviewer/reviewer/claude.go index f31d4ec81..2b4a66da8 100644 --- a/agents/security-reviewer/reviewer/claude.go +++ b/agents/security-reviewer/reviewer/claude.go @@ -23,12 +23,6 @@ func (claudeAgent) ModelEnvVar() string { return "CLAUDE_REVIEW_MODEL" } -// DefaultAllowedTools returns the default Claude tool allowlist. -func (claudeAgent) DefaultAllowedTools() string { - // Mirror the default permissions granted in prior workflows. - return defaultClaudeAllowedTools -} - // BuildCommand constructs the Claude CLI invocation for a review run. func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { // When running Claude Code in non-interactive mode, the only output format @@ -39,19 +33,14 @@ func (claudeAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec // https://github.com/anthropics/claude-code/issues/4346 // In the meantime, I think we'll just live with the JSON output, since at // least that gives some indication of progress and what's happening. - args := []string{"--print", "--verbose", "--output-format", "stream-json"} - if strings.TrimSpace(inv.AllowedTools) != "" { - args = append(args, "--allowed-tools", inv.AllowedTools) + args := []string{ + "--print", "--verbose", + "--output-format", "stream-json", + "--dangerously-skip-permissions", } if strings.TrimSpace(inv.Model) != "" { args = append(args, "--model", inv.Model) } - for _, dir := range inv.AllowedDirs { - if strings.TrimSpace(dir) == "" { - continue - } - args = append(args, "--add-dir", dir) - } if strings.TrimSpace(inv.ExtraArgs) != "" { parsed, err := shellwords.Parse(inv.ExtraArgs) if err != nil { diff --git a/agents/security-reviewer/reviewer/codex.go b/agents/security-reviewer/reviewer/codex.go index bb4348c13..7aac74b70 100644 --- a/agents/security-reviewer/reviewer/codex.go +++ b/agents/security-reviewer/reviewer/codex.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "os" "os/exec" "strings" @@ -24,21 +23,16 @@ func (codexAgent) ModelEnvVar() string { return "CODEX_REVIEW_MODEL" } -// DefaultAllowedTools returns the default tool allowlist for Codex. -func (codexAgent) DefaultAllowedTools() string { - // Codex manages tool permissions internally, so we default to an empty allowlist. - return "" -} - // BuildCommand constructs the Codex CLI invocation for a review run. func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec.Cmd, error) { - args := []string{"exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"} + args := []string{ + "exec", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + } if strings.TrimSpace(inv.Model) != "" { args = append(args, "--model", inv.Model) } - if inv.WorkingDir != "" { - args = append(args, "--cd", inv.WorkingDir) - } if strings.TrimSpace(inv.ExtraArgs) != "" { parsed, err := shellwords.Parse(inv.ExtraArgs) if err != nil { @@ -54,12 +48,5 @@ func (codexAgent) BuildCommand(ctx context.Context, inv agentInvocation) (*exec. cmd.Dir = inv.WorkingDir } - env := os.Environ() - env = append(env, envCodexQuiet+"=1", envCodexJson+"=1") - if inv.WorkingDir != "" { - env = append(env, envCodexWorkingDir+"="+inv.WorkingDir) - } - cmd.Env = env - return cmd, nil } diff --git a/agents/security-reviewer/reviewer/main.go b/agents/security-reviewer/reviewer/main.go index fe47688d8..6a7c44226 100644 --- a/agents/security-reviewer/reviewer/main.go +++ b/agents/security-reviewer/reviewer/main.go @@ -15,21 +15,20 @@ import ( ) const ( - promptTemplatePath = "/opt/security-reviewer/prompt-template.md" - reportTemplatePath = "/opt/security-reviewer/report-template.md" - defaultPromptPath = "/workspace/input/prompt.md" - defaultRepositoryPath = "/workspace/input/repository" - defaultReportPath = "/workspace/output/report.md" - defaultLabelsPath = "/workspace/output/labels.txt" - defaultClaudeAllowedTools = "Read,Write,Bash(git:*),Bash(mkdir),Bash(ls),Bash(cat)" - defaultReviewAgent = "claude" - defaultAgentWorkingDir = "/workspace" - - envReviewAgent = "REVIEW_AGENT" - envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" - envCodexQuiet = "CODEX_QUIET_MODE" - envCodexJson = "CODEX_JSON_MODE" - envCodexWorkingDir = "CODEX_WORKDIR" + promptTemplatePath = "/opt/security-reviewer/prompt-template.md" + reportTemplatePath = "/opt/security-reviewer/report-template.md" + defaultPromptPath = "/workspace/input/prompt.md" + defaultRepositoryPath = "/workspace/input/repository" + defaultReportPath = "/workspace/output/report.md" + defaultLabelsPath = "/workspace/output/labels.txt" + defaultReviewAgent = "claude" + defaultAgentWorkingDir = "/workspace" + + envReviewAgent = "REVIEW_AGENT" + envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" + envReviewHeadSHA = "REVIEW_HEAD_SHA" + envReviewBaseSHA = "REVIEW_BASE_SHA" + envReviewTarget = "REVIEW_TARGET_LABEL" ) // ReviewMode enumerates supported security review modes. @@ -48,12 +47,6 @@ type agentInvocation struct { Prompt string // Model identifies the model to invoke, when the agent supports overrides. Model string - // AllowedTools enumerates tool permissions for agents that honor them. - AllowedTools string - // AllowedDirs lists directories the agent should be allowed to traverse. - AllowedDirs []string - // AllowedFiles lists specific files the agent may read or write. - AllowedFiles []string // ExtraArgs contains caller-supplied CLI arguments for the agent. ExtraArgs string // WorkingDir specifies the directory where the agent command executes. @@ -82,6 +75,8 @@ type promptPlaceholders struct { ReportPath string // LabelsPath denotes where the agent should write labels for automation. LabelsPath string + // ReportTemplatePath tells the agent which template to follow exactly. + ReportTemplatePath string } // main configures logging, resolves environment, and runs the selected agent. @@ -99,11 +94,11 @@ func main() { // run orchestrates prompt generation and agent execution. func run(ctx context.Context) error { // Parse review configuration from the environment. - headSHA, err := fetchEnv("REVIEW_HEAD_SHA", true) + headSHA, err := fetchEnv(envReviewHeadSHA, true) if err != nil { return err } - baseSHA, err := fetchEnv("REVIEW_BASE_SHA", false) + baseSHA, err := fetchEnv(envReviewBaseSHA, false) if err != nil { return err } @@ -113,7 +108,7 @@ func run(ctx context.Context) error { mode = ReviewModeDifferential } - targetLabel, err := fetchEnv("REVIEW_TARGET_LABEL", false) + targetLabel, err := fetchEnv(envReviewTarget, false) if err != nil { return err } @@ -165,21 +160,12 @@ func run(ctx context.Context) error { model = mustFetchOptional(envName) } - allowedTools := agent.DefaultAllowedTools() extraArgs := mustFetchOptional(envAgentExtraArgs) - - allowedDirs := []string{defaultAgentWorkingDir} - - allowedFiles := []string{reportTemplatePath, promptPath} - inv := agentInvocation{ - Prompt: promptContent, - Model: model, - AllowedTools: allowedTools, - AllowedDirs: allowedDirs, - AllowedFiles: allowedFiles, - ExtraArgs: extraArgs, - WorkingDir: defaultAgentWorkingDir, + Prompt: promptContent, + Model: model, + ExtraArgs: extraArgs, + WorkingDir: defaultAgentWorkingDir, } logInfo(fmt.Sprintf( @@ -311,16 +297,17 @@ func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) ( } ph := promptPlaceholders{ - ModeLabel: modeLabel(mode), - ModeSummary: modeSummary(mode), - TargetLabel: displayLabel, - RepositoryPath: defaultRepositoryPath, - HeadCommit: displayHead, - BaseCommit: displayBase, - CommitRange: commitRange, - GitDiffHint: gitDiffHint(mode, baseSHA, headSHA), - ReportPath: defaultReportPath, - LabelsPath: defaultLabelsPath, + ModeLabel: modeLabel(mode), + ModeSummary: modeSummary(mode), + TargetLabel: displayLabel, + RepositoryPath: defaultRepositoryPath, + HeadCommit: displayHead, + BaseCommit: displayBase, + CommitRange: commitRange, + GitDiffHint: gitDiffHint(mode, baseSHA, headSHA), + ReportPath: defaultReportPath, + LabelsPath: defaultLabelsPath, + ReportTemplatePath: reportTemplatePath, } return renderPrompt(ph) } @@ -342,6 +329,7 @@ func renderPrompt(ph promptPlaceholders) (string, error) { "$GIT_DIFF_HINT", ph.GitDiffHint, "$REPORT_PATH", ph.ReportPath, "$LABELS_PATH", ph.LabelsPath, + "$REPORT_TEMPLATE_PATH", ph.ReportTemplatePath, ) return replacer.Replace(string(templateBytes)), nil } From 2c3230c309cf96de7d8b367d9e97ee9717eadeac Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 17:58:59 -0600 Subject: [PATCH 22/29] agents/sec-rev: bump proxy timeout for inference calls Upstream calls can take a long time. This should be very conservative. Signed-off-by: Jacob Howard --- agents/security-reviewer/proxy/main.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go index 026d47443..e076e8ef7 100644 --- a/agents/security-reviewer/proxy/main.go +++ b/agents/security-reviewer/proxy/main.go @@ -51,10 +51,12 @@ func main() { }) server := &http.Server{ - Addr: cfg.listenAddr, - Handler: withLogging(mux), - ReadTimeout: 15 * time.Second, - WriteTimeout: 60 * time.Second, + Addr: cfg.listenAddr, + Handler: withLogging(mux), + ReadTimeout: 15 * time.Second, + // WriteTimeout needs to be relatively high because it limits how long + // the upstream inference API has to respond. + WriteTimeout: 3600 * time.Second, IdleTimeout: 60 * time.Second, } From 525e59fbc602341343a3ab6032daa95966977d87 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 18:34:25 -0600 Subject: [PATCH 23/29] agents/sec-rev: add overall timeout to reviews Signed-off-by: Jacob Howard --- agents/security-reviewer/compose.yml | 1 + agents/security-reviewer/reviewer/main.go | 26 +++++++++++- cmd/security-reviewer/main.go | 49 ++++++++++++++--------- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index be5dc12d9..b026da9d0 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -26,6 +26,7 @@ services: REVIEW_BASE_SHA: ${REVIEW_BASE_SHA:-} REVIEW_TARGET_LABEL: ${REVIEW_TARGET_LABEL:-} REVIEW_AGENT_EXTRA_ARGS: ${REVIEW_AGENT_EXTRA_ARGS:-} + REVIEW_TIMEOUT_SECS: ${REVIEW_TIMEOUT_SECS:-3600} volumes: - type: bind source: ${REVIEW_REPOSITORY_PATH:?Set REVIEW_REPOSITORY_PATH to bind the repository under review} diff --git a/agents/security-reviewer/reviewer/main.go b/agents/security-reviewer/reviewer/main.go index 6a7c44226..73d706bf6 100644 --- a/agents/security-reviewer/reviewer/main.go +++ b/agents/security-reviewer/reviewer/main.go @@ -10,8 +10,10 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "strings" "syscall" + "time" ) const ( @@ -23,12 +25,14 @@ const ( defaultLabelsPath = "/workspace/output/labels.txt" defaultReviewAgent = "claude" defaultAgentWorkingDir = "/workspace" + defaultTimeout = time.Hour envReviewAgent = "REVIEW_AGENT" envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" envReviewHeadSHA = "REVIEW_HEAD_SHA" envReviewBaseSHA = "REVIEW_BASE_SHA" envReviewTarget = "REVIEW_TARGET_LABEL" + envReviewTimeout = "REVIEW_TIMEOUT_SECS" ) // ReviewMode enumerates supported security review modes. @@ -168,13 +172,21 @@ func run(ctx context.Context) error { WorkingDir: defaultAgentWorkingDir, } + timeout, err := resolveTimeout() + if err != nil { + return err + } + + agentCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + logInfo(fmt.Sprintf( "Starting %s review (agent=%s head=%s base=%s label=%s).", mode, agent.Name(), headSHA, baseSHA, targetLabel, )) // Execute the agent command and relay its output streams. - stdout, stderr, runErr := runAgent(ctx, agent, inv) + stdout, stderr, runErr := runAgent(agentCtx, agent, inv) if stderr != "" { logError(errors.New(stderr)) } @@ -271,6 +283,18 @@ func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (st return stdout.String(), stderr.String(), nil } +func resolveTimeout() (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(envReviewTimeout)) + if value == "" { + return defaultTimeout, nil + } + secs, err := strconv.Atoi(value) + if err != nil || secs <= 0 { + return 0, fmt.Errorf("invalid %s value %q", envReviewTimeout, value) + } + return time.Duration(secs) * time.Second, nil +} + // buildPromptContent renders a concrete prompt for the selected review mode. func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) (string, error) { displayLabel := strings.TrimSpace(targetLabel) diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go index e7fd22125..eb2fd4000 100644 --- a/cmd/security-reviewer/main.go +++ b/cmd/security-reviewer/main.go @@ -17,15 +17,16 @@ import ( ) const ( - composeFileName = "compose.yml" - reportFileName = "report.md" - labelsFileName = "labels.txt" - repositoryDirName = "repository" - dockerExecutable = "docker" - gitExecutable = "git" - projectPrefix = "security-reviewer" - agentService = "reviewer" - composeRelativePath = "agents/security-reviewer" + composeFileName = "compose.yml" + reportFileName = "report.md" + labelsFileName = "labels.txt" + repositoryDirName = "repository" + dockerExecutable = "docker" + gitExecutable = "git" + projectPrefix = "security-reviewer" + agentService = "reviewer" + composeRelativePath = "agents/security-reviewer" + defaultTimeoutSeconds = 3600 envAnthropicAPIKey = "ANTHROPIC_API_KEY" envOpenAIAPIKey = "OPENAI_API_KEY" @@ -56,6 +57,8 @@ type options struct { ExtraArgs string // KeepWorkdir preserves the temporary workspace when true. KeepWorkdir bool + // TimeoutSeconds bounds the reviewer runtime; zero uses the default. + TimeoutSeconds int } var ( @@ -66,6 +69,7 @@ var ( flagTarget string flagOutput string flagLabels string + flagTimeoutSecs int flagModel string flagExtraArgs string flagKeepWorkdir bool @@ -87,18 +91,23 @@ var rootCmd = &cobra.Command{ if labelsOutput == "" { labelsOutput = deriveDefaultLabelsPath(flagOutput) } + timeoutSecs := flagTimeoutSecs + if timeoutSecs <= 0 { + timeoutSecs = defaultTimeoutSeconds + } opts := options{ - Agent: agent, - Repository: strings.TrimSpace(flagRepo), - HeadSHA: strings.TrimSpace(flagHead), - BaseSHA: strings.TrimSpace(flagBase), - TargetLabel: strings.TrimSpace(flagTarget), - OutputPath: flagOutput, - LabelsOutput: labelsOutput, - Model: strings.TrimSpace(flagModel), - ExtraArgs: strings.TrimSpace(flagExtraArgs), - KeepWorkdir: flagKeepWorkdir, + Agent: agent, + Repository: strings.TrimSpace(flagRepo), + HeadSHA: strings.TrimSpace(flagHead), + BaseSHA: strings.TrimSpace(flagBase), + TargetLabel: strings.TrimSpace(flagTarget), + OutputPath: flagOutput, + LabelsOutput: labelsOutput, + Model: strings.TrimSpace(flagModel), + ExtraArgs: strings.TrimSpace(flagExtraArgs), + KeepWorkdir: flagKeepWorkdir, + TimeoutSeconds: timeoutSecs, } if opts.Repository == "" { @@ -120,6 +129,7 @@ func init() { rootCmd.Flags().StringVar(&flagTarget, "target-label", "", "Human readable identifier for the target.") rootCmd.Flags().StringVar(&flagOutput, "output", "security-review.md", "Destination for the rendered report.") rootCmd.Flags().StringVar(&flagLabels, "labels-output", "", "Destination for the labels file (defaults alongside the report).") + rootCmd.Flags().IntVar(&flagTimeoutSecs, "timeout", defaultTimeoutSeconds, "Maximum runtime for the review in seconds (defaults to 3600 seconds).") rootCmd.Flags().StringVar(&flagModel, "model", "", "Override the reviewer model for the selected agent.") rootCmd.Flags().StringVar(&flagExtraArgs, "extra-args", "", "Additional arguments passed to the reviewer agent.") rootCmd.Flags().BoolVar(&flagKeepWorkdir, "keep-workdir", false, "Keep the temporary workspace after completion.") @@ -359,6 +369,7 @@ func buildComposeEnv(opts options, repositoryDir, outputDir string) []string { fmt.Sprintf("REVIEW_TARGET_LABEL=%s", opts.TargetLabel), fmt.Sprintf("REVIEW_REPOSITORY_PATH=%s", repositoryDir), fmt.Sprintf("REVIEW_OUTPUT_PATH=%s", outputDir), + fmt.Sprintf("REVIEW_TIMEOUT_SECS=%d", opts.TimeoutSeconds), ) if opts.Model != "" { // Route custom models to the right environment variable per agent. From 8cb53f4d5a1f4a8a5b57f0befecc0faf3c15b611 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Mon, 27 Oct 2025 20:45:00 -0600 Subject: [PATCH 24/29] ci: add concurrency group spec to update-pins Signed-off-by: Jacob Howard --- .github/workflows/update-pins.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml index 7aea8eae5..675784357 100644 --- a/.github/workflows/update-pins.yaml +++ b/.github/workflows/update-pins.yaml @@ -7,6 +7,10 @@ on: - cron: "0 0 1 * *" workflow_dispatch: +concurrency: + group: update-pins + cancel-in-progress: false + permissions: contents: write pull-requests: write From 52f41ccfca71d9d9e2ed7e02da30a1a08499d0f1 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Tue, 28 Oct 2025 15:11:50 -0600 Subject: [PATCH 25/29] agents/sec-rev: final tweaks to agent code after review Signed-off-by: Jacob Howard --- agents/security-reviewer/Dockerfile | 9 +- agents/security-reviewer/proxy/main.go | 55 ++++-- agents/security-reviewer/reviewer/main.go | 195 +++++++++------------- cmd/security-reviewer/main.go | 123 ++++++++------ 4 files changed, 198 insertions(+), 184 deletions(-) diff --git a/agents/security-reviewer/Dockerfile b/agents/security-reviewer/Dockerfile index d406ea616..ba5eb1cd5 100644 --- a/agents/security-reviewer/Dockerfile +++ b/agents/security-reviewer/Dockerfile @@ -54,7 +54,7 @@ RUN useradd --create-home --shell /bin/bash agent \ && install -d -o agent -g agent /workspace/input \ && install -d -o agent -g agent /workspace/output -# Copy the reviewer assets and report template. +# Copy the reviewer entrypoint and assets. COPY --from=gobuilder /security-reviewer/build/reviewer /opt/security-reviewer/reviewer COPY --chown=agent:agent prompt-template.md /opt/security-reviewer/prompt-template.md COPY --chown=agent:agent report-template.md /opt/security-reviewer/report-template.md @@ -67,9 +67,10 @@ WORKDIR /workspace ENTRYPOINT ["/opt/security-reviewer/reviewer"] -# Lightweight image hosting the API proxy. +# Use a debian image to host the proxy. FROM debian:trixie-slim AS proxy-image +# Install dependencies, including those needed for healthchecks. ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -77,7 +78,11 @@ RUN apt-get update \ wget \ && rm -rf /var/lib/apt/lists/* +# Copy the proxy entrypoint. COPY --from=gobuilder /security-reviewer/build/proxy /opt/security-reviewer/proxy +# Expose the proxy's default port. EXPOSE 4000 + +# Set the proxy entrypoint. ENTRYPOINT ["/opt/security-reviewer/proxy"] diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go index e076e8ef7..624e3cc94 100644 --- a/agents/security-reviewer/proxy/main.go +++ b/agents/security-reviewer/proxy/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "errors" "fmt" "log" @@ -10,27 +11,44 @@ import ( "net/http/httputil" "net/url" "os" + "os/signal" "strings" + "syscall" "time" ) const ( - defaultListenAddr = ":4000" - defaultOpenAIBaseURL = "https://api.openai.com/v1" + // defaultListenAddr is the fallback bind address when none is provided via PROXY_LISTEN_ADDR. + defaultListenAddr = ":4000" + // defaultOpenAIBaseURL is the upstream OpenAI API base path used when none is provided. + defaultOpenAIBaseURL = "https://api.openai.com/v1" + // defaultAnthropicBaseURL is the upstream Anthropic API base path used when none is provided. + // NOTE: Anthropic clients are expected to include /v1 in their requests, so + // it is not idiomatic to include it in the base URL. defaultAnthropicBaseURL = "https://api.anthropic.com" - openAIInboundPrefix = "/openai/" - anthropicInboundPrefix = "/anthropic/" - healthPath = "/health/liveliness" - headerAuthorization = "Authorization" - headerAnthropicAPIKey = "X-Api-Key" + // openAIInboundPrefix is the path prefix used to route requests to OpenAI. + openAIInboundPrefix = "/openai/" + // anthropicInboundPrefix is the path prefix used to route requests to Anthropic. + anthropicInboundPrefix = "/anthropic/" + // healthPath is the HTTP endpoint used for container health checks. + healthPath = "/health/liveliness" + // headerAuthorization is the inbound HTTP header that carries bearer tokens. + headerAuthorization = "Authorization" + // headerAnthropicAPIKey is the Anthropic-specific header carrying API keys. + headerAnthropicAPIKey = "X-Api-Key" ) // providerProxy defines how to forward requests to a specific upstream API. type providerProxy struct { - Prefix string - Target *url.URL - HeaderName string + // Prefix is the inbound path prefix handled by the provider. + Prefix string + // Target is the upstream endpoint used to service requests for the provider. + Target *url.URL + // HeaderName is the outbound header carrying the provider-specific credential. + HeaderName string + // HeaderValue is the credential value set on outbound requests. HeaderValue string + // DisplayName is the human-readable name of the provider used in logs. DisplayName string } @@ -41,6 +59,9 @@ func main() { log.Fatalf("proxy configuration error: %v", err) } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer stop() + mux := http.NewServeMux() mux.HandleFunc(healthPath, handleHealth) @@ -63,6 +84,15 @@ func main() { log.Printf("proxy listening on %s (OpenAI -> %s, Anthropic -> %s)", cfg.listenAddr, cfg.openAIProxy.Target.String(), cfg.anthropicProxy.Target.String()) + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("proxy shutdown error: %v", err) + } + }() + if err = server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("proxy server error: %v", err) } @@ -289,6 +319,8 @@ func firstNonEmpty(values ...string) string { return "" } +// validateClientToken ensures inbound requests present the proxy bearer secret using +// a constant-time comparison to avoid leaking timing information. func validateClientToken(headerValue, expectedToken string) bool { if expectedToken == "" { return false @@ -300,5 +332,6 @@ func validateClientToken(headerValue, expectedToken string) bool { if !strings.EqualFold(parts[0], "bearer") { return false } - return strings.TrimSpace(parts[1]) == expectedToken + provided := strings.TrimSpace(parts[1]) + return subtle.ConstantTimeCompare([]byte(provided), []byte(expectedToken)) == 1 } diff --git a/agents/security-reviewer/reviewer/main.go b/agents/security-reviewer/reviewer/main.go index 73d706bf6..72ea74e34 100644 --- a/agents/security-reviewer/reviewer/main.go +++ b/agents/security-reviewer/reviewer/main.go @@ -1,11 +1,9 @@ package main import ( - "bytes" "context" "errors" "fmt" - "io" "io/fs" "os" "os/signal" @@ -17,22 +15,37 @@ import ( ) const ( - promptTemplatePath = "/opt/security-reviewer/prompt-template.md" - reportTemplatePath = "/opt/security-reviewer/report-template.md" - defaultPromptPath = "/workspace/input/prompt.md" - defaultRepositoryPath = "/workspace/input/repository" - defaultReportPath = "/workspace/output/report.md" - defaultLabelsPath = "/workspace/output/labels.txt" - defaultReviewAgent = "claude" + // promptTemplatePath is the location of the static prompt template bundled with the image. + promptTemplatePath = "/opt/security-reviewer/prompt-template.md" + // reportTemplatePath is the location of the report template referenced in prompts. + reportTemplatePath = "/opt/security-reviewer/report-template.md" + // defaultPromptPath is where the rendered prompt is written for the agent to consume. + defaultPromptPath = "/workspace/input/prompt.md" + // defaultRepositoryPath is the mount point containing the repository under review. + defaultRepositoryPath = "/workspace/input/repository" + // defaultReportPath is the expected location for the agent's security report. + defaultReportPath = "/workspace/output/report.md" + // defaultLabelsPath is the expected location for the agent's label output. + defaultLabelsPath = "/workspace/output/labels.txt" + // defaultReviewAgent is the reviewer implementation used when none is specified. + defaultReviewAgent = "claude" + // defaultAgentWorkingDir is the directory from which the agent executes. defaultAgentWorkingDir = "/workspace" - defaultTimeout = time.Hour + // defaultTimeout bounds how long the reviewer will wait for the agent to complete. + defaultTimeout = time.Hour - envReviewAgent = "REVIEW_AGENT" + // envReviewAgent selects which reviewer agent to run. + envReviewAgent = "REVIEW_AGENT" + // envAgentExtraArgs contains optional CLI arguments passed through to the agent. envAgentExtraArgs = "REVIEW_AGENT_EXTRA_ARGS" - envReviewHeadSHA = "REVIEW_HEAD_SHA" - envReviewBaseSHA = "REVIEW_BASE_SHA" - envReviewTarget = "REVIEW_TARGET_LABEL" - envReviewTimeout = "REVIEW_TIMEOUT_SECS" + // envReviewHeadSHA provides the head commit SHA under review. + envReviewHeadSHA = "REVIEW_HEAD_SHA" + // envReviewBaseSHA provides the base commit SHA when performing differential reviews. + envReviewBaseSHA = "REVIEW_BASE_SHA" + // envReviewTarget supplies a human-readable label describing the review target. + envReviewTarget = "REVIEW_TARGET_LABEL" + // envReviewTimeout allows callers to override the agent execution timeout in seconds. + envReviewTimeout = "REVIEW_TIMEOUT_SECS" ) // ReviewMode enumerates supported security review modes. @@ -117,19 +130,8 @@ func run(ctx context.Context) error { return err } - // Resolve concrete paths for prompt, repository, and outputs. - promptPath := defaultPromptPath - repositoryPath := defaultRepositoryPath - reportPath := defaultReportPath - labelsPath := defaultLabelsPath - - promptPath = filepath.Clean(promptPath) - repositoryPath = filepath.Clean(repositoryPath) - reportPath = filepath.Clean(reportPath) - labelsPath = filepath.Clean(labelsPath) - - // Read the rendered prompt and ensure the repository mount is present. - if err = ensureDirectory(repositoryPath); err != nil { + // Ensure the repository mount is present before processing. + if err = ensureDirectory(defaultRepositoryPath); err != nil { return err } @@ -137,13 +139,13 @@ func run(ctx context.Context) error { if err != nil { return err } - if err = ensureParent(promptPath); err != nil { + if err = ensureParent(defaultPromptPath); err != nil { return err } - if err = os.WriteFile(promptPath, []byte(promptContent), 0o644); err != nil { + if err = os.WriteFile(defaultPromptPath, []byte(promptContent), 0o644); err != nil { return fmt.Errorf("write prompt: %w", err) } - logInfo(fmt.Sprintf("Rendered prompt to %s.", promptPath)) + logInfo(fmt.Sprintf("Rendered prompt to %s.", defaultPromptPath)) // Select the reviewer implementation and build invocation parameters. agentName, err := fetchEnv(envReviewAgent, false) @@ -161,10 +163,13 @@ func run(ctx context.Context) error { var model string if envName := agent.ModelEnvVar(); envName != "" { - model = mustFetchOptional(envName) + model, err = fetchEnv(envName, false) + if err != nil { + return err + } } - extraArgs := mustFetchOptional(envAgentExtraArgs) + extraArgs, _ := fetchEnv(envAgentExtraArgs, false) inv := agentInvocation{ Prompt: promptContent, Model: model, @@ -180,44 +185,36 @@ func run(ctx context.Context) error { agentCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - logInfo(fmt.Sprintf( - "Starting %s review (agent=%s head=%s base=%s label=%s).", - mode, agent.Name(), headSHA, baseSHA, targetLabel, - )) - - // Execute the agent command and relay its output streams. - stdout, stderr, runErr := runAgent(agentCtx, agent, inv) - if stderr != "" { - logError(errors.New(stderr)) - } - if stdout != "" { - fmt.Print(stdout) - } - if runErr != nil { - return runErr + if mode == ReviewModeDifferential { + logInfo(fmt.Sprintf( + "Starting differential review (agent=%s head=%s base=%s label=%s).", + agent.Name(), headSHA, baseSHA, targetLabel, + )) + } else { + logInfo(fmt.Sprintf( + "Starting full review (agent=%s head=%s label=%s).", + agent.Name(), headSHA, targetLabel, + )) } - // Persist the report and labels outputs, falling back to stdout when needed. - if err = ensureParent(reportPath); err != nil { + // Execute the agent command and relay its output streams. + if err := runAgent(agentCtx, agent, inv); err != nil { return err } - if !fileExists(reportPath) { - if err = os.WriteFile(reportPath, []byte(stdout), 0o644); err != nil { - return fmt.Errorf("write fallback report: %w", err) - } - logInfo("Report not found, wrote fallback using stdout output.") - } - if err = ensureParent(labelsPath); err != nil { - return err + // Inspect the outputs and warn if they were not produced. + if fileExists(defaultReportPath) { + logInfo(fmt.Sprintf("Report stored at %s.", defaultReportPath)) + } else { + logWarn(fmt.Sprintf("Report not produced at %s.", defaultReportPath)) } - if err = ensureLabelsFile(labelsPath); err != nil { - return err + if fileExists(defaultLabelsPath) { + logInfo(fmt.Sprintf("Labels stored at %s.", defaultLabelsPath)) + } else { + logWarn(fmt.Sprintf("Labels not produced at %s.", defaultLabelsPath)) } logInfo("Security review completed successfully.") - logInfo(fmt.Sprintf("Report stored at %s.", reportPath)) - logInfo(fmt.Sprintf("Labels stored at %s.", labelsPath)) return nil } @@ -230,12 +227,6 @@ func fetchEnv(name string, required bool) (string, error) { return value, nil } -// mustFetchOptional retrieves an optional environment variable without error returns. -func mustFetchOptional(name string) string { - value, _ := fetchEnv(name, false) - return value -} - // ensureParent creates directories needed for the provided path. func ensureParent(path string) error { dir := filepath.Dir(path) @@ -245,42 +236,20 @@ func ensureParent(path string) error { return os.MkdirAll(dir, 0o755) } -// ensureLabelsFile guarantees the labels file exists as a regular file. -func ensureLabelsFile(path string) error { - info, err := os.Stat(path) - if err == nil { - if info.IsDir() { - return fmt.Errorf("expected file at %s", path) - } - return nil - } - if errors.Is(err, os.ErrNotExist) { - if writeErr := os.WriteFile(path, []byte{}, 0o644); writeErr != nil { - return fmt.Errorf("create labels file %s: %w", path, writeErr) - } - logInfo(fmt.Sprintf("Labels file not found, created empty file at %s.", path)) - return nil - } - return fmt.Errorf("stat labels file %s: %w", path, err) -} - // runAgent executes the reviewer agent command and captures output streams. -func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) (string, string, error) { +func runAgent(ctx context.Context, agent reviewerAgent, inv agentInvocation) error { cmd, err := agent.BuildCommand(ctx, inv) if err != nil { - return "", "", err + return err } - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) - cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr if err = cmd.Run(); err != nil { - return stdout.String(), stderr.String(), fmt.Errorf("%s invocation failed: %w", agent.Name(), err) + return fmt.Errorf("%s invocation failed: %w", agent.Name(), err) } - - return stdout.String(), stderr.String(), nil + return nil } func resolveTimeout() (time.Duration, error) { @@ -301,23 +270,11 @@ func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) ( if displayLabel == "" { displayLabel = "[Not provided]" } - displayHead := strings.TrimSpace(headSHA) - if displayHead == "" { - displayHead = "[Not provided]" - } - displayBase := "[Not applicable]" + baseDisplay := "[Not applicable]" commitRange := "[Not applicable]" if mode == ReviewModeDifferential { - cleanBase := strings.TrimSpace(baseSHA) - cleanHead := strings.TrimSpace(headSHA) - if cleanBase == "" { - displayBase = "[Not provided]" - } else { - displayBase = cleanBase - } - if cleanBase != "" && cleanHead != "" { - commitRange = fmt.Sprintf("%s...%s", baseSHA, headSHA) - } + baseDisplay = baseSHA + commitRange = fmt.Sprintf("%s...%s", baseSHA, headSHA) } ph := promptPlaceholders{ @@ -325,8 +282,8 @@ func buildPromptContent(mode ReviewMode, targetLabel, headSHA, baseSHA string) ( ModeSummary: modeSummary(mode), TargetLabel: displayLabel, RepositoryPath: defaultRepositoryPath, - HeadCommit: displayHead, - BaseCommit: displayBase, + HeadCommit: headSHA, + BaseCommit: baseDisplay, CommitRange: commitRange, GitDiffHint: gitDiffHint(mode, baseSHA, headSHA), ReportPath: defaultReportPath, @@ -361,11 +318,6 @@ func renderPrompt(ph promptPlaceholders) (string, error) { // gitDiffHint conveys how the agent should inspect the repository state. func gitDiffHint(mode ReviewMode, baseSHA, headSHA string) string { if mode == ReviewModeDifferential { - cleanBase := strings.TrimSpace(baseSHA) - cleanHead := strings.TrimSpace(headSHA) - if cleanBase == "" || cleanHead == "" { - return fmt.Sprintf("Run `git diff` inside %s to inspect the change set.", defaultRepositoryPath) - } return fmt.Sprintf("Run `git diff %s...%s` (and related commands) inside %s to inspect the change set.", baseSHA, headSHA, defaultRepositoryPath) } return "Audit the entire working tree at the head commit." @@ -401,7 +353,7 @@ func fileExists(path string) bool { if err != nil { return false } - if info.IsDir() { + if info.Mode()&os.ModeType != 0 { return false } return info.Size() > 0 @@ -412,6 +364,11 @@ func logInfo(msg string) { fmt.Printf("[security-reviewer] %s\n", msg) } +// logWarn prints warning messages prefixed for clarity. +func logWarn(msg string) { + fmt.Printf("[security-reviewer] WARNING: %s\n", msg) +} + // logError prints error messages prefixed for clarity. func logError(err error) { var pathErr *fs.PathError diff --git a/cmd/security-reviewer/main.go b/cmd/security-reviewer/main.go index eb2fd4000..bc992cc5e 100644 --- a/cmd/security-reviewer/main.go +++ b/cmd/security-reviewer/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -17,22 +18,36 @@ import ( ) const ( - composeFileName = "compose.yml" - reportFileName = "report.md" - labelsFileName = "labels.txt" - repositoryDirName = "repository" - dockerExecutable = "docker" - gitExecutable = "git" - projectPrefix = "security-reviewer" - agentService = "reviewer" - composeRelativePath = "agents/security-reviewer" + // composeFileName is the compose manifest executed for each review run. + composeFileName = "compose.yml" + // reportFileName is the name of the individual report emitted by the agent. + reportFileName = "report.md" + // labelsFileName is the name of the label output emitted by the agent. + labelsFileName = "labels.txt" + // repositoryDirName is the working directory used to stage repository clones. + repositoryDirName = "repository" + // dockerExecutable identifies the docker CLI binary invoked by the tool. + dockerExecutable = "docker" + // gitExecutable identifies the git CLI binary used to manage repositories. + gitExecutable = "git" + // projectPrefix is applied to compose project names to make them unique yet readable. + projectPrefix = "security-reviewer" + // agentService is the compose service name running the security reviewer container. + agentService = "reviewer" + // composeRelativePath is the path to the compose project relative to the repository root. + composeRelativePath = "agents/security-reviewer" + // defaultTimeoutSeconds bounds agent execution time when not explicitly configured. defaultTimeoutSeconds = 3600 + // envAnthropicAPIKey is the environment variable supplying Claude credentials. envAnthropicAPIKey = "ANTHROPIC_API_KEY" - envOpenAIAPIKey = "OPENAI_API_KEY" + // envOpenAIAPIKey is the environment variable supplying Codex credentials. + envOpenAIAPIKey = "OPENAI_API_KEY" + // agentNameClaude identifies the Claude-based reviewer. agentNameClaude = "claude" - agentNameCodex = "codex" + // agentNameCodex identifies the Codex-based reviewer. + agentNameCodex = "codex" ) // options stores parsed CLI arguments. @@ -61,52 +76,44 @@ type options struct { TimeoutSeconds int } -var ( - flagAgent string - flagRepo string - flagHead string - flagBase string - flagTarget string - flagOutput string - flagLabels string - flagTimeoutSecs int - flagModel string - flagExtraArgs string - flagKeepWorkdir bool -) +var cliOpts = options{ + Agent: agentNameClaude, + OutputPath: "security-review.md", + TimeoutSeconds: defaultTimeoutSeconds, +} var rootCmd = &cobra.Command{ Use: "security-reviewer", Short: "Run the security reviewer compose workflow", RunE: func(cmd *cobra.Command, args []string) error { - agent := strings.ToLower(strings.TrimSpace(flagAgent)) + agent := strings.ToLower(strings.TrimSpace(cliOpts.Agent)) if agent == "" { agent = agentNameClaude } if agent != agentNameClaude && agent != agentNameCodex { - return fmt.Errorf("invalid agent %q (supported: %s, %s)", flagAgent, agentNameClaude, agentNameCodex) + return fmt.Errorf("invalid agent %q (supported: %s, %s)", cliOpts.Agent, agentNameClaude, agentNameCodex) } - labelsOutput := strings.TrimSpace(flagLabels) + labelsOutput := strings.TrimSpace(cliOpts.LabelsOutput) if labelsOutput == "" { - labelsOutput = deriveDefaultLabelsPath(flagOutput) + labelsOutput = deriveDefaultLabelsPath(cliOpts.OutputPath) } - timeoutSecs := flagTimeoutSecs + timeoutSecs := cliOpts.TimeoutSeconds if timeoutSecs <= 0 { timeoutSecs = defaultTimeoutSeconds } opts := options{ Agent: agent, - Repository: strings.TrimSpace(flagRepo), - HeadSHA: strings.TrimSpace(flagHead), - BaseSHA: strings.TrimSpace(flagBase), - TargetLabel: strings.TrimSpace(flagTarget), - OutputPath: flagOutput, + Repository: strings.TrimSpace(cliOpts.Repository), + HeadSHA: strings.TrimSpace(cliOpts.HeadSHA), + BaseSHA: strings.TrimSpace(cliOpts.BaseSHA), + TargetLabel: strings.TrimSpace(cliOpts.TargetLabel), + OutputPath: strings.TrimSpace(cliOpts.OutputPath), LabelsOutput: labelsOutput, - Model: strings.TrimSpace(flagModel), - ExtraArgs: strings.TrimSpace(flagExtraArgs), - KeepWorkdir: flagKeepWorkdir, + Model: strings.TrimSpace(cliOpts.Model), + ExtraArgs: strings.TrimSpace(cliOpts.ExtraArgs), + KeepWorkdir: cliOpts.KeepWorkdir, TimeoutSeconds: timeoutSecs, } @@ -122,17 +129,17 @@ var rootCmd = &cobra.Command{ } func init() { - rootCmd.Flags().StringVar(&flagAgent, "agent", agentNameClaude, "Reviewer agent to use (claude or codex).") - rootCmd.Flags().StringVar(&flagRepo, "repo", "", "Git repository URL or local path to review.") - rootCmd.Flags().StringVar(&flagHead, "head", "", "Head commit SHA to review.") - rootCmd.Flags().StringVar(&flagBase, "base", "", "Base commit SHA for differential reviews.") - rootCmd.Flags().StringVar(&flagTarget, "target-label", "", "Human readable identifier for the target.") - rootCmd.Flags().StringVar(&flagOutput, "output", "security-review.md", "Destination for the rendered report.") - rootCmd.Flags().StringVar(&flagLabels, "labels-output", "", "Destination for the labels file (defaults alongside the report).") - rootCmd.Flags().IntVar(&flagTimeoutSecs, "timeout", defaultTimeoutSeconds, "Maximum runtime for the review in seconds (defaults to 3600 seconds).") - rootCmd.Flags().StringVar(&flagModel, "model", "", "Override the reviewer model for the selected agent.") - rootCmd.Flags().StringVar(&flagExtraArgs, "extra-args", "", "Additional arguments passed to the reviewer agent.") - rootCmd.Flags().BoolVar(&flagKeepWorkdir, "keep-workdir", false, "Keep the temporary workspace after completion.") + rootCmd.Flags().StringVar(&cliOpts.Agent, "agent", cliOpts.Agent, "Reviewer agent to use (claude or codex).") + rootCmd.Flags().StringVar(&cliOpts.Repository, "repo", cliOpts.Repository, "Git repository URL or local path to review.") + rootCmd.Flags().StringVar(&cliOpts.HeadSHA, "head", cliOpts.HeadSHA, "Head commit SHA to review.") + rootCmd.Flags().StringVar(&cliOpts.BaseSHA, "base", cliOpts.BaseSHA, "Base commit SHA for differential reviews.") + rootCmd.Flags().StringVar(&cliOpts.TargetLabel, "target-label", cliOpts.TargetLabel, "Human readable identifier for the target.") + rootCmd.Flags().StringVar(&cliOpts.OutputPath, "output", cliOpts.OutputPath, "Destination for the rendered report.") + rootCmd.Flags().StringVar(&cliOpts.LabelsOutput, "labels-output", cliOpts.LabelsOutput, "Destination for the labels file (defaults alongside the report).") + rootCmd.Flags().IntVar(&cliOpts.TimeoutSeconds, "timeout", cliOpts.TimeoutSeconds, "Maximum runtime for the review in seconds (defaults to 3600 seconds).") + rootCmd.Flags().StringVar(&cliOpts.Model, "model", cliOpts.Model, "Override the reviewer model for the selected agent.") + rootCmd.Flags().StringVar(&cliOpts.ExtraArgs, "extra-args", cliOpts.ExtraArgs, "Additional arguments passed to the reviewer agent.") + rootCmd.Flags().BoolVar(&cliOpts.KeepWorkdir, "keep-workdir", cliOpts.KeepWorkdir, "Keep the temporary workspace after completion.") _ = rootCmd.MarkFlagRequired("repo") _ = rootCmd.MarkFlagRequired("head") @@ -233,17 +240,21 @@ func run(ctx context.Context, opts options) error { func deriveDefaultLabelsPath(reportPath string) string { reportPath = strings.TrimSpace(reportPath) if reportPath == "" { + // Without an explicit report, fall back to a stable default name. return "security-review-labels.txt" } + // Place the labels file alongside the report for easier discovery. dir := filepath.Dir(reportPath) base := filepath.Base(reportPath) idx := strings.LastIndex(base, ".") if idx > 0 { + // Drop the extension so the generated labels file mirrors the report name. base = base[:idx] } if strings.TrimSpace(base) == "" { base = "security-review" } + // Append a suffix to distinguish the labels artifact from the report. return filepath.Join(dir, base+"-labels.txt") } @@ -260,7 +271,6 @@ func sanitizeName(text string) string { } // prepareRepository clones the repository and materializes commits for review. - func prepareRepository(ctx context.Context, opts options, repositoryDir string) error { parentDir := filepath.Dir(repositoryDir) if err := os.MkdirAll(parentDir, 0o755); err != nil { @@ -309,14 +319,23 @@ func ensureCommit(ctx context.Context, repoDir, sha string) error { // copyFile copies a file from src to dst, creating parent directories. func copyFile(src, dst string) error { - data, err := os.ReadFile(src) + in, err := os.Open(src) if err != nil { - return fmt.Errorf("read file %s: %w", src, err) + return fmt.Errorf("open file %s: %w", src, err) } + defer in.Close() if err = os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return fmt.Errorf("create directory for %s: %w", dst, err) } - return os.WriteFile(dst, data, 0o644) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("open destination %s: %w", dst, err) + } + defer out.Close() + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("copy %s to %s: %w", src, dst, err) + } + return nil } // runCompose executes the docker compose workflow for the review. From bfd3a5a452d7f181917eaf684fa75dc69286f449 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Tue, 28 Oct 2025 16:31:06 -0600 Subject: [PATCH 26/29] ci: final workflow tweaks before merging Signed-off-by: Jacob Howard --- .../workflows/security-review-changes.yaml | 400 +++++++++++------- .github/workflows/security-review-manual.yaml | 79 ++-- .github/workflows/update-pins.yaml | 44 +- cmd/ci/update_pins.go | 11 + 4 files changed, 331 insertions(+), 203 deletions(-) diff --git a/.github/workflows/security-review-changes.yaml b/.github/workflows/security-review-changes.yaml index a5605ed1a..aca4df519 100644 --- a/.github/workflows/security-review-changes.yaml +++ b/.github/workflows/security-review-changes.yaml @@ -7,6 +7,18 @@ on: description: "Optional pull request number to review" required: false default: "" + agent: + description: "Optional reviewer agent (claude or codex)." + required: false + default: "" + model: + description: "Optional reviewer model override." + required: false + default: "" + timeout_secs: + description: "Optional reviewer timeout in seconds (defaults to 1800)." + required: false + default: "" # pull_request: # types: # - opened @@ -54,11 +66,13 @@ jobs: set -euo pipefail base_sha="${{ github.event.pull_request.base.sha }}" head_sha="${{ github.sha }}" + pr_number="${{ github.event.pull_request.number }}" if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pull_request_number }}" ]; then - pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid) + pr_json=$(gh pr view "${{ github.event.inputs.pull_request_number }}" --json baseRefOid,headRefOid,number) base_sha=$(echo "$pr_json" | jq -r '.baseRefOid') head_sha=$(echo "$pr_json" | jq -r '.headRefOid') + pr_number=$(echo "$pr_json" | jq -r '.number') fi if [ -n "$base_sha" ]; then @@ -67,6 +81,9 @@ jobs: if [ -n "$head_sha" ]; then echo "head=$head_sha" >> "$GITHUB_OUTPUT" fi + if [ -n "$pr_number" ] && [ "$pr_number" != "null" ]; then + echo "pr=$pr_number" >> "$GITHUB_OUTPUT" + fi - name: Collect updated pin targets id: pins @@ -124,147 +141,258 @@ jobs: echo "has_targets=false" >> "$GITHUB_OUTPUT" fi - - name: Run security reviewer (Claude) + - name: Gather existing security review metadata + if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + id: existing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + metadata_file="existing-reviews.json" + pr_number="${{ steps.revision.outputs.pr }}" + repo="${{ github.repository }}" + + if [ -n "$pr_number" ]; then + gh api \ + --paginate \ + repos/$repo/issues/$pr_number/comments \ + --jq '[.[] | select(.body | contains("").json | fromjson)}]' \ + > "$metadata_file" || echo '[]' > "$metadata_file" + else + echo '[]' > "$metadata_file" + fi + + if [ ! -s "$metadata_file" ]; then + echo '[]' > "$metadata_file" + fi + + echo "file=$metadata_file" >> "$GITHUB_OUTPUT" + + - name: Run security reviewers if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REVIEW_AGENT_INPUT: ${{ github.event.inputs.agent }} + REVIEW_MODEL_INPUT: ${{ github.event.inputs.model }} + REVIEW_TIMEOUT_INPUT: ${{ github.event.inputs.timeout_secs }} run: | set -euo pipefail - agent="claude" + + agent="${REVIEW_AGENT_INPUT:-}" + if [ -z "$agent" ]; then + agent="claude" + fi + + model="${REVIEW_MODEL_INPUT:-}" + + timeout_secs="${REVIEW_TIMEOUT_INPUT:-1800}" + + pr_number="${{ steps.revision.outputs.pr }}" + repo="${{ github.repository }}" + metadata_file="${{ steps.existing.outputs.file }}" + if [ -z "$metadata_file" ] || [ ! -f "$metadata_file" ]; then + metadata_file="existing-reviews.json" + echo '[]' > "$metadata_file" + fi mkdir -p review-output - combined_body="review-output/security-review.body" - combined_report="review-output/security-review.md" - combined_labels="review-output/labels.txt" - : > "$combined_body" - : > "$combined_labels" + results_tmp="review-output/review-results.ndjson" + : > "$results_tmp" + if [ -s "$metadata_file" ]; then + jq -c '.[] | {target: .metadata.target, mode: .metadata.mode, base: (.metadata.base // ""), head: (.metadata.head // ""), labels: (.metadata.labels // []), comment_id: (.id|tostring)}' "$metadata_file" >> "$results_tmp" + fi - if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then - while read -r target; do - server=$(echo "$target" | jq -r '.server') - project=$(echo "$target" | jq -r '.project') - base_commit=$(echo "$target" | jq -r '.old_commit') - head_commit=$(echo "$target" | jq -r '.new_commit') + github_repo_regex='^https://github.com/([^/]+)/([^/]+?)(\.git)?$' - if [ -z "$project" ] || [ "$project" = "null" ]; then - echo "Skipping $server: missing project URL." >&2 - continue - fi + review_target() { + local mode="$1" + local server="$2" + local project="$3" + local base_commit="$4" + local head_commit="$5" + local comment_id="" + + if [ -z "$project" ] || [ "$project" = "null" ]; then + echo "Skipping $server: missing project URL." >&2 + return + fi + if [ "$mode" = "differential" ]; then if [ -z "$base_commit" ] || [ "$base_commit" = "null" ] || [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then echo "Skipping $server: missing commit information." >&2 - continue + return fi - - report_path="review-output/${server}-diff.md" - labels_path="review-output/${server}-diff-labels.txt" - - task security-reviewer -- \ - --agent "$agent" \ - --mode diff \ - --repo "$project" \ - --base "$base_commit" \ - --head "$head_commit" \ - --target-label "$server" \ - --output "$report_path" \ - --labels-output "$labels_path" - - { - echo "## ${server} (updated pin)" - echo "" - cat "$report_path" - echo "" - } >> "$combined_body" - - if [ -s "$labels_path" ]; then - cat "$labels_path" >> "$combined_labels" + else + if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then + echo "Skipping $server: missing head commit." >&2 + return fi - done < <(jq -c '.[]' "${{ steps.pins.outputs.context }}") - fi + base_commit="" + fi - if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then - while read -r target; do - server=$(echo "$target" | jq -r '.server') - project=$(echo "$target" | jq -r '.project') - head_commit=$(echo "$target" | jq -r '.commit') + local exact_id + exact_id=$(jq -r --arg mode "$mode" --arg target "$server" --arg base "$base_commit" --arg head "$head_commit" ' + map(select(.metadata.mode == $mode and .metadata.target == $target and (.metadata.base // "") == $base and (.metadata.head // "") == $head)) + | first + | (.id|tostring // "") + ' "$metadata_file") + + if [ -n "$exact_id" ]; then + echo "Skipping $server ($mode): already reviewed for this commit." >&2 + return + fi + + local existing_comment_id + existing_comment_id=$(jq -r --arg mode "$mode" --arg target "$server" ' + map(select(.metadata.mode == $mode and .metadata.target == $target)) + | first + | (.id|tostring // "") + ' "$metadata_file") + + local report_suffix + if [ "$mode" = "differential" ]; then + report_suffix="diff" + else + report_suffix="full" + fi + + local report_path="review-output/${server}-${report_suffix}.md" + local labels_path="review-output/${server}-${report_suffix}-labels.txt" + + cmd=(task security-reviewer -- \ + --agent "$agent" \ + --repo "$project" \ + --head "$head_commit" \ + --target-label "$server" \ + --output "$report_path" \ + --labels-output "$labels_path" \ + --timeout "$timeout_secs") + if [ "$mode" = "differential" ]; then + cmd+=(--base "$base_commit") + fi + if [ -n "$model" ]; then + cmd+=(--model "$model") + fi - if [ -z "$project" ] || [ "$project" = "null" ]; then - echo "Skipping $server: missing project URL." >&2 - continue + "${cmd[@]}" + + local labels_json + if [ -s "$labels_path" ]; then + labels_json=$(jq -Rs 'split("\n") | map(select(length > 0))' "$labels_path") + else + labels_json='[]' + fi + + local repo_slug="" + if [[ "$project" =~ $github_repo_regex ]]; then + repo_slug="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + fi + + local link_line + if [ -n "$repo_slug" ]; then + if [ "$mode" = "differential" ]; then + link_line="- Diff: [view changes](https://github.com/${repo_slug}/compare/${base_commit}...${head_commit})" + else + link_line="- Snapshot: [view tree](https://github.com/${repo_slug}/tree/${head_commit})" fi - if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then - echo "Skipping $server: missing commit information." >&2 - continue + else + if [ "$mode" = "differential" ]; then + link_line="- Diff: unavailable (non-GitHub repository)." + else + link_line="- Snapshot: unavailable (non-GitHub repository)." fi + fi - report_path="review-output/${server}-new.md" - labels_path="review-output/${server}-new-labels.txt" - - task security-reviewer -- \ - --agent "$agent" \ - --mode full \ - --repo "$project" \ - --head "$head_commit" \ - --target-label "$server" \ - --output "$report_path" \ - --labels-output "$labels_path" - - { - echo "## ${server} (new server)" - echo "" - cat "$report_path" - echo "" - } >> "$combined_body" - - if [ -s "$labels_path" ]; then - cat "$labels_path" >> "$combined_labels" - fi - done < <(jq -c '.[]' "${{ steps.newservers.outputs.context }}") - fi + local mode_label + if [ "$mode" = "differential" ]; then + mode_label="Differential" + else + mode_label="Full" + fi - summary_tmp="review-output/change-summary.body" - : > "$summary_tmp" - if [ -s pins-summary.md ]; then + local labels_summary + labels_summary=$(echo "$labels_json" | jq -r 'if length == 0 then "- Labels: none." else "- Labels: " + (join(", ")) end') + + local metadata_json + metadata_json=$(jq -n --arg target "$server" --arg mode "$mode" --arg base "$base_commit" --arg head "$head_commit" --argjson labels "$labels_json" '{target:$target,mode:$mode,base:$base,head:$head,labels:$labels}') + + local comment_body="review-output/${server}-${report_suffix}-comment.md" { - echo "## Updated Commit Pins" + echo "⚠️ **Automated security review (beta).** This system may make mistakes and is intended only to aid human reviewers." echo "" - cat pins-summary.md + echo "- Target: \\`$server\\`" + echo "- Review mode: $mode_label" + echo "$link_line" + echo "$labels_summary" echo "" - } >> "$summary_tmp" - fi - if [ -s new-servers-summary.md ]; then - { - echo "## New Local Servers" + echo "---" echo "" - cat new-servers-summary.md + cat "$report_path" echo "" - } >> "$summary_tmp" - fi + echo "" + } > "$comment_body" - if [ -s "$combined_body" ] || [ -s "$summary_tmp" ]; then - { - if [ -s "$summary_tmp" ]; then - echo "# Security Review Targets" - echo "" - cat "$summary_tmp" - echo "" - fi - if [ -s "$combined_body" ]; then - echo "# Automated Security Review" - echo "" - cat "$combined_body" + if [ -z "$pr_number" ]; then + echo "No pull request number available; skipping comment publication for $server." >&2 + else + body_json=$(jq -Rs '.' "$comment_body") + if [ -n "$existing_comment_id" ]; then + printf '{"body":%s}\n' "$body_json" | gh api \ + repos/$repo/issues/comments/$existing_comment_id \ + --method PATCH \ + --header "Content-Type: application/json" \ + --input - >/dev/null + comment_id="$existing_comment_id" + else + comment_response=$(printf '{"body":%s}\n' "$body_json" | gh api \ + repos/$repo/issues/$pr_number/comments \ + --method POST \ + --header "Content-Type: application/json" \ + --input -) + comment_id=$(echo "$comment_response" | jq -r '.id | tostring') fi - } > "$combined_report" - else - rm -f "$combined_report" + fi + + jq -n \ + --arg target "$server" \ + --arg mode "$mode" \ + --arg base "$base_commit" \ + --arg head "$head_commit" \ + --argjson labels "$labels_json" \ + --arg comment_id "$comment_id" \ + '{target:$target,mode:$mode,base:$base,head:$head,labels:$labels,comment_id:$comment_id}' \ + >> "$results_tmp" + } + + if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + base_commit=$(echo "$target" | jq -r '.old_commit') + head_commit=$(echo "$target" | jq -r '.new_commit') + review_target "differential" "$server" "$project" "$base_commit" "$head_commit" + done < <(jq -c '.[]' "${{ steps.pins.outputs.context }}") fi - rm -f "$combined_body" "$summary_tmp" - if [ -s "$combined_labels" ]; then - sort -u "$combined_labels" -o "$combined_labels" + if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + head_commit=$(echo "$target" | jq -r '.commit') + review_target "full" "$server" "$project" "" "$head_commit" + done < <(jq -c '.[]' "${{ steps.newservers.outputs.context }}") + fi + + if [ -s "$results_tmp" ]; then + jq -s 'reduce .[] as $item ({}; .[$item.mode + "|" + $item.target] = $item) | [.[]]' "$results_tmp" > "review-output/review-results.json" + jq -r '.[] | .labels[]?' "review-output/review-results.json" | sort -u > "review-output/labels.txt" || true + if [ ! -s "review-output/labels.txt" ]; then + rm -f "review-output/labels.txt" + fi else - rm -f "$combined_labels" + echo '[]' > "review-output/review-results.json" fi - name: Apply security labels @@ -326,53 +454,5 @@ jobs: fi for label in "${final_labels[@]}"; do - if [ "$label" = "security-blocked" ]; then - gh label create "$label" \ - --color B60205 \ - --description "Security automation detected blocking issues." \ - --repo "$repo" \ - || echo "Label $label already exists." - else - gh label create "$label" \ - --color 0E8A16 \ - --description "Security automation risk assessment." \ - --repo "$repo" \ - || echo "Label $label already exists." - fi - - gh pr edit "$pr_number" --repo "$repo" --add-label "$label" + gh pr edit "$pr_number" --repo "$repo" --add-label "$label" || echo "Warning: unable to apply label $label" >&2 done - - - name: Post security review as PR comment - if: always() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - if [ ! -f "${{ github.workspace }}/review-output/security-review.md" ]; then - echo "No security review report produced." - exit 0 - fi - - { - cat "${{ github.workspace }}/review-output/security-review.md" - echo "" - echo "" - } > security-review-comment.md - - comment_id=$(gh api \ - repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ - --jq '.[] | select(.body | contains("")) | .id' \ - || true) - - if [ -n "$comment_id" ]; then - gh api \ - -X PATCH \ - -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository }}/issues/comments/$comment_id \ - -F body=@security-review-comment.md - else - gh pr comment ${{ github.event.pull_request.number }} \ - --repo ${{ github.repository }} \ - --body-file security-review-comment.md - fi diff --git a/.github/workflows/security-review-manual.yaml b/.github/workflows/security-review-manual.yaml index dc7b704bc..2947d29f7 100644 --- a/.github/workflows/security-review-manual.yaml +++ b/.github/workflows/security-review-manual.yaml @@ -7,6 +7,18 @@ on: description: "Comma-separated list of local server names to audit (leave blank for all)." required: false default: "" + agent: + description: "Optional reviewer agent (claude or codex)." + required: false + default: "" + model: + description: "Optional reviewer model override." + required: false + default: "" + timeout_secs: + description: "Optional reviewer timeout in seconds (defaults to 1800)." + required: false + default: "" concurrency: group: security-review-manual-${{ github.run_id }} @@ -37,6 +49,7 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Collect audit targets + id: collect run: | set -euo pipefail task ci -- collect-full-audit \ @@ -45,28 +58,33 @@ jobs: --output-json audit-targets.json if jq -e '. | length > 0' audit-targets.json >/dev/null; then - echo "AUDIT_HAS_TARGETS=true" >> "$GITHUB_ENV" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + echo "targets=audit-targets.json" >> "$GITHUB_OUTPUT" else echo "No audit targets identified; exiting." >&2 - echo "AUDIT_HAS_TARGETS=false" >> "$GITHUB_ENV" + echo "has_targets=false" >> "$GITHUB_OUTPUT" fi - - name: Run security reviewer (Claude) - if: env.AUDIT_HAS_TARGETS == 'true' + - name: Run security reviewer + if: steps.collect.outputs.has_targets == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + REVIEW_AGENT_INPUT: ${{ github.event.inputs.agent }} + REVIEW_MODEL_INPUT: ${{ github.event.inputs.model }} + REVIEW_TIMEOUT_INPUT: ${{ github.event.inputs.timeout_secs }} run: | set -euo pipefail - agent="claude" + agent="${REVIEW_AGENT_INPUT:-}" + if [ -z "$agent" ]; then + agent="claude" + fi - mkdir -p reports - combined_body="reports/full-audit.body" - combined_report="reports/full-audit-report.md" - combined_labels="reports/full-audit-labels.txt" + model="${REVIEW_MODEL_INPUT:-}" - : > "$combined_body" - : > "$combined_labels" + timeout_secs="${REVIEW_TIMEOUT_INPUT:-1800}" + + mkdir -p reports while read -r target; do server=$(echo "$target" | jq -r '.server') @@ -84,47 +102,24 @@ jobs: report_path="reports/${server}.md" labels_path="reports/${server}-labels.txt" - - task security-reviewer -- \ + cmd=(task security-reviewer -- \ --agent "$agent" \ - --mode full \ --repo "$project" \ --head "$head_commit" \ --target-label "$server" \ --output "$report_path" \ - --labels-output "$labels_path" - - { - echo "## ${server}" - echo "" - cat "$report_path" - echo "" - } >> "$combined_body" + --labels-output "$labels_path" \ + --timeout "$timeout_secs") - if [ -s "$labels_path" ]; then - cat "$labels_path" >> "$combined_labels" + if [ -n "$model" ]; then + cmd+=(--model "$model") fi - done < <(jq -c '.[]' audit-targets.json) - - if [ -s "$combined_body" ]; then - { - echo "# Full Security Audit" - echo "" - cat "$combined_body" - } > "$combined_report" - else - rm -f "$combined_report" - fi - rm -f "$combined_body" - if [ -s "$combined_labels" ]; then - sort -u "$combined_labels" -o "$combined_labels" - else - rm -f "$combined_labels" - fi + "${cmd[@]}" + done < <(jq -c '.[]' "${{ steps.collect.outputs.targets }}") - name: Upload security reports - if: env.AUDIT_HAS_TARGETS == 'true' + if: steps.collect.outputs.has_targets == 'true' uses: actions/upload-artifact@v4 with: name: security-reports diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml index 675784357..70f5c7bf0 100644 --- a/.github/workflows/update-pins.yaml +++ b/.github/workflows/update-pins.yaml @@ -6,6 +6,15 @@ on: schedule: - cron: "0 0 1 * *" workflow_dispatch: + inputs: + max_new_prs: + description: "Maximum number of new pull requests to create (leave blank for unlimited)." + required: false + default: "" + servers: + description: "Comma-separated list of servers to update (leave blank for all)." + required: false + default: "" concurrency: group: update-pins @@ -48,6 +57,8 @@ jobs: - name: Collect per-server patches id: prepare + env: + ALLOWED_SERVERS: ${{ github.event.inputs.servers || '' }} run: | # Gather the diff for each modified server YAML and store it as an # individual patch file so we can open one PR per server. @@ -58,13 +69,29 @@ jobs: exit 0 fi + allowed_servers=$(echo "$ALLOWED_SERVERS" | tr '[:upper:]' '[:lower:]' | tr -d ' ') server_list=() for file in $changed_files; do server=$(basename "$(dirname "$file")") + server_lc=$(echo "$server" | tr '[:upper:]' '[:lower:]') + + if [ -n "$allowed_servers" ]; then + if ! echo ",$allowed_servers," | grep -q ",$server_lc,"; then + continue + fi + fi + git diff -- "$file" > "patches/${server}.patch" server_list+=("$server") done + if [ ${#server_list[@]} -eq 0 ]; then + echo "No servers matched the provided filter; exiting." >&2 + git checkout -- servers + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Reset the working tree so we can apply patches one-at-a-time. git checkout -- servers @@ -73,12 +100,20 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" echo "servers=$(cat patches/servers.txt)" >> "$GITHUB_OUTPUT" - - name: Create pull requests + - name: Create or update pull requests if: steps.prepare.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAX_NEW_PRS: ${{ github.event.inputs.max_new_prs || '' }} run: | IFS=',' read -ra SERVERS <<< "${{ steps.prepare.outputs.servers }}" + new_pr_limit=$(echo "$MAX_NEW_PRS" | tr -d ' ') + if [ -n "$new_pr_limit" ] && ! [[ "$new_pr_limit" =~ ^[0-9]+$ ]]; then + echo "Invalid max_new_prs value: $new_pr_limit" >&2 + exit 1 + fi + new_pr_count=0 + for server in "${SERVERS[@]}"; do patch="patches/${server}.patch" if [ ! -s "$patch" ]; then @@ -106,6 +141,11 @@ jobs: echo "Existing PR for $server already pins ${existing_commit}; skipping." continue fi + else + if [ -n "$new_pr_limit" ] && [ "$new_pr_count" -ge "$new_pr_limit" ]; then + echo "New PR quota reached ($new_pr_limit); skipping $server." + continue + fi fi # Apply the patch onto a fresh branch for this server. @@ -136,7 +176,9 @@ jobs: --body "Automated commit pin update for ${server}." \ --base main \ --head "$branch" + new_pr_count=$((new_pr_count + 1)) fi done + # Leave the repository in a clean state. git checkout main diff --git a/cmd/ci/update_pins.go b/cmd/ci/update_pins.go index 225c9d94e..45cda0c88 100644 --- a/cmd/ci/update_pins.go +++ b/cmd/ci/update_pins.go @@ -38,6 +38,9 @@ func runUpdatePins(args []string) error { } serverPath := filepath.Join("servers", entry.Name(), "server.yaml") + + // Parse the server definition so that we can evaluate eligibility and + // discover the backing GitHub repository and branch information. server, err := servers.Read(serverPath) if err != nil { fmt.Fprintf(os.Stderr, "reading %s: %v\n", serverPath, err) @@ -54,6 +57,8 @@ func runUpdatePins(args []string) error { continue } + // Only repositories hosted on GitHub can be advanced by this command, + // because the helper client relies on the GitHub API for commit lookup. if !strings.Contains(server.Source.Project, "github.com/") { fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name) continue @@ -66,6 +71,8 @@ func runUpdatePins(args []string) error { } client := github.NewFromServer(server) + // Resolve the latest commit on the configured branch so we can refresh + // the pin if it has advanced since the last sweep. latest, err := client.GetCommitSHA1(ctx, server.Source.Project, server.GetBranch()) if err != nil { fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err) @@ -121,6 +128,8 @@ func writePinnedCommit(path string, updated string) (bool, error) { return false, fmt.Errorf("no source block found in %s", path) } + // Scan the nested source block until we locate the commit attribute, + // capturing its indentation so that formatting survives the rewrite. commitIndex := -1 indent := "" commitPattern := regexp.MustCompile(`^([ \t]+)commit:\s*[a-fA-F0-9]{40}\s*$`) @@ -141,6 +150,8 @@ func writePinnedCommit(path string, updated string) (bool, error) { return false, fmt.Errorf("no commit line found in %s", path) } + // Replace only the commit value so that other keys maintain their + // original ordering and indentation. newLine := indent + "commit: " + updated lines[commitIndex] = newLine From 4e538ca4ba1229a60b8e9dbdb5526a548b08ac0f Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Tue, 28 Oct 2025 16:46:03 -0600 Subject: [PATCH 27/29] agents/sec-rev: adjust healthcheck path Signed-off-by: Jacob Howard --- agents/security-reviewer/compose.yml | 2 +- agents/security-reviewer/proxy/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/security-reviewer/compose.yml b/agents/security-reviewer/compose.yml index b026da9d0..a46a7b212 100644 --- a/agents/security-reviewer/compose.yml +++ b/agents/security-reviewer/compose.yml @@ -54,7 +54,7 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1"] + test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveness || exit 1"] interval: 5s timeout: 5s retries: 5 diff --git a/agents/security-reviewer/proxy/main.go b/agents/security-reviewer/proxy/main.go index 624e3cc94..a1efe6429 100644 --- a/agents/security-reviewer/proxy/main.go +++ b/agents/security-reviewer/proxy/main.go @@ -31,7 +31,7 @@ const ( // anthropicInboundPrefix is the path prefix used to route requests to Anthropic. anthropicInboundPrefix = "/anthropic/" // healthPath is the HTTP endpoint used for container health checks. - healthPath = "/health/liveliness" + healthPath = "/health/liveness" // headerAuthorization is the inbound HTTP header that carries bearer tokens. headerAuthorization = "Authorization" // headerAnthropicAPIKey is the Anthropic-specific header carrying API keys. From 170843fcbcb2a848edb559172545afb9b2b87528 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Tue, 28 Oct 2025 17:43:58 -0600 Subject: [PATCH 28/29] ci: switch security-review workflow to use GitHub checks Signed-off-by: Jacob Howard --- .../workflows/security-review-changes.yaml | 470 +++++++++--------- .github/workflows/security-review-manual.yaml | 2 +- 2 files changed, 226 insertions(+), 246 deletions(-) diff --git a/.github/workflows/security-review-changes.yaml b/.github/workflows/security-review-changes.yaml index aca4df519..d1eae9cf3 100644 --- a/.github/workflows/security-review-changes.yaml +++ b/.github/workflows/security-review-changes.yaml @@ -19,6 +19,11 @@ on: description: "Optional reviewer timeout in seconds (defaults to 1800)." required: false default: "" + force_review: + description: "Force re-review even if already completed for this commit" + required: false + type: boolean + default: false # pull_request: # types: # - opened @@ -29,7 +34,7 @@ on: concurrency: group: security-review-changes-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true + cancel-in-progress: false jobs: pr-security-review: @@ -38,8 +43,7 @@ jobs: if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' permissions: contents: read - pull-requests: write - issues: write + checks: write steps: - name: Checkout repository @@ -86,7 +90,7 @@ jobs: fi - name: Collect updated pin targets - id: pins + id: updatedpins env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -141,35 +145,79 @@ jobs: echo "has_targets=false" >> "$GITHUB_OUTPUT" fi - - name: Gather existing security review metadata - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - id: existing + - name: Create pending security review checks + if: steps.updatedpins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + id: checks env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REVIEW_AGENT_INPUT: ${{ github.event.inputs.agent }} run: | set -euo pipefail - metadata_file="existing-reviews.json" - pr_number="${{ steps.revision.outputs.pr }}" + + agent="${REVIEW_AGENT_INPUT:-}" + if [ -z "$agent" ]; then + agent="claude" + fi + + head_sha="${{ steps.revision.outputs.head }}" repo="${{ github.repository }}" - if [ -n "$pr_number" ]; then - gh api \ - --paginate \ - repos/$repo/issues/$pr_number/comments \ - --jq '[.[] | select(.body | contains("").json | fromjson)}]' \ - > "$metadata_file" || echo '[]' > "$metadata_file" - else - echo '[]' > "$metadata_file" + mkdir -p review-output + + # Store check names and IDs in files for the next step. + > review-output/check-ids.txt + + # Function to create a pending check and return its ID. + create_pending_check() { + local check_name="$1" + local server="$2" + local review_type="$3" + + gh api repos/$repo/check-runs \ + --method POST \ + --field name="$check_name" \ + --field head_sha="$head_sha" \ + --field status="queued" \ + --field output[title]="Security Review: $server" \ + --field output[summary]="Queued for $review_type security review..." \ + --jq '.id' + } + + # Create checks for updated pins (differential reviews). + if [ "${{ steps.updatedpins.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + old_commit=$(echo "$target" | jq -r '.old_commit') + new_commit=$(echo "$target" | jq -r '.new_commit') + + check_name="security-review/$agent/pin/$server" + check_id=$(create_pending_check "$check_name" "$server" "differential") + + echo "$check_name|$check_id|$server|$project|$new_commit|$old_commit|differential" >> review-output/check-ids.txt + done < <(jq -c '.[]' "${{ steps.updatedpins.outputs.context }}") fi - if [ ! -s "$metadata_file" ]; then - echo '[]' > "$metadata_file" + # Create checks for new servers (full reviews). + if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then + while read -r target; do + server=$(echo "$target" | jq -r '.server') + project=$(echo "$target" | jq -r '.project') + commit=$(echo "$target" | jq -r '.commit') + + check_name="security-review/$agent/new/$server" + check_id=$(create_pending_check "$check_name" "$server" "full") + + echo "$check_name|$check_id|$server|$project|$commit||full" >> review-output/check-ids.txt + done < <(jq -c '.[]' "${{ steps.newservers.outputs.context }}") fi - echo "file=$metadata_file" >> "$GITHUB_OUTPUT" + num_checks=$(wc -l < review-output/check-ids.txt) + echo "Created $num_checks pending check(s)" + echo "has_checks=true" >> "$GITHUB_OUTPUT" - - name: Run security reviewers - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' + - name: Run security reviews + if: steps.checks.outputs.has_checks == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -177,6 +225,7 @@ jobs: REVIEW_AGENT_INPUT: ${{ github.event.inputs.agent }} REVIEW_MODEL_INPUT: ${{ github.event.inputs.model }} REVIEW_TIMEOUT_INPUT: ${{ github.event.inputs.timeout_secs }} + FORCE_REVIEW_INPUT: ${{ github.event.inputs.force_review }} run: | set -euo pipefail @@ -186,80 +235,110 @@ jobs: fi model="${REVIEW_MODEL_INPUT:-}" - timeout_secs="${REVIEW_TIMEOUT_INPUT:-1800}" + force_review="${FORCE_REVIEW_INPUT:-false}" - pr_number="${{ steps.revision.outputs.pr }}" + head_sha="${{ steps.revision.outputs.head }}" repo="${{ github.repository }}" - metadata_file="${{ steps.existing.outputs.file }}" - if [ -z "$metadata_file" ] || [ ! -f "$metadata_file" ]; then - metadata_file="existing-reviews.json" - echo '[]' > "$metadata_file" - fi - mkdir -p review-output + # Maximum size for GitHub check output text field (in bytes). + max_check_output_size=65000 + + # Function to determine check conclusion from labels. + determine_conclusion() { + local labels_file="$1" + + if [ ! -s "$labels_file" ]; then + echo "success" + elif grep -qE '^security-blocked$' "$labels_file"; then + echo "failure" + elif grep -qE '^security-risk:(critical|high)$' "$labels_file"; then + echo "failure" + elif grep -qE '^security-risk:medium$' "$labels_file"; then + echo "neutral" + else + echo "success" + fi + } - results_tmp="review-output/review-results.ndjson" - : > "$results_tmp" - if [ -s "$metadata_file" ]; then - jq -c '.[] | {target: .metadata.target, mode: .metadata.mode, base: (.metadata.base // ""), head: (.metadata.head // ""), labels: (.metadata.labels // []), comment_id: (.id|tostring)}' "$metadata_file" >> "$results_tmp" - fi + # Function to check if a review is already completed (unless forced). + should_skip_review() { + local check_name="$1" - github_repo_regex='^https://github.com/([^/]+)/([^/]+?)(\.git)?$' + [ "$force_review" != "true" ] && \ + [ -n "$(gh api repos/$repo/commits/$head_sha/check-runs \ + --jq ".check_runs[] | select(.name == \"$check_name\" and .status == \"completed\")" 2>/dev/null)" ] + } - review_target() { - local mode="$1" + # Function to mark a check as skipped. + skip_check() { + local check_id="$1" local server="$2" - local project="$3" - local base_commit="$4" + local reason="$3" + + gh api repos/$repo/check-runs/$check_id \ + --method PATCH \ + --field status="completed" \ + --field conclusion="skipped" \ + --field output[title]="Security Review: $server" \ + --field output[summary]="Skipped - $reason" + } + + # Function to create a pending check and return its ID. + create_pending_check() { + local check_name="$1" + local server="$2" + local review_type="$3" + + gh api repos/$repo/check-runs \ + --method POST \ + --field name="$check_name" \ + --field head_sha="$head_sha" \ + --field status="queued" \ + --field output[title]="Security Review: $server" \ + --field output[summary]="Queued for $review_type security review..." \ + --jq '.id' + } + + # Function to run a security review and update a check. + run_review() { + local check_name="$1" + local check_id="$2" + local server="$3" + local project="$4" local head_commit="$5" - local comment_id="" + local base_commit="$6" + local review_type="$7" if [ -z "$project" ] || [ "$project" = "null" ]; then echo "Skipping $server: missing project URL." >&2 + skip_check "$check_id" "$server" "missing project URL" return fi - if [ "$mode" = "differential" ]; then - if [ -z "$base_commit" ] || [ "$base_commit" = "null" ] || [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then - echo "Skipping $server: missing commit information." >&2 - return - fi - else - if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then - echo "Skipping $server: missing head commit." >&2 - return - fi - base_commit="" + if [ -z "$head_commit" ] || [ "$head_commit" = "null" ]; then + echo "Skipping $server: missing head commit." >&2 + skip_check "$check_id" "$server" "missing head commit" + return fi - local exact_id - exact_id=$(jq -r --arg mode "$mode" --arg target "$server" --arg base "$base_commit" --arg head "$head_commit" ' - map(select(.metadata.mode == $mode and .metadata.target == $target and (.metadata.base // "") == $base and (.metadata.head // "") == $head)) - | first - | (.id|tostring // "") - ' "$metadata_file") - - if [ -n "$exact_id" ]; then - echo "Skipping $server ($mode): already reviewed for this commit." >&2 + # Check if we should skip this review. + if should_skip_review "$check_name"; then + echo "Skipping $server: review already completed for this commit (use force_review to re-run)" >&2 + skip_check "$check_id" "$server" "review already completed for this commit" return fi - local existing_comment_id - existing_comment_id=$(jq -r --arg mode "$mode" --arg target "$server" ' - map(select(.metadata.mode == $mode and .metadata.target == $target)) - | first - | (.id|tostring // "") - ' "$metadata_file") + echo "Starting $review_type review for $server..." - local report_suffix - if [ "$mode" = "differential" ]; then - report_suffix="diff" - else - report_suffix="full" - fi + # Mark check as in progress. + gh api repos/$repo/check-runs/$check_id \ + --method PATCH \ + --field status="in_progress" \ + --field output[summary]="Running $review_type security review..." - local report_path="review-output/${server}-${report_suffix}.md" - local labels_path="review-output/${server}-${report_suffix}-labels.txt" + # Run the security review. + report_path="review-output/${server}-${review_type}.md" + labels_path="review-output/${server}-${review_type}-labels.txt" cmd=(task security-reviewer -- \ --agent "$agent" \ @@ -269,190 +348,91 @@ jobs: --output "$report_path" \ --labels-output "$labels_path" \ --timeout "$timeout_secs") - if [ "$mode" = "differential" ]; then - cmd+=(--base "$base_commit") - fi + if [ -n "$model" ]; then cmd+=(--model "$model") fi - "${cmd[@]}" - - local labels_json - if [ -s "$labels_path" ]; then - labels_json=$(jq -Rs 'split("\n") | map(select(length > 0))' "$labels_path") - else - labels_json='[]' + if [ "$review_type" = "differential" ]; then + if [ -z "$base_commit" ] || [ "$base_commit" = "null" ]; then + echo "Skipping $server: missing base commit for differential review." >&2 + return + fi + cmd+=(--base "$base_commit") fi - local repo_slug="" - if [[ "$project" =~ $github_repo_regex ]]; then - repo_slug="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" - fi + if "${cmd[@]}"; then + # Review succeeded - determine conclusion from labels. + conclusion=$(determine_conclusion "$labels_path") - local link_line - if [ -n "$repo_slug" ]; then - if [ "$mode" = "differential" ]; then - link_line="- Diff: [view changes](https://github.com/${repo_slug}/compare/${base_commit}...${head_commit})" - else - link_line="- Snapshot: [view tree](https://github.com/${repo_slug}/tree/${head_commit})" - fi - else - if [ "$mode" = "differential" ]; then - link_line="- Diff: unavailable (non-GitHub repository)." + # Build summary text. + if [ "$review_type" = "differential" ]; then + summary="Differential review completed (${base_commit:0:7}...${head_commit:0:7})" else - link_line="- Snapshot: unavailable (non-GitHub repository)." + summary="Full code review completed at ${head_commit:0:7}" fi - fi - local mode_label - if [ "$mode" = "differential" ]; then - mode_label="Differential" - else - mode_label="Full" - fi + # Read labels for summary. + if [ -s "$labels_path" ]; then + labels_list=$(cat "$labels_path" | head -5 | paste -sd, -) + summary="${summary}\nLabels: ${labels_list}" + fi - local labels_summary - labels_summary=$(echo "$labels_json" | jq -r 'if length == 0 then "- Labels: none." else "- Labels: " + (join(", ")) end') - - local metadata_json - metadata_json=$(jq -n --arg target "$server" --arg mode "$mode" --arg base "$base_commit" --arg head "$head_commit" --argjson labels "$labels_json" '{target:$target,mode:$mode,base:$base,head:$head,labels:$labels}') - - local comment_body="review-output/${server}-${report_suffix}-comment.md" - { - echo "⚠️ **Automated security review (beta).** This system may make mistakes and is intended only to aid human reviewers." - echo "" - echo "- Target: \\`$server\\`" - echo "- Review mode: $mode_label" - echo "$link_line" - echo "$labels_summary" - echo "" - echo "---" - echo "" - cat "$report_path" - echo "" - echo "" - } > "$comment_body" - - if [ -z "$pr_number" ]; then - echo "No pull request number available; skipping comment publication for $server." >&2 - else - body_json=$(jq -Rs '.' "$comment_body") - if [ -n "$existing_comment_id" ]; then - printf '{"body":%s}\n' "$body_json" | gh api \ - repos/$repo/issues/comments/$existing_comment_id \ - --method PATCH \ - --header "Content-Type: application/json" \ - --input - >/dev/null - comment_id="$existing_comment_id" + # Read report and truncate if necessary. + if [ -s "$report_path" ]; then + report_text=$(cat "$report_path") + report_size=${#report_text} + + if [ "$report_size" -gt "$max_check_output_size" ]; then + # Truncate and add notice. + truncate_at=$((max_check_output_size - 200)) + report_text="${report_text:0:$truncate_at}" + report_text="${report_text}\n\n---\n\n**Note:** Report truncated due to size limits. Full report available in workflow artifacts." + fi else - comment_response=$(printf '{"body":%s}\n' "$body_json" | gh api \ - repos/$repo/issues/$pr_number/comments \ - --method POST \ - --header "Content-Type: application/json" \ - --input -) - comment_id=$(echo "$comment_response" | jq -r '.id | tostring') + report_text="No report generated." fi - fi - - jq -n \ - --arg target "$server" \ - --arg mode "$mode" \ - --arg base "$base_commit" \ - --arg head "$head_commit" \ - --argjson labels "$labels_json" \ - --arg comment_id "$comment_id" \ - '{target:$target,mode:$mode,base:$base,head:$head,labels:$labels,comment_id:$comment_id}' \ - >> "$results_tmp" - } - if [ "${{ steps.pins.outputs.has_targets }}" = "true" ]; then - while read -r target; do - server=$(echo "$target" | jq -r '.server') - project=$(echo "$target" | jq -r '.project') - base_commit=$(echo "$target" | jq -r '.old_commit') - head_commit=$(echo "$target" | jq -r '.new_commit') - review_target "differential" "$server" "$project" "$base_commit" "$head_commit" - done < <(jq -c '.[]' "${{ steps.pins.outputs.context }}") - fi - - if [ "${{ steps.newservers.outputs.has_targets }}" = "true" ]; then - while read -r target; do - server=$(echo "$target" | jq -r '.server') - project=$(echo "$target" | jq -r '.project') - head_commit=$(echo "$target" | jq -r '.commit') - review_target "full" "$server" "$project" "" "$head_commit" - done < <(jq -c '.[]' "${{ steps.newservers.outputs.context }}") - fi + # Update check with results. + gh api repos/$repo/check-runs/$check_id \ + --method PATCH \ + --field status="completed" \ + --field conclusion="$conclusion" \ + --field output[title]="Security Review: $server" \ + --field output[summary]="$summary" \ + --field output[text]="$report_text" - if [ -s "$results_tmp" ]; then - jq -s 'reduce .[] as $item ({}; .[$item.mode + "|" + $item.target] = $item) | [.[]]' "$results_tmp" > "review-output/review-results.json" - jq -r '.[] | .labels[]?' "review-output/review-results.json" | sort -u > "review-output/labels.txt" || true - if [ ! -s "review-output/labels.txt" ]; then - rm -f "review-output/labels.txt" - fi - else - echo '[]' > "review-output/review-results.json" - fi - - - name: Apply security labels - if: steps.pins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - labels_file="${{ github.workspace }}/review-output/labels.txt" - pr_number="${{ github.event.pull_request.number }}" - repo="${{ github.repository }}" - standard_labels=(security-blocked security-risk:critical security-risk:high security-risk:medium security-risk:low security-risk:info) - - if [ -z "$pr_number" ]; then - echo "No pull request number available; skipping label application." >&2 - exit 0 - fi - - for label in "${standard_labels[@]}"; do - gh pr edit "$pr_number" --repo "$repo" --remove-label "$label" || true - done - - if [ ! -f "$labels_file" ] || ! grep -qEv '^\s*$' "$labels_file"; then - echo "Labels file absent or empty; no labels applied." >&2 - exit 0 - fi - - mapfile -t labels < <(grep -Ev '^\s*$' "$labels_file" | sort -u) - - blocked=false - highest="" - for label in "${labels[@]}"; do - if [ "$label" = "security-blocked" ]; then - blocked=true + echo "Completed $review_type review for $server (conclusion: $conclusion)" + else + # Review failed. + gh api repos/$repo/check-runs/$check_id \ + --method PATCH \ + --field status="completed" \ + --field conclusion="failure" \ + --field output[title]="Security Review: $server" \ + --field output[summary]="Security review failed to complete" \ + --field output[text]="The security review process encountered an error. Check workflow logs for details." + + echo "Failed $review_type review for $server" >&2 fi - done - - for severity in critical high medium low info; do - candidate="security-risk:${severity}" - for label in "${labels[@]}"; do - if [ "$label" = "$candidate" ]; then - highest="$candidate" - break 2 - fi - done - done - - final_labels=() - if [ "$blocked" = true ]; then - final_labels+=("security-blocked") - fi - if [ -n "$highest" ]; then - final_labels+=("$highest") - fi - - if [ "${#final_labels[@]}" -eq 0 ]; then - echo "No recognized labels to apply." >&2 - exit 0 - fi + } - for label in "${final_labels[@]}"; do - gh pr edit "$pr_number" --repo "$repo" --add-label "$label" || echo "Warning: unable to apply label $label" >&2 - done + # Run all reviews using the check IDs created in the previous step. + while IFS='|' read -r check_name check_id server project head_commit base_commit review_type; do + run_review \ + "$check_name" \ + "$check_id" \ + "$server" \ + "$project" \ + "$head_commit" \ + "$base_commit" \ + "$review_type" + done < review-output/check-ids.txt + + - name: Upload security reports + if: always() && (steps.updatedpins.outputs.has_targets == 'true' || steps.newservers.outputs.has_targets == 'true') + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: review-output/ + if-no-files-found: warn diff --git a/.github/workflows/security-review-manual.yaml b/.github/workflows/security-review-manual.yaml index 2947d29f7..83743de6f 100644 --- a/.github/workflows/security-review-manual.yaml +++ b/.github/workflows/security-review-manual.yaml @@ -65,7 +65,7 @@ jobs: echo "has_targets=false" >> "$GITHUB_OUTPUT" fi - - name: Run security reviewer + - name: Run security reviews if: steps.collect.outputs.has_targets == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} From 0d7746c124f61ac9b0a9f0b6d34edcd28e8cd0f6 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Tue, 28 Oct 2025 18:10:09 -0600 Subject: [PATCH 29/29] ci: clean up workflows and supporting commands Signed-off-by: Jacob Howard --- .../workflows/security-review-changes.yaml | 14 ++- .github/workflows/security-review-manual.yaml | 6 +- .github/workflows/update-pins.yaml | 38 +++--- cmd/ci/compose_pr_summary.go | 82 ------------ cmd/ci/helpers.go | 24 ---- cmd/ci/main.go | 30 +++-- cmd/ci/prepare_full_audit.go | 99 --------------- cmd/ci/prepare_new_servers.go | 118 ----------------- cmd/ci/prepare_updated_pins.go | 119 ------------------ cmd/ci/update_pins.go | 22 ++++ 10 files changed, 82 insertions(+), 470 deletions(-) delete mode 100644 cmd/ci/compose_pr_summary.go delete mode 100644 cmd/ci/prepare_full_audit.go delete mode 100644 cmd/ci/prepare_new_servers.go delete mode 100644 cmd/ci/prepare_updated_pins.go diff --git a/.github/workflows/security-review-changes.yaml b/.github/workflows/security-review-changes.yaml index d1eae9cf3..34902f82f 100644 --- a/.github/workflows/security-review-changes.yaml +++ b/.github/workflows/security-review-changes.yaml @@ -183,6 +183,11 @@ jobs: --jq '.id' } + # Helper to create a slug suitable for check names. + slugify() { + echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-+|-+$//g' + } + # Create checks for updated pins (differential reviews). if [ "${{ steps.updatedpins.outputs.has_targets }}" = "true" ]; then while read -r target; do @@ -191,7 +196,8 @@ jobs: old_commit=$(echo "$target" | jq -r '.old_commit') new_commit=$(echo "$target" | jq -r '.new_commit') - check_name="security-review/$agent/pin/$server" + server_slug=$(slugify "$server") + check_name="security-review/$agent/pin/$server_slug" check_id=$(create_pending_check "$check_name" "$server" "differential") echo "$check_name|$check_id|$server|$project|$new_commit|$old_commit|differential" >> review-output/check-ids.txt @@ -205,7 +211,8 @@ jobs: project=$(echo "$target" | jq -r '.project') commit=$(echo "$target" | jq -r '.commit') - check_name="security-review/$agent/new/$server" + server_slug=$(slugify "$server") + check_name="security-review/$agent/new/$server_slug" check_id=$(create_pending_check "$check_name" "$server" "full") echo "$check_name|$check_id|$server|$project|$commit||full" >> review-output/check-ids.txt @@ -356,6 +363,7 @@ jobs: if [ "$review_type" = "differential" ]; then if [ -z "$base_commit" ] || [ "$base_commit" = "null" ]; then echo "Skipping $server: missing base commit for differential review." >&2 + skip_check "$check_id" "$server" "missing base commit" return fi cmd+=(--base "$base_commit") @@ -426,7 +434,7 @@ jobs: "$project" \ "$head_commit" \ "$base_commit" \ - "$review_type" + "$review_type" || true done < review-output/check-ids.txt - name: Upload security reports diff --git a/.github/workflows/security-review-manual.yaml b/.github/workflows/security-review-manual.yaml index 83743de6f..71260d2d7 100644 --- a/.github/workflows/security-review-manual.yaml +++ b/.github/workflows/security-review-manual.yaml @@ -74,7 +74,7 @@ jobs: REVIEW_MODEL_INPUT: ${{ github.event.inputs.model }} REVIEW_TIMEOUT_INPUT: ${{ github.event.inputs.timeout_secs }} run: | - set -euo pipefail + set -uo pipefail agent="${REVIEW_AGENT_INPUT:-}" if [ -z "$agent" ]; then agent="claude" @@ -115,7 +115,9 @@ jobs: cmd+=(--model "$model") fi - "${cmd[@]}" + if ! "${cmd[@]}"; then + echo "Security review failed for $server" >&2 + fi done < <(jq -c '.[]' "${{ steps.collect.outputs.targets }}") - name: Upload security reports diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml index 70f5c7bf0..0ac6d84e3 100644 --- a/.github/workflows/update-pins.yaml +++ b/.github/workflows/update-pins.yaml @@ -3,8 +3,6 @@ name: Update MCP Server Version Pins on: # schedule: # - cron: "0 5 * * *" - schedule: - - cron: "0 0 1 * *" workflow_dispatch: inputs: max_new_prs: @@ -132,20 +130,22 @@ jobs: git fetch origin main git reset --hard origin/main - # If a prior PR exists for this server, fetch it and bail out when - # the requested commit is identical (no update required). + # Check if we've hit the new PR limit before doing any work. + branch_exists=false if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + branch_exists=true git fetch origin "$branch" existing_commit=$(git show "origin/${branch}:servers/${server}/server.yaml" 2>/dev/null | awk '/commit:/{print $2}' | tail -n1) if [ -n "$existing_commit" ] && [ "$existing_commit" = "$new_commit" ]; then echo "Existing PR for $server already pins ${existing_commit}; skipping." continue fi - else - if [ -n "$new_pr_limit" ] && [ "$new_pr_count" -ge "$new_pr_limit" ]; then - echo "New PR quota reached ($new_pr_limit); skipping $server." - continue - fi + fi + + # Check PR limit for new branches only. + if [ "$branch_exists" = false ] && [ -n "$new_pr_limit" ] && [ "$new_pr_count" -ge "$new_pr_limit" ]; then + echo "New PR quota reached ($new_pr_limit); skipping $server." + continue fi # Apply the patch onto a fresh branch for this server. @@ -163,20 +163,28 @@ jobs: # Commit the server YAML change and force-push the automation branch. git add "servers/${server}/server.yaml" git commit -m "chore: update pin for ${server}" - git push --force origin "$branch" + if ! git push --force origin "$branch"; then + echo "Failed to push branch for $server, skipping." >&2 + continue + fi # Create or update the PR dedicated to this server. if gh pr view --head "$branch" >/dev/null 2>&1; then - gh pr edit "$branch" \ + if ! gh pr edit "$branch" \ --title "chore: update pin for ${server}" \ - --body "Automated commit pin update for ${server}." + --body "Automated commit pin update for ${server}." 2>&1; then + echo "Failed to update PR for $server" >&2 + fi else - gh pr create \ + if gh pr create \ --title "chore: update pin for ${server}" \ --body "Automated commit pin update for ${server}." \ --base main \ - --head "$branch" - new_pr_count=$((new_pr_count + 1)) + --head "$branch" 2>&1; then + new_pr_count=$((new_pr_count + 1)) + else + echo "Failed to create PR for $server" >&2 + fi fi done diff --git a/cmd/ci/compose_pr_summary.go b/cmd/ci/compose_pr_summary.go deleted file mode 100644 index defe18dca..000000000 --- a/cmd/ci/compose_pr_summary.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright © 2025 Docker, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -package main - -import ( - "errors" - "flag" - "os" - "strings" -) - -// runComposePRSummary merges per-category summaries into a single Markdown -// document. It requires --pins-summary, --new-summary, and --output flags and -// tolerates missing summary files by emitting nothing. -func runComposePRSummary(args []string) error { - flags := flag.NewFlagSet("compose-pr-summary", flag.ContinueOnError) - pinsSummary := flags.String("pins-summary", "", "summary file for updated pins") - newSummary := flags.String("new-summary", "", "summary file for new servers") - output := flags.String("output", "", "path to write merged summary") - if err := flags.Parse(args); err != nil { - return err - } - - if *output == "" { - return errors.New("output is required") - } - - var sections []string - - if *pinsSummary != "" { - if content, err := os.ReadFile(*pinsSummary); err == nil { - if len(strings.TrimSpace(string(content))) > 0 { - sections = append(sections, string(content)) - } - } - } - - if *newSummary != "" { - if content, err := os.ReadFile(*newSummary); err == nil { - if len(strings.TrimSpace(string(content))) > 0 { - sections = append(sections, string(content)) - } - } - } - - if len(sections) == 0 { - removeIfPresent(*output) - return nil - } - - builder := strings.Builder{} - builder.WriteString("# Security Review Targets\n\n") - for _, section := range sections { - builder.WriteString(section) - if !strings.HasSuffix(section, "\n") { - builder.WriteRune('\n') - } - builder.WriteRune('\n') - } - - return os.WriteFile(*output, []byte(builder.String()), 0o644) -} diff --git a/cmd/ci/helpers.go b/cmd/ci/helpers.go index 93827ed58..7b8f72a71 100644 --- a/cmd/ci/helpers.go +++ b/cmd/ci/helpers.go @@ -128,30 +128,6 @@ func runGitCommand(dir string, args ...string) (string, error) { return string(output), nil } -// initGitRepository creates or reuses a git repository rooted at dir with origin set. -func initGitRepository(dir, remote string) error { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - if _, err := runGitCommand(dir, "rev-parse", "--is-inside-work-tree"); err == nil { - return nil - } - if _, err := runGitCommand(dir, "init"); err != nil { - return err - } - if _, err := runGitCommand(dir, "remote", "remove", "origin"); err == nil { - // ignore error - } - _, err := runGitCommand(dir, "remote", "add", "origin", remote) - return err -} - -// fetchCommit retrieves a single commit from origin into the repository. -func fetchCommit(dir, commit string) error { - _, err := runGitCommand(dir, "fetch", "--depth", "1", "--no-tags", "origin", commit) - return err -} - // splitList normalizes a delimited string into lowercase server names. func splitList(raw string) []string { if raw == "" { diff --git a/cmd/ci/main.go b/cmd/ci/main.go index daa4b26d0..c2c66fb9d 100644 --- a/cmd/ci/main.go +++ b/cmd/ci/main.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import ( @@ -19,18 +41,10 @@ func main() { switch cmd { case "collect-updated-pins": err = runCollectUpdatedPins(args) - case "prepare-updated-pins": - err = runPrepareUpdatedPins(args) case "collect-new-servers": err = runCollectNewServers(args) - case "prepare-new-servers": - err = runPrepareNewServers(args) - case "compose-pr-summary": - err = runComposePRSummary(args) case "collect-full-audit": err = runCollectFullAudit(args) - case "prepare-full-audit": - err = runPrepareFullAudit(args) case "update-pins": err = runUpdatePins(args) default: diff --git a/cmd/ci/prepare_full_audit.go b/cmd/ci/prepare_full_audit.go deleted file mode 100644 index ee1f9287f..000000000 --- a/cmd/ci/prepare_full_audit.go +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright © 2025 Docker, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -package main - -import ( - "errors" - "flag" - "fmt" - "os" - "path/filepath" - "strings" -) - -// runPrepareFullAudit clones source data for a single audit target specified by -// a JSON descriptor. It requires --target-file and --output-dir flags and -// prepares the repository checkout plus metadata. -func runPrepareFullAudit(args []string) error { - flags := flag.NewFlagSet("prepare-full-audit", flag.ContinueOnError) - targetFile := flags.String("target-file", "", "path to JSON target descriptor") - outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") - if err := flags.Parse(args); err != nil { - return err - } - - if *targetFile == "" || *outputDir == "" { - return errors.New("target-file and output-dir are required") - } - - var target auditTarget - if err := readJSONFile(*targetFile, &target); err != nil { - return err - } - - return prepareAuditTarget(*outputDir, target) -} - -// prepareAuditTarget materializes repository state and metadata for auditing a -// single server, storing artifacts beneath the provided output directory. -func prepareAuditTarget(outputDir string, target auditTarget) error { - serverDir := filepath.Join(outputDir, target.Server) - repoDir := filepath.Join(serverDir, "repo") - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - if err := initGitRepository(repoDir, target.Project); err != nil { - return err - } - if err := fetchCommit(repoDir, target.Commit); err != nil { - return err - } - if _, err := runGitCommand(repoDir, "checkout", target.Commit); err != nil { - return err - } - - context := buildAuditContext(target, repoDir) - if err := os.WriteFile(filepath.Join(serverDir, "context.md"), []byte(context), 0o644); err != nil { - return err - } - - return writeJSONFile(filepath.Join(serverDir, "metadata.json"), target) -} - -// buildAuditContext produces Markdown describing the prepared audit checkout, -// which is used to prime review prompts. -func buildAuditContext(target auditTarget, repoDir string) string { - builder := strings.Builder{} - builder.WriteString("# Full Audit Target\n\n") - builder.WriteString(fmt.Sprintf("- Server: %s\n", target.Server)) - builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) - builder.WriteString(fmt.Sprintf("- Commit: %s\n", target.Commit)) - if target.Directory != "" { - builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) - } else { - builder.WriteString("- Directory: (repository root)\n") - } - builder.WriteString(fmt.Sprintf("- Checkout path: %s\n", repoDir)) - return builder.String() -} diff --git a/cmd/ci/prepare_new_servers.go b/cmd/ci/prepare_new_servers.go deleted file mode 100644 index 8daebcdb0..000000000 --- a/cmd/ci/prepare_new_servers.go +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright © 2025 Docker, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -package main - -import ( - "errors" - "flag" - "fmt" - "os" - "path/filepath" - "strings" -) - -// runPrepareNewServers checks out repositories for newly added local servers, -// given a JSON context file. It expects --context-file and --output-dir flags -// and prepares per-server metadata and source trees. -func runPrepareNewServers(args []string) error { - flags := flag.NewFlagSet("prepare-new-servers", flag.ContinueOnError) - contextFile := flags.String("context-file", "", "path to JSON context file") - outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") - if err := flags.Parse(args); err != nil { - return err - } - - if *contextFile == "" || *outputDir == "" { - return errors.New("context-file and output-dir are required") - } - - var targets []newServerTarget - if err := readJSONFile(*contextFile, &targets); err != nil { - return err - } - - if len(targets) == 0 { - return nil - } - - if err := os.MkdirAll(*outputDir, 0o755); err != nil { - return err - } - - for _, target := range targets { - if err := prepareNewServerTarget(*outputDir, target); err != nil { - return fmt.Errorf("prepare new server %s: %w", target.Server, err) - } - } - - return nil -} - -// prepareNewServerTarget clones the upstream repository at the pinned commit -// for a new server and records metadata for downstream review. -func prepareNewServerTarget(outputDir string, target newServerTarget) error { - serverDir := filepath.Join(outputDir, target.Server) - repoDir := filepath.Join(serverDir, "repo") - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - if err := initGitRepository(repoDir, target.Project); err != nil { - return err - } - if err := fetchCommit(repoDir, target.Commit); err != nil { - return err - } - if _, err := runGitCommand(repoDir, "checkout", target.Commit); err != nil { - return err - } - - metadata := map[string]string{ - "server": target.Server, - "repository": target.Project, - "commit": target.Commit, - "directory": target.Directory, - } - if err := writeJSONFile(filepath.Join(serverDir, "metadata.json"), metadata); err != nil { - return err - } - - summary := buildNewServerDetail(target) - return os.WriteFile(filepath.Join(serverDir, "README.md"), []byte(summary), 0o644) -} - -// buildNewServerDetail returns a Markdown overview describing the cloned -// server, suitable for inclusion in review prompts. -func buildNewServerDetail(target newServerTarget) string { - builder := strings.Builder{} - builder.WriteString("# New Server Security Review\n\n") - builder.WriteString(fmt.Sprintf("- Server: %s\n", target.Server)) - builder.WriteString(fmt.Sprintf("- Repository: %s\n", target.Project)) - builder.WriteString(fmt.Sprintf("- Commit: %s\n", target.Commit)) - if target.Directory != "" { - builder.WriteString(fmt.Sprintf("- Directory: %s\n", target.Directory)) - } else { - builder.WriteString("- Directory: (repository root)\n") - } - return builder.String() -} diff --git a/cmd/ci/prepare_updated_pins.go b/cmd/ci/prepare_updated_pins.go deleted file mode 100644 index 64cd91a28..000000000 --- a/cmd/ci/prepare_updated_pins.go +++ /dev/null @@ -1,119 +0,0 @@ -/* -Copyright © 2025 Docker, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -package main - -import ( - "errors" - "flag" - "fmt" - "os" - "path/filepath" -) - -// runPrepareUpdatedPins fetches upstream repositories and prepares diff -// artifacts for each updated pin listed in the context file. It consumes -// --context-file and --output-dir flags and writes diffs, logs, and metadata -// for downstream analysis. -func runPrepareUpdatedPins(args []string) error { - flags := flag.NewFlagSet("prepare-updated-pins", flag.ContinueOnError) - contextFile := flags.String("context-file", "", "path to JSON context file") - outputDir := flags.String("output-dir", "", "directory to receive prepared artifacts") - if err := flags.Parse(args); err != nil { - return err - } - - if *contextFile == "" || *outputDir == "" { - return errors.New("context-file and output-dir are required") - } - - var targets []pinTarget - if err := readJSONFile(*contextFile, &targets); err != nil { - return err - } - - if len(targets) == 0 { - return nil - } - - if err := os.MkdirAll(*outputDir, 0o755); err != nil { - return err - } - - for _, target := range targets { - if err := preparePinTarget(*outputDir, target); err != nil { - return fmt.Errorf("prepare pin target %s: %w", target.Server, err) - } - } - - return nil -} - -// preparePinTarget materializes git diffs, commit logs, and metadata for a -// single commit pin update, storing the results under the provided output -// directory. -func preparePinTarget(outputDir string, target pinTarget) error { - serverDir := filepath.Join(outputDir, target.Server) - repoDir := filepath.Join(serverDir, "repo") - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - if err := initGitRepository(repoDir, target.Project); err != nil { - return err - } - - for _, commit := range []string{target.OldCommit, target.NewCommit} { - if err := fetchCommit(repoDir, commit); err != nil { - return err - } - } - - diffArgs := []string{"diff", target.OldCommit, target.NewCommit} - if target.Directory != "" && target.Directory != "." { - diffArgs = append(diffArgs, "--", target.Directory) - } - diffOut, err := runGitCommand(repoDir, diffArgs...) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(serverDir, "diff.patch"), []byte(diffOut), 0o644); err != nil { - return err - } - - logOut, err := runGitCommand(repoDir, "log", "--oneline", "--stat", fmt.Sprintf("%s..%s", target.OldCommit, target.NewCommit)) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(serverDir, "changes.log"), []byte(logOut), 0o644); err != nil { - return err - } - - metadata := map[string]string{ - "server": target.Server, - "repository": target.Project, - "old_commit": target.OldCommit, - "new_commit": target.NewCommit, - "directory": target.Directory, - } - return writeJSONFile(filepath.Join(serverDir, "metadata.json"), metadata) -} diff --git a/cmd/ci/update_pins.go b/cmd/ci/update_pins.go index 45cda0c88..0f2a368d4 100644 --- a/cmd/ci/update_pins.go +++ b/cmd/ci/update_pins.go @@ -1,3 +1,25 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + package main import (