diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 655bcee5830..84a3eaf38e5 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d0a6de7384fafce23c3de1a5010c24419ab48362a5e92bd89584214fe924b56f","body_hash":"775f391aa546edc759a3d7903e8e0487138b365c5b1ee5ea58e9b3e12ab4b24b","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.216"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4c696dba97db546dc6a291704ae2b207353b8435dc8154b38337183b589c9255","body_hash":"7056f785369cace55221c1645a714938384813226a05bde93eeae68b01f6de0f","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.216"}} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.37","digest":"sha256:0d35e8682845f183c1c634699a8e8a6cbe2c271b867031410df74533243c5f67","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.37@sha256:0d35e8682845f183c1c634699a8e8a6cbe2c271b867031410df74533243c5f67"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.37","digest":"sha256:fc2970aadaeae05993e76697d29f03dc8bfb9248ff87a8f3d8b0975485a4b317","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.37@sha256:fc2970aadaeae05993e76697d29f03dc8bfb9248ff87a8f3d8b0975485a4b317"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.37","digest":"sha256:1d5300d9b08e1c4f2ad1830860656a0656383a83280058f17e805a7c3ecda203","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.37@sha256:1d5300d9b08e1c4f2ad1830860656a0656383a83280058f17e805a7c3ecda203"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.37","digest":"sha256:5abc51995e5901c5d1daeefc957301ee409980e2e607391ec22c06cb2513327b","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.37@sha256:5abc51995e5901c5d1daeefc957301ee409980e2e607391ec22c06cb2513327b"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.3","digest":"sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.3@sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -23,7 +23,7 @@ # # For more information: https://github.github.com/gh-aw/introduction/overview/ # -# Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, and runner-guard +# Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, runner-guard, and grype # # Resolved workflow manifest: # Imports: @@ -565,11 +565,11 @@ jobs: make build "$GITHUB_WORKSPACE/gh-aw" --version - name: Pull static analysis Docker images - run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\n# Pull runner-guard Docker image\necho \"Pulling runner-guard image...\"\ndocker pull ghcr.io/vigilant-llc/runner-guard:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" + run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\n# Pull runner-guard Docker image\necho \"Pulling runner-guard image...\"\ndocker pull ghcr.io/vigilant-llc/runner-guard:latest\n\n# Pull grype Docker image\necho \"Pulling grype image...\"\ndocker pull anchore/grype:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" - name: Verify static analysis tools - run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\n# Verify runner-guard\necho \"Testing runner-guard...\"\ndocker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo \"Warning: runner-guard version check failed\"\n\necho \"Static analysis tools verification complete\"\n" + run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\n# Verify runner-guard\necho \"Testing runner-guard...\"\ndocker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo \"Warning: runner-guard version check failed\"\n\n# Verify grype\necho \"Testing grype...\"\ndocker run --rm anchore/grype:latest version || echo \"Warning: grype version check failed\"\n\necho \"Static analysis tools verification complete\"\n" - name: Run compile with security tools - run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\n\"$GITHUB_WORKSPACE/gh-aw\" compile --zizmor --poutine --actionlint --runner-guard 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/agent/compile-output.txt\"" + run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\n\"$GITHUB_WORKSPACE/gh-aw\" compile --zizmor --poutine --actionlint --runner-guard --grype 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/agent/compile-output.txt\"" - name: Configure Git credentials env: @@ -1644,7 +1644,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Static Analysis Report" - WORKFLOW_DESCRIPTION: "Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, and runner-guard" + WORKFLOW_DESCRIPTION: "Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, runner-guard, and grype" HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | diff --git a/.github/workflows/static-analysis-report.md b/.github/workflows/static-analysis-report.md index 4640689477a..1e02b19c27d 100644 --- a/.github/workflows/static-analysis-report.md +++ b/.github/workflows/static-analysis-report.md @@ -1,6 +1,6 @@ --- emoji: "📊" -description: Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, and runner-guard +description: Scans agentic workflows daily for security vulnerabilities using zizmor, poutine, actionlint, runner-guard, and grype on: schedule: daily workflow_dispatch: @@ -54,6 +54,10 @@ steps: # Pull runner-guard Docker image echo "Pulling runner-guard image..." docker pull ghcr.io/vigilant-llc/runner-guard:latest + + # Pull grype Docker image + echo "Pulling grype image..." + docker pull anchore/grype:latest echo "All static analysis Docker images pulled successfully" - name: Verify static analysis tools @@ -72,6 +76,10 @@ steps: # Verify runner-guard echo "Testing runner-guard..." docker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo "Warning: runner-guard version check failed" + + # Verify grype + echo "Testing grype..." + docker run --rm anchore/grype:latest version || echo "Warning: grype version check failed" echo "Static analysis tools verification complete" - name: Run compile with security tools @@ -81,7 +89,7 @@ steps: # Run compile with all security scanner flags to download Docker images # Store the output in a file for inspection - "$GITHUB_WORKSPACE/gh-aw" compile --zizmor --poutine --actionlint --runner-guard 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt + "$GITHUB_WORKSPACE/gh-aw" compile --zizmor --poutine --actionlint --runner-guard --grype 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt echo "Compile with security tools completed" echo "Output saved to /tmp/gh-aw/agent/compile-output.txt" @@ -93,7 +101,7 @@ sandbox: # Static Analysis Report -You are the Static Analysis Report Agent - an expert system that scans agentic workflows for security vulnerabilities and code quality issues using multiple static analysis tools: zizmor, poutine, and actionlint. +You are the Static Analysis Report Agent - an expert system that scans agentic workflows for security vulnerabilities and code quality issues using multiple static analysis tools: zizmor, poutine, actionlint, runner-guard, and grype. ## Mission @@ -113,10 +121,10 @@ Daily scan all agentic workflow files with static analysis tools to identify sec ### Phase 1: Analyze Static Analysis Output -The workflow has already compiled all workflows with static analysis tools (zizmor, poutine, actionlint) and saved the output to `/tmp/gh-aw/agent/compile-output.txt`. +The workflow has already compiled all workflows with static analysis tools (zizmor, poutine, actionlint, runner-guard, grype) and saved the output to `/tmp/gh-aw/agent/compile-output.txt`. 1. **Read Compilation Output**: - Read and parse the file `/tmp/gh-aw/agent/compile-output.txt` which contains the JSON output from the compilation with all three static analysis tools. + Read and parse the file `/tmp/gh-aw/agent/compile-output.txt` which contains the JSON output from the compilation with all five static analysis tools. The output is JSON format with validation results for each workflow: - workflow: Name of the workflow file @@ -124,10 +132,10 @@ The workflow has already compiled all workflows with static analysis tools (zizm - errors: Array of error objects with type, message, and optional line number - warnings: Array of warning objects - compiled_file: Path to the generated .lock.yml file - - security findings from zizmor, poutine, and actionlint (if any) + - security findings from zizmor, poutine, actionlint, runner-guard, and grype (if any) 2. **Parse and Extract Findings**: - - Parse the JSON output to extract findings from all three tools + - Parse the JSON output to extract findings from all five tools - Note which workflows have findings from each tool - Identify total number of issues by tool and severity - Extract specific error messages, locations, and recommendations @@ -139,7 +147,7 @@ The workflow has already compiled all workflows with static analysis tools (zizm ### Phase 2: Analyze and Cluster Findings -Review the output from all three tools and cluster findings: +Review the output from all five tools and cluster findings: #### 2.1 Parse Tool Outputs @@ -171,7 +179,7 @@ Review the output from all three tools and cluster findings: #### 2.2 Cluster by Issue Type and Tool Group findings by: -- Tool (zizmor, poutine, actionlint) +- Tool (zizmor, poutine, actionlint, runner-guard, grype) - Issue identifier/rule code - Severity level - Count occurrences of each issue type @@ -191,7 +199,7 @@ Use the cache memory folder `/tmp/gh-aw/cache-memory/` to build persistent knowl 1. **Create Security Scan Index**: - Save scan results to `/tmp/gh-aw/cache-memory/security-scans/.json` - - Include findings from all three tools (zizmor, poutine, actionlint) + - Include findings from all five tools (zizmor, poutine, actionlint, runner-guard, grype) - Maintain an index of all scans in `/tmp/gh-aw/cache-memory/security-scans/index.json` 2. **Update Vulnerability Database**: @@ -264,7 +272,7 @@ Use the cache memory folder `/tmp/gh-aw/cache-memory/` to build persistent knowl **ALWAYS create a comprehensive issue report** with your static analysis findings, regardless of whether issues were found or not. Create an issue with: -- **Summary**: Overview of static analysis findings from all three tools +- **Summary**: Overview of static analysis findings from all five tools - **Statistics**: Total findings by tool, by severity, by type - **Clustered Findings**: Issues grouped by tool and type with counts - **Affected Workflows**: Which workflows have issues @@ -283,7 +291,7 @@ Wrap long sections (>5 items, detailed lists, raw data) in `
5 items, detailed lists, raw data) in `
-o json`) so no native install is required. Images are deduplicated by pinned digest reference before scanning, results are cached in memory for the duration of a compile run, and findings are normalized to the existing `console.CompilerError` format with severity-to-level mapping (Critical/High → error, Medium/Unknown → warning, Low/Negligible → info). In strict mode (`--strict`), any finding or scan error causes a non-zero exit. + +### Alternatives Considered + +#### Alternative 1: Require native grype binary installation + +Users or CI environments would install the grype binary directly (e.g., `brew install grype`, OS package manager, or a GitHub Actions setup step). This avoids the Docker dependency and could be faster for repeated runs. It was rejected because it requires per-platform install instructions, version pinning across every environment, and breaks the zero-prerequisite model established by the other Docker-based scanners (`--runner-guard`, `--poutine`). + +#### Alternative 2: Use Trivy instead of Grype + +Trivy (`aquasecurity/trivy`) is another widely-adopted container vulnerability scanner with a similar Docker-based invocation model. It was not chosen because the existing toolchain already integrates grype-compatible output conventions and `anchore/grype` is well-established for CI image scanning with structured JSON output. Either tool would be viable; grype was selected for consistency with the direction expressed in the PR. + +#### Alternative 3: Perform image scanning outside the compile pipeline (e.g., dedicated CI step) + +A separate CI job or reusable workflow could run grype on images listed in manifests after compilation. This keeps the compile pipeline lighter and lets security scanning scale independently. It was not chosen because tightly coupling the scan to compilation (same invocation, same output format, same strict-mode gate) gives developers immediate feedback during local development (`gh aw compile --grype`) without requiring CI configuration changes. + +### Consequences + +#### Positive +- No native grype installation required; Docker is the only prerequisite (already required by `--runner-guard` and `--poutine`). +- Container image vulnerability findings are presented in the same `CompilerError` format as other scanner output, giving a unified developer experience. +- Deduplication by pinned digest prevents redundant scans when multiple lock files reference the same image. +- In-memory per-run caching avoids repeated Docker invocations for images already scanned in the same compile run. +- Strict mode integration (`--strict`) allows CI pipelines to gate on vulnerability findings without additional scripting. + +#### Negative +- Docker must be running; users without Docker (or on machines where the daemon is unavailable) silently skip grype scanning rather than receiving a hard error, which may create a false sense of security. +- `anchore/grype:latest` is pinned to a mutable tag. If upstream releases a breaking change, scans may fail non-deterministically or produce different results across environments. A pinned digest (like `anchore/grype@sha256:...`) would be safer. +- Each unique image requires a `docker run` invocation; cold-start Docker image pull adds significant latency on first use per machine. +- The in-memory cache does not persist across compile runs, so repeated invocations in the same shell session re-scan all images. + +#### Neutral +- The `--grype` flag follows the same opt-in pattern as `--zizmor`, `--poutine`, `--actionlint`, and `--runner-guard`; existing compile invocations are unaffected. +- Grype exit code 1 (vulnerabilities found) is treated as a successful scan with findings, not a tool failure — this is a grype-specific convention documented in the implementation. +- The MCP tool interface (`mcp_tools_readonly.go`) exposes `grype` as a JSON schema field, making it available to AI-assisted compile invocations. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 90be6c42cbb..023ff1dfb62 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -25,6 +25,7 @@ type CompileConfig struct { Poutine bool // Run poutine security scanner on generated .lock.yml files Actionlint bool // Run actionlint linter on generated .lock.yml files RunnerGuard bool // Run runner-guard taint analysis scanner on generated .lock.yml files + Grype bool // Run grype vulnerability scanner on container images referenced in compiled .lock.yml files JSONOutput bool // Output validation results as JSON ShowAllErrors bool // Display all prioritized errors instead of the default top five ActionMode string // How action scripts are referenced: dev, release, or action. Auto-detected if empty. diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go index 193c3e5f653..de256aad167 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -58,6 +58,13 @@ func RunRunnerGuardOnDirectory(workflowDir string, verbose bool, strict bool) er return runRunnerGuardOnDirectory(workflowDir, verbose, strict) } +// RunGrypeOnLockFiles runs the grype vulnerability scanner on container images extracted +// from the gh-aw-manifest headers in the provided lock files. +// Images are deduplicated by pinned reference, and results are cached per image. +func RunGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + return runBatchLockFileTool("grype", lockFiles, verbose, strict, runGrypeOnLockFiles) +} + // runBatchLockFileTool runs a batch tool on lock files with uniform error handling func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error { if len(lockFiles) == 0 { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 84396ccf4d7..ce735ebc086 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -63,6 +63,7 @@ func compileSpecificFiles( var lockFilesForActionlint []string var lockFilesForZizmor []string var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) + var lockFilesForGrype []string // lock files for grype container image vulnerability scanning // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -148,6 +149,9 @@ func compileSpecificFiles( if config.Poutine || config.RunnerGuard { lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) } + if config.Grype { + lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) + } } } } @@ -207,6 +211,18 @@ func compileSpecificFiles( } } + // Run grype vulnerability scanner on container images referenced in the compiled lock files. + if config.Grype && !config.NoEmit && len(lockFilesForGrype) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrypeOnLockFiles(lockFilesForGrype, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Get warning count from compiler stats.Warnings = compiler.GetWarningCount() @@ -302,6 +318,7 @@ func compileAllFilesInDirectory( var lockFilesForActionlint []string var lockFilesForZizmor []string var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) + var lockFilesForGrype []string // lock files for grype container image vulnerability scanning for _, file := range mdFiles { // Respect context cancellation between files (e.g. Ctrl+C) @@ -356,6 +373,9 @@ func compileAllFilesInDirectory( if config.Poutine || config.RunnerGuard { lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) } + if config.Grype { + lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) + } } } } @@ -411,6 +431,18 @@ func compileAllFilesInDirectory( } } + // Run grype vulnerability scanner on container images referenced in the compiled lock files. + if config.Grype && !config.NoEmit && len(lockFilesForGrype) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrypeOnLockFiles(lockFilesForGrype, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Emit recommendation when many slash commands are present without centralized strategy. displayCentralizedSlashCommandRecommendation(compiler, workflowDataList, config.JSONOutput) diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index 45a37af4285..e20e38bf478 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -33,6 +33,7 @@ const ( PoutineImage = "ghcr.io/boostsecurityio/poutine:latest" ActionlintImage = "rhysd/actionlint:1.7.12" RunnerGuardImage = "ghcr.io/vigilant-llc/runner-guard:latest" + GrypeImage = "anchore/grype:latest" ) // dockerPullState tracks the state of docker pull operations @@ -224,9 +225,9 @@ func StartDockerImageDownload(ctx context.Context, image string) bool { // Returns: // - nil if all required images are available // - error if Docker is unavailable or images are downloading/need to be downloaded -func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useGrype bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useGrype { return nil } @@ -254,6 +255,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useGrype { + tool := "grype" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } verb := "requires" if len(requestedTools) > 1 { verb = "require" @@ -276,6 +282,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {usePoutine, PoutineImage, "poutine"}, {useActionlint, ActionlintImage, "actionlint"}, {useRunnerGuard, RunnerGuardImage, "runner-guard"}, + {useGrype, GrypeImage, "grype"}, } for _, img := range imagesToCheck { diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index 1fa5fe7372d..39cc5895f6d 100644 --- a/pkg/cli/docker_images_test.go +++ b/pkg/cli/docker_images_test.go @@ -15,7 +15,7 @@ func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) { ResetDockerPullState() // When no tools are requested, should return nil - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested, got: %v", err) } @@ -31,7 +31,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyDownloading(t *testing.T) { SetDockerImageDownloading(ZizmorImage, true) // Should return an error indicating to retry - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) if err == nil { t.Error("Expected error when image is downloading, got nil") } @@ -104,6 +104,9 @@ func TestDockerImageConstants(t *testing.T) { if RunnerGuardImage == "" { t.Error("RunnerGuardImage constant should not be empty") } + if GrypeImage == "" { + t.Error("GrypeImage constant should not be empty") + } // Verify they are docker image references expectedImages := map[string]string{ @@ -111,6 +114,7 @@ func TestDockerImageConstants(t *testing.T) { "poutine": PoutineImage, "actionlint": ActionlintImage, "runner-guard": RunnerGuardImage, + "grype": GrypeImage, } for name, image := range expectedImages { @@ -134,7 +138,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -160,7 +164,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -195,7 +199,7 @@ func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) { // when the image is marked as downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -219,7 +223,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyAvailable(t *testing.T) { SetMockImageAvailable(ZizmorImage, true) // Should not return an error since the image is available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -526,7 +530,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) { SetMockDockerAvailable(false) // Should return a clear error about Docker not being available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -564,7 +568,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -603,7 +607,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_NoTools(t *testing.T) { SetMockDockerAvailable(false) // When no tools requested, should return nil even if Docker is unavailable - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested (even with Docker unavailable), got: %v", err) } @@ -635,7 +639,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -664,7 +668,7 @@ func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) { SetDockerImageDownloading(RunnerGuardImage, true) // Request all tools, including runner-guard - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } diff --git a/pkg/cli/grype.go b/pkg/cli/grype.go new file mode 100644 index 00000000000..64cc9b3dfc9 --- /dev/null +++ b/pkg/cli/grype.go @@ -0,0 +1,348 @@ +// This file provides container image vulnerability scanning for workflow compilation. +// +// It uses the grype vulnerability scanner (via Docker) to scan container images +// referenced in compiled lock files. Images are extracted from the gh-aw-manifest +// header embedded in each lock file, deduplicated by pinned image reference, and +// scanned once per unique image per compile run (results are cached in memory). +// +// # Integration +// +// This scanner integrates alongside actionlint, zizmor, poutine, and runner-guard +// as a post-compilation step invoked via the --grype flag. Unlike the workflow-file +// scanners, grype operates on the container images referenced in the manifests rather +// than the YAML files themselves. +// +// # Caching +// +// Scan results are cached by image reference (pinned image@digest when available, or +// image tag otherwise). This prevents re-scanning the same image when multiple lock +// files reference it. + +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/workflow" +) + +var grypeLog = logger.New("cli:grype") + +// grypeFinding represents a single vulnerability match from grype JSON output. +type grypeFinding struct { + Vulnerability struct { + ID string `json:"id"` + DataSource string `json:"dataSource"` + Severity string `json:"severity"` + Fix struct { + Versions []string `json:"versions"` + State string `json:"state"` + } `json:"fix"` + } `json:"vulnerability"` + Artifact struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + } `json:"artifact"` +} + +// grypeOutput represents the complete JSON output from grype. +type grypeOutput struct { + Matches []grypeFinding `json:"matches"` +} + +// grypeCache caches grype scan results by image reference to avoid rescanning +// the same image within a single compile run. +type grypeCache struct { + mu sync.Mutex + results map[string]*grypeOutput + errors map[string]error +} + +// get returns a cached result and whether an entry exists for the key. +func (c *grypeCache) get(key string) (result *grypeOutput, err error, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + if r, found := c.results[key]; found { + return r, nil, true + } + if e, found := c.errors[key]; found { + return nil, e, true + } + return nil, nil, false +} + +// set stores a successful scan result. +func (c *grypeCache) set(key string, result *grypeOutput) { + c.mu.Lock() + defer c.mu.Unlock() + c.results[key] = result +} + +// setError stores a scan error so the same failure is not retried. +func (c *grypeCache) setError(key string, err error) { + c.mu.Lock() + defer c.mu.Unlock() + c.errors[key] = err +} + +// reset clears all cached entries. Used in tests. +func (c *grypeCache) reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.results = make(map[string]*grypeOutput) + c.errors = make(map[string]error) +} + +// grypeScanResultCache is the process-wide grype result cache. +var grypeScanResultCache = &grypeCache{ + results: make(map[string]*grypeOutput), + errors: make(map[string]error), +} + +// collectContainerImagesFromLockFiles extracts unique container image references from +// the gh-aw-manifest embedded in each lock file's comment header. +// Images are deduplicated using the pinned image reference (image@digest) as the key +// when available, falling back to the bare image tag. +func collectContainerImagesFromLockFiles(lockFiles []string) []workflow.GHAWManifestContainer { + if len(lockFiles) == 0 { + return nil + } + + seen := make(map[string]struct{}) + var images []workflow.GHAWManifestContainer + + for _, lockFile := range lockFiles { + // #nosec G304 -- lockFile is a path produced by the compiler from trusted markdown + // sources. Paths are validated by the compile pipeline before being passed here. + content, err := os.ReadFile(lockFile) + if err != nil { + grypeLog.Printf("Skipping %s: failed to read file: %v", lockFile, err) + continue + } + + manifest, err := workflow.ExtractGHAWManifestFromLockFile(string(content)) + if err != nil { + grypeLog.Printf("Skipping %s: failed to extract manifest: %v", lockFile, err) + continue + } + if manifest == nil { + grypeLog.Printf("Skipping %s: no manifest header", lockFile) + continue + } + + for _, c := range manifest.Containers { + // Use the pinned image (image@sha256:...) as the deduplication key when + // available; fall back to the bare image tag for unpinned references. + key := c.PinnedImage + if key == "" { + key = c.Image + } + if key == "" { + continue + } + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + images = append(images, c) + } + } + } + + return images +} + +// runGrypeOnLockFiles extracts container image references from the gh-aw-manifest +// headers in the provided lock files, deduplicates them, and runs the grype +// vulnerability scanner on each unique image via Docker. +func runGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + + images := collectContainerImagesFromLockFiles(lockFiles) + if len(images) == 0 { + grypeLog.Print("No container images found in lock files") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("No container images found in lock files to scan with grype")) + } + return nil + } + + if len(images) == 1 { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running grype vulnerability scanner on 1 container image")) + } else { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage( + fmt.Sprintf("Running grype vulnerability scanner on %d container images", len(images)))) + } + + totalFindings := 0 + var scanErrors []string + + for _, img := range images { + // Prefer the pinned reference (image@sha256:...) for immutability guarantees. + imageRef := img.PinnedImage + if imageRef == "" { + imageRef = img.Image + } + + output, err := grypeRunOnImage(imageRef, verbose) + if err != nil { + grypeLog.Printf("Grype scan failed for %s: %v", img.Image, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", img.Image, err)) + continue + } + + count := grypeDisplayFindings(img.Image, output) + totalFindings += count + } + + if len(scanErrors) > 0 { + errMsg := fmt.Sprintf("grype scan failed for %d image(s): %s", + len(scanErrors), strings.Join(scanErrors, "; ")) + if strict { + return errors.New(errMsg) + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(errMsg)) + } + + if strict && totalFindings > 0 { + return fmt.Errorf("strict mode: grype found %d vulnerability finding(s) in container images", totalFindings) + } + + return nil +} + +// grypeRunOnImage runs grype on a single container image reference via Docker, +// using the result cache to avoid re-scanning images already checked in this run. +func grypeRunOnImage(imageRef string, verbose bool) (*grypeOutput, error) { + // Check cache first. + if result, err, ok := grypeScanResultCache.get(imageRef); ok { + grypeLog.Printf("Grype cache hit for %s", imageRef) + return result, err + } + + grypeLog.Printf("Scanning %s with grype", imageRef) + + // #nosec G204 -- imageRef is extracted from the gh-aw-manifest in compiled lock files, + // which are produced by this tool from trusted markdown sources. exec.Command passes + // args directly to the OS without shell interpretation, preventing command injection. + cmd := exec.Command( + "docker", + "run", + "--rm", + GrypeImage, + imageRef, + "-o", "json", + ) + + if verbose { + dockerCmd := fmt.Sprintf("docker run --rm %s %s -o json", GrypeImage, imageRef) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run grype directly: "+dockerCmd)) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + + // Parse JSON output regardless of exit code — grype exits non-zero when vulnerabilities + // are found (exit 1), so a non-zero exit does not necessarily indicate a tool failure. + var output grypeOutput + var parseErr error + if stdout.Len() > 0 && strings.HasPrefix(strings.TrimSpace(stdout.String()), "{") { + parseErr = json.Unmarshal(stdout.Bytes(), &output) + } + + if runErr != nil { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + // Command could not be started (e.g., Docker not found). + scanErr := fmt.Errorf("grype failed: %w", runErr) + grypeScanResultCache.setError(imageRef, scanErr) + return nil, scanErr + } + exitCode := exitErr.ExitCode() + // Exit code 1 means grype found vulnerabilities — that is expected and parseable. + // Any other non-zero code signals a real tool failure. + if exitCode != 1 || (parseErr != nil && stdout.Len() == 0) { + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + grypeLog.Printf("grype stderr for %s: %s", imageRef, stderrStr) + } + scanErr := fmt.Errorf("grype failed with exit code %d on %s", exitCode, imageRef) + grypeScanResultCache.setError(imageRef, scanErr) + return nil, scanErr + } + // Exit code 1 with JSON output — vulnerability findings were returned normally. + } + + if parseErr != nil { + scanErr := fmt.Errorf("failed to parse grype JSON output for %s: %w", imageRef, parseErr) + grypeScanResultCache.setError(imageRef, scanErr) + return nil, scanErr + } + + grypeScanResultCache.set(imageRef, &output) + return &output, nil +} + +// grypeDisplayFindings renders grype vulnerability findings using the CompilerError +// format so they are presented consistently with other scanner output. +// Returns the total number of findings displayed. +func grypeDisplayFindings(imageTag string, output *grypeOutput) int { + if output == nil || len(output.Matches) == 0 { + return 0 + } + + for _, match := range output.Matches { + vuln := match.Vulnerability + art := match.Artifact + + severity := vuln.Severity + if severity == "" { + severity = "Unknown" + } + + // Map severity to error type for display purposes. + errorType := "warning" + switch strings.ToLower(severity) { + case "critical", "high": + errorType = "error" + case "low", "negligible", "informational": + errorType = "info" + } + + // Build a compact message: [Severity] CVE-ID: package@version (fix: x.y.z) (url) + message := fmt.Sprintf("[%s] %s: %s@%s", severity, vuln.ID, art.Name, art.Version) + if len(vuln.Fix.Versions) > 0 { + message = fmt.Sprintf("%s (fix: %s)", message, strings.Join(vuln.Fix.Versions, ", ")) + } + if vuln.DataSource != "" { + message = fmt.Sprintf("%s (%s)", message, vuln.DataSource) + } + + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: imageTag, + Line: 1, + Column: 1, + }, + Type: errorType, + Message: message, + } + + fmt.Fprint(os.Stderr, console.FormatError(compilerErr)) + } + + return len(output.Matches) +} diff --git a/pkg/cli/grype_test.go b/pkg/cli/grype_test.go new file mode 100644 index 00000000000..992b8aac518 --- /dev/null +++ b/pkg/cli/grype_test.go @@ -0,0 +1,315 @@ +//go:build !integration + +package cli + +import ( + "errors" + "os" + "testing" +) + +func TestGrypeDisplayFindings_NilOutput(t *testing.T) { + count := grypeDisplayFindings("test-image:latest", nil) + if count != 0 { + t.Errorf("Expected 0 findings for nil output, got %d", count) + } +} + +func TestGrypeDisplayFindings_EmptyMatches(t *testing.T) { + output := &grypeOutput{Matches: []grypeFinding{}} + count := grypeDisplayFindings("test-image:latest", output) + if count != 0 { + t.Errorf("Expected 0 findings for empty output, got %d", count) + } +} + +func TestGrypeDisplayFindings_WithFindings(t *testing.T) { + output := &grypeOutput{ + Matches: []grypeFinding{ + makeGrypeFinding("CVE-2021-12345", "High", "libssl", "1.1.1", []string{"1.1.2"}, "https://nvd.nist.gov/vuln/detail/CVE-2021-12345"), + makeGrypeFinding("CVE-2021-99999", "Critical", "openssl", "1.0.0", nil, ""), + }, + } + + count := grypeDisplayFindings("ubuntu:20.04", output) + if count != 2 { + t.Errorf("Expected 2 findings, got %d", count) + } +} + +func TestGrypeDisplayFindings_SeverityMapping(t *testing.T) { + tests := []struct { + severity string + wantType string + }{ + {"Critical", "error"}, + {"High", "error"}, + {"Medium", "warning"}, + {"Low", "info"}, + {"Negligible", "info"}, + {"Informational", "info"}, + {"Unknown", "warning"}, + {"", "warning"}, + } + + for _, tc := range tests { + t.Run(tc.severity, func(t *testing.T) { + output := &grypeOutput{ + Matches: []grypeFinding{ + makeGrypeFinding("CVE-2021-00000", tc.severity, "pkg", "1.0", nil, ""), + }, + } + // We can't easily test the errorType without capturing stderr, + // but we can verify the function returns the right count. + count := grypeDisplayFindings("test-image:latest", output) + if count != 1 { + t.Errorf("Expected 1 finding for severity %q, got %d", tc.severity, count) + } + }) + } +} + +func TestGrypeCacheGetSet(t *testing.T) { + cache := &grypeCache{ + results: make(map[string]*grypeOutput), + errors: make(map[string]error), + } + + key := "test-image:latest" + + // Initially no entry. + result, err, ok := cache.get(key) + if ok { + t.Error("Expected no cache entry initially") + } + if result != nil || err != nil { + t.Error("Expected nil result and nil error for empty cache") + } + + // Set a result. + expected := &grypeOutput{Matches: []grypeFinding{}} + cache.set(key, expected) + + result, err, ok = cache.get(key) + if !ok { + t.Error("Expected cache hit after set") + } + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result != expected { + t.Error("Expected cached result to match stored result") + } +} + +func TestGrypeCacheSetError(t *testing.T) { + cache := &grypeCache{ + results: make(map[string]*grypeOutput), + errors: make(map[string]error), + } + + key := "test-image:v1.0" + testErr := errors.New("test scan error") + cache.setError(key, testErr) + + result, err, ok := cache.get(key) + if !ok { + t.Error("Expected cache hit after setError") + } + if result != nil { + t.Error("Expected nil result for error entry") + } + if !errors.Is(err, testErr) { + t.Errorf("Expected stored error %v, got %v", testErr, err) + } +} + +func TestGrypeCacheReset(t *testing.T) { + cache := &grypeCache{ + results: make(map[string]*grypeOutput), + errors: make(map[string]error), + } + + cache.set("key1", &grypeOutput{}) + cache.setError("key2", errors.New("test error")) + + cache.reset() + + _, _, ok := cache.get("key1") + if ok { + t.Error("Expected key1 to be cleared after reset") + } + _, _, ok = cache.get("key2") + if ok { + t.Error("Expected key2 to be cleared after reset") + } +} + +func TestRunGrypeOnLockFiles_NoLockFiles(t *testing.T) { + err := runGrypeOnLockFiles([]string{}, false, false) + if err != nil { + t.Errorf("Expected no error for empty lock file list, got: %v", err) + } +} + +func TestCollectContainerImagesFromLockFiles_Nil(t *testing.T) { + images := collectContainerImagesFromLockFiles(nil) + if images != nil { + t.Errorf("Expected nil for nil input, got %v", images) + } +} + +func TestCollectContainerImagesFromLockFiles_Empty(t *testing.T) { + images := collectContainerImagesFromLockFiles([]string{}) + if images != nil { + t.Errorf("Expected nil for empty input, got %v", images) + } +} + +func TestCollectContainerImagesFromLockFiles_NonExistentFile(t *testing.T) { + images := collectContainerImagesFromLockFiles([]string{"/nonexistent/path.lock.yml"}) + if len(images) != 0 { + t.Errorf("Expected 0 images for non-existent file, got %d", len(images)) + } +} + +func TestCollectContainerImagesFromLockFiles_NoManifest(t *testing.T) { + // A lock file with no gh-aw-manifest header. + tmpFile, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString("# Generated workflow YAML\nname: test\n"); err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + tmpFile.Close() + + images := collectContainerImagesFromLockFiles([]string{tmpFile.Name()}) + if len(images) != 0 { + t.Errorf("Expected 0 images for lock file without manifest, got %d", len(images)) + } +} + +func TestCollectContainerImagesFromLockFiles_WithManifest(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + manifest := `# gh-aw-manifest: {"version":1,"secrets":[],"actions":[],"containers":[{"image":"ghcr.io/test/image:v1.0","digest":"sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1","pinned_image":"ghcr.io/test/image:v1.0@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1"}]}` + if _, err := tmpFile.WriteString(manifest + "\n"); err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + tmpFile.Close() + + images := collectContainerImagesFromLockFiles([]string{tmpFile.Name()}) + if len(images) != 1 { + t.Fatalf("Expected 1 image, got %d: %v", len(images), images) + } + if images[0].Image != "ghcr.io/test/image:v1.0" { + t.Errorf("Expected image tag %q, got %q", "ghcr.io/test/image:v1.0", images[0].Image) + } + if images[0].PinnedImage == "" { + t.Error("Expected non-empty PinnedImage") + } +} + +func TestCollectContainerImagesFromLockFiles_DeduplicatesByPinnedImage(t *testing.T) { + manifest := `# gh-aw-manifest: {"version":1,"secrets":[],"actions":[],"containers":[{"image":"ghcr.io/test/image:v1.0","digest":"sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1","pinned_image":"ghcr.io/test/image:v1.0@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1"}]}` + + file1, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file 1: %v", err) + } + defer os.Remove(file1.Name()) + file1.WriteString(manifest + "\n") + file1.Close() + + file2, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file 2: %v", err) + } + defer os.Remove(file2.Name()) + file2.WriteString(manifest + "\n") + file2.Close() + + images := collectContainerImagesFromLockFiles([]string{file1.Name(), file2.Name()}) + if len(images) != 1 { + t.Errorf("Expected 1 unique image after deduplication, got %d", len(images)) + } +} + +func TestCollectContainerImagesFromLockFiles_MultipleDistinctImages(t *testing.T) { + manifest1 := `# gh-aw-manifest: {"version":1,"secrets":[],"actions":[],"containers":[{"image":"ghcr.io/test/image-a:v1.0","pinned_image":"ghcr.io/test/image-a:v1.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}` + manifest2 := `# gh-aw-manifest: {"version":1,"secrets":[],"actions":[],"containers":[{"image":"ghcr.io/test/image-b:v2.0","pinned_image":"ghcr.io/test/image-b:v2.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}` + + file1, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file 1: %v", err) + } + defer os.Remove(file1.Name()) + file1.WriteString(manifest1 + "\n") + file1.Close() + + file2, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file 2: %v", err) + } + defer os.Remove(file2.Name()) + file2.WriteString(manifest2 + "\n") + file2.Close() + + images := collectContainerImagesFromLockFiles([]string{file1.Name(), file2.Name()}) + if len(images) != 2 { + t.Errorf("Expected 2 distinct images, got %d", len(images)) + } +} + +func TestCollectContainerImagesFromLockFiles_EmptyImageIgnored(t *testing.T) { + manifest := `# gh-aw-manifest: {"version":1,"secrets":[],"actions":[],"containers":[{"image":"","pinned_image":""}]}` + + tmpFile, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(manifest + "\n") + tmpFile.Close() + + images := collectContainerImagesFromLockFiles([]string{tmpFile.Name()}) + if len(images) != 0 { + t.Errorf("Expected 0 images (empty image name ignored), got %d", len(images)) + } +} + +func TestCollectContainerImagesFromLockFiles_NoContainers(t *testing.T) { + manifest := `# gh-aw-manifest: {"version":1,"secrets":["MY_SECRET"],"actions":[]}` + + tmpFile, err := os.CreateTemp("", "test-*.lock.yml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(manifest + "\n") + tmpFile.Close() + + images := collectContainerImagesFromLockFiles([]string{tmpFile.Name()}) + if len(images) != 0 { + t.Errorf("Expected 0 images for manifest without containers, got %d", len(images)) + } +} + +// makeGrypeFinding is a test helper that constructs a grypeFinding. +func makeGrypeFinding(id, severity, pkgName, pkgVersion string, fixVersions []string, dataSource string) grypeFinding { + f := grypeFinding{} + f.Vulnerability.ID = id + f.Vulnerability.Severity = severity + f.Vulnerability.DataSource = dataSource + f.Vulnerability.Fix.Versions = fixVersions + f.Artifact.Name = pkgName + f.Artifact.Version = pkgVersion + return f +} diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index 81e3f075720..473168b7db3 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -75,6 +75,7 @@ type compileArgs struct { Poutine bool `json:"poutine,omitempty" jsonschema:"Run poutine security scanner on generated .lock.yml files"` Actionlint bool `json:"actionlint,omitempty" jsonschema:"Run actionlint linter on generated .lock.yml files"` RunnerGuard bool `json:"runner-guard,omitempty" jsonschema:"Run runner-guard taint analysis scanner on generated .lock.yml files"` + Grype bool `json:"grype,omitempty" jsonschema:"Run grype vulnerability scanner on container images referenced in compiled .lock.yml files"` Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` MaxTokens int `json:"max_tokens,omitempty" jsonschema:"Deprecated: accepted for backward compatibility but ignored."` } @@ -138,9 +139,9 @@ Returns JSON array with validation results for each workflow: var dockerUnavailableWarning string // Check if any static analysis tools are requested that require Docker images - if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Grype { // Check if Docker images are available; if not, start downloading and return retry message - if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -151,6 +152,7 @@ Returns JSON array with validation results for each workflow: args.Poutine = false args.Actionlint = false args.RunnerGuard = false + args.Grype = false } else { // Images are still downloading — ask the caller to retry. // Build per-workflow validation errors instead of throwing an MCP protocol error, @@ -201,6 +203,9 @@ Returns JSON array with validation results for each workflow: if args.RunnerGuard { cmdArgs = append(cmdArgs, "--runner-guard") } + if args.Grype { + cmdArgs = append(cmdArgs, "--grype") + } cmdArgs = append(cmdArgs, args.Workflows...) @@ -210,8 +215,8 @@ Returns JSON array with validation results for each workflow: cmdArgs = append(cmdArgs, "--prior-manifest-file", manifestCacheFile) } - mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, grype=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: