From f9493b80a6bb7e95eda2ac1227f29aae92cf87bc Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Fri, 17 Oct 2025 17:11:20 -0600 Subject: [PATCH 1/5] 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 00000000..893e06d1 --- /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 6098d96f..270a89f4 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 00000000..90aa6942 --- /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 2/5] 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 00000000..785c4bac --- /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 00000000..4c0aa9c6 --- /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 270a89f4..4b724af8 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 00000000..ade3aedb --- /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 00000000..c1bec9c3 --- /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 00000000..67d054f5 --- /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 00000000..c43a0ec2 --- /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 00000000..9e23d4e7 --- /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 00000000..a9de365e --- /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 00000000..86efccd2 --- /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 00000000..32e7b0e0 --- /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 00000000..1a4d03dc --- /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 00000000..9a3e06f3 --- /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 00000000..3dfe61fb --- /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 00000000..b7318645 --- /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 00000000..c82ed259 --- /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 00000000..9e092d35 --- /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 3/5] 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 893e06d1..37c90292 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 4b724af8..45eb118f 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 ade3aedb..c8dbb804 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 c1bec9c3..517aebef 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 67d054f5..0b85c27c 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 c43a0ec2..defe18dc 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 9e23d4e7..42552ace 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 a9de365e..daa4b26d 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 86efccd2..ee1f9287 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 32e7b0e0..8daebcdb 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 1a4d03dc..64cd91a2 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 9a3e06f3..2b881625 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 90aa6942..225c9d94 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 4/5] 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 42552ace..93827ed5 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 2b881625..98d95572 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 5/5] 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 785c4bac..5ee9bea6 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 37c90292..7aea8eae 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: