diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 5760dbda83b..6dc7dacc7eb 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":"4c696dba97db546dc6a291704ae2b207353b8435dc8154b38337183b589c9255","body_hash":"7056f785369cace55221c1645a714938384813226a05bde93eeae68b01f6de0f","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.216"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"fff0fc640fe0daee152d28223cb1d5279f2d18eabf71dc42cba91f2b9e7580c3","body_hash":"30636ec30879016c7250a8383a69c71aa69640489b71a1a756da26a0bd12e7b7","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.38","digest":"sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.38@sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38","digest":"sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38@sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38","digest":"sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38@sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38","digest":"sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38@sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917"},{"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, runner-guard, and grype +# Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, 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\n# Pull grype Docker image\necho \"Pulling grype image...\"\ndocker pull anchore/grype: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\n# Pull syft Docker image\necho \"Pulling syft image...\"\ndocker pull anchore/syft: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\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" + 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\n# Verify syft\necho \"Testing syft...\"\ndocker run --rm anchore/syft:latest version || echo \"Warning: syft 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 --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\"" + 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 --syft --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, runner-guard, and grype" + WORKFLOW_DESCRIPTION: "Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, 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 1e02b19c27d..808a53b5ebc 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, runner-guard, and grype +description: Scans agentic workflows daily with zizmor, poutine, actionlint, runner-guard, syft, and grype on: schedule: daily workflow_dispatch: @@ -58,6 +58,10 @@ steps: # Pull grype Docker image echo "Pulling grype image..." docker pull anchore/grype:latest + + # Pull syft Docker image + echo "Pulling syft image..." + docker pull anchore/syft:latest echo "All static analysis Docker images pulled successfully" - name: Verify static analysis tools @@ -80,6 +84,10 @@ steps: # Verify grype echo "Testing grype..." docker run --rm anchore/grype:latest version || echo "Warning: grype version check failed" + + # Verify syft + echo "Testing syft..." + docker run --rm anchore/syft:latest version || echo "Warning: syft version check failed" echo "Static analysis tools verification complete" - name: Run compile with security tools @@ -89,7 +97,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 --grype 2>&1 | tee /tmp/gh-aw/agent/compile-output.txt + "$GITHUB_WORKSPACE/gh-aw" compile --zizmor --poutine --actionlint --runner-guard --syft --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" @@ -101,7 +109,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, actionlint, runner-guard, and grype. +You are the Static Analysis Report Agent - an expert system that scans agentic workflows for security vulnerabilities, SBOM inventory data, and code quality issues using multiple static analysis tools: zizmor, poutine, actionlint, runner-guard, syft, and grype. ## Mission @@ -121,10 +129,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, runner-guard, grype) 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, syft, 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 five static analysis tools. + Read and parse the file `/tmp/gh-aw/agent/compile-output.txt` which contains the compilation output from all six static analysis tools. The output is JSON format with validation results for each workflow: - workflow: Name of the workflow file @@ -132,10 +140,11 @@ 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, actionlint, runner-guard, and grype (if any) + - security and lint findings from zizmor, poutine, actionlint, runner-guard, and grype (if any) + - SBOM inventory output from syft, including scanned images and package counts 2. **Parse and Extract Findings**: - - Parse the JSON output to extract findings from all five tools + - Parse the compilation output to extract findings from all six 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 @@ -147,7 +156,7 @@ The workflow has already compiled all workflows with static analysis tools (zizm ### Phase 2: Analyze and Cluster Findings -Review the output from all five tools and cluster findings: +Review the output from all six tools and cluster findings: #### 2.1 Parse Tool Outputs @@ -177,9 +186,26 @@ Review the output from all five tools and cluster findings: - Location (file, line, column) - Suggestions for fixes +**Syft Output**: +- Extract SBOM inventory data from syft +- Parse inventory details: + - Container image reference + - Total package count + - Notable package names or ecosystems if surfaced + - Workflows that reference each image + +**Grype Output**: +- Extract container vulnerability findings from grype +- Parse finding details: + - Package name + - Vulnerability ID + - Severity + - Affected image and workflow + - Fixed version when available + #### 2.2 Cluster by Issue Type and Tool Group findings by: -- Tool (zizmor, poutine, actionlint, runner-guard, grype) +- Tool (zizmor, poutine, actionlint, runner-guard, syft, grype) - Issue identifier/rule code - Severity level - Count occurrences of each issue type @@ -199,7 +225,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 five tools (zizmor, poutine, actionlint, runner-guard, grype) + - Include findings from all six tools (zizmor, poutine, actionlint, runner-guard, syft, grype) - Maintain an index of all scans in `/tmp/gh-aw/cache-memory/security-scans/index.json` 2. **Update Vulnerability Database**: @@ -272,7 +298,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 five tools +- **Summary**: Overview of static analysis findings from all six 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 @@ -291,7 +317,7 @@ Wrap long sections (>5 items, detailed lists, raw data) in `
5 items, detailed lists, raw data) in `
5 items, detailed lists, raw data) in `
-o syft-json`) so no native install is required. The implementation reuses the existing `collectContainerImagesFromLockFiles` function (shared with Grype) and the `runBatchLockFileTool` dispatch pattern. In strict mode (`--strict`), any scan error causes a non-zero exit. + +### Alternatives Considered + +#### Alternative 1: Use Trivy for SBOM generation instead of Syft + +Trivy (`aquasecurity/trivy`) supports SBOM output formats (CycloneDX, SPDX) and has a compatible Docker-based invocation model. It was not chosen because Syft is already the de-facto SBOM tool from Anchore — the same vendor as Grype — making the two tools natural companions in the compile pipeline. Using Syft maintains vendor consistency and leverages developer familiarity already established by Grype adoption. + +#### Alternative 2: Extract SBOM data from Grype output instead of a separate tool + +Grype can produce CycloneDX-format SBOM alongside vulnerability findings using `--add-cpes-if-none` and related flags. Reusing Grype would avoid adding a new Docker image. It was not chosen because SBOM generation is a distinct concern from vulnerability scanning — Grype's primary output is a vulnerability report, not an SBOM — and conflating the two would make the `--grype` flag ambiguous. Syft is purpose-built for SBOM generation and produces richer package metadata. + +#### Alternative 3: Implement SBOM scanning as a separate CI step, outside `gh aw compile` + +A dedicated CI job or reusable workflow action could scan images listed in lock-file manifests after compilation, keeping the compile pipeline lighter. This was not chosen because tight coupling of the SBOM scan to compilation (same invocation, same strict-mode gate, same output format) gives developers immediate local feedback via `gh aw compile --syft` without requiring CI configuration changes, consistent with how `--grype`, `--zizmor`, and `--runner-guard` are used today. + +### Consequences + +#### Positive +- No native Syft installation required; Docker is the only prerequisite, already mandated by `--runner-guard`, `--poutine`, and `--grype`. +- Reuses the existing `collectContainerImagesFromLockFiles` and `runBatchLockFileTool` infrastructure, minimising new code surface area. +- Developers receive SBOM package counts per image at compile time, enabling early supply-chain visibility without CI changes. +- The `--syft` flag follows the same opt-in pattern as all other post-compile tools; existing compile invocations are unaffected. + +#### Negative +- Docker must be running; users without Docker (or where the daemon is unavailable) silently skip Syft scanning rather than receiving a hard error, which may create a false sense of security. +- `anchore/syft:latest` is pinned to a mutable tag. A breaking upstream release could cause non-deterministic failures across environments; a pinned digest would be safer. +- Each unique image requires a `docker run` invocation; cold-start Docker image pull adds significant latency on first use per machine. +- Syft SBOM output is currently printed as a package count only; the full SBOM artifact is not persisted to disk or attached to compilation output, limiting downstream use of the generated data. + +#### Neutral +- The MCP tool interface (`mcp_tools_readonly.go`) exposes `syft` as a JSON schema field, making it available to AI-assisted compile invocations alongside `grype` and other scanners. +- Strict mode integration mirrors the `--grype` implementation: any scan error returns a non-zero exit when `--strict` is set, otherwise a warning is printed. +- The implementation parses `syft-json` output format to extract artifact counts but does not otherwise post-process or filter findings — SBOM evaluation is left to the consumer. + +--- + +*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 023ff1dfb62..91147e21c31 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 + Syft bool // Run syft SBOM scanner on container images referenced in compiled .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 diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go index de256aad167..422dae39f51 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -1,7 +1,7 @@ // This file provides external tool runners for workflow compilation. // // This file contains functions that invoke external analysis tools -// (actionlint, zizmor, poutine, runner-guard) on compiled workflow files. +// (actionlint, zizmor, poutine, runner-guard, syft, grype) on compiled workflow files. // // # Organization Rationale // @@ -65,6 +65,12 @@ func RunGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { return runBatchLockFileTool("grype", lockFiles, verbose, strict, runGrypeOnLockFiles) } +// RunSyftOnLockFiles runs the syft SBOM scanner on container images extracted +// from the gh-aw-manifest headers in the provided lock files. +func RunSyftOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + return runBatchLockFileTool("syft", lockFiles, verbose, strict, runSyftOnLockFiles) +} + // 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 ce735ebc086..14ac3161f77 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 lockFilesForSyft []string // lock files for syft container image SBOM scanning var lockFilesForGrype []string // lock files for grype container image vulnerability scanning // Compile each specified file @@ -152,6 +153,9 @@ func compileSpecificFiles( if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } + if config.Syft { + lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) + } } } } @@ -211,6 +215,18 @@ func compileSpecificFiles( } } + // Run syft SBOM scanner on container images referenced in the compiled lock files. + if config.Syft && !config.NoEmit && len(lockFilesForSyft) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunSyftOnLockFiles(lockFilesForSyft, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // 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 { @@ -318,6 +334,7 @@ func compileAllFilesInDirectory( var lockFilesForActionlint []string var lockFilesForZizmor []string var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) + var lockFilesForSyft []string // lock files for syft container image SBOM scanning var lockFilesForGrype []string // lock files for grype container image vulnerability scanning for _, file := range mdFiles { @@ -376,6 +393,9 @@ func compileAllFilesInDirectory( if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } + if config.Syft { + lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) + } } } } @@ -431,6 +451,18 @@ func compileAllFilesInDirectory( } } + // Run syft SBOM scanner on container images referenced in the compiled lock files. + if config.Syft && !config.NoEmit && len(lockFilesForSyft) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunSyftOnLockFiles(lockFilesForSyft, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // 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 { diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index e20e38bf478..18b6789adfd 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" + SyftImage = "anchore/syft:v1.48.0" GrypeImage = "anchore/grype:latest" ) @@ -225,9 +226,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, useGrype bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useSyft, useGrype bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useGrype { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useSyft && !useGrype { return nil } @@ -255,6 +256,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useSyft { + tool := "syft" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } if useGrype { tool := "grype" requestedTools = append(requestedTools, tool) @@ -282,6 +288,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {usePoutine, PoutineImage, "poutine"}, {useActionlint, ActionlintImage, "actionlint"}, {useRunnerGuard, RunnerGuardImage, "runner-guard"}, + {useSyft, SyftImage, "syft"}, {useGrype, GrypeImage, "grype"}, } diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index 39cc5895f6d..26e1bb57e2d 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, false) + err := CheckAndPrepareDockerImages(context.Background(), false, 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, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, 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 SyftImage == "" { + t.Error("SyftImage constant should not be empty") + } if GrypeImage == "" { t.Error("GrypeImage constant should not be empty") } @@ -114,6 +117,7 @@ func TestDockerImageConstants(t *testing.T) { "poutine": PoutineImage, "actionlint": ActionlintImage, "runner-guard": RunnerGuardImage, + "syft": SyftImage, "grype": GrypeImage, } @@ -138,7 +142,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -164,7 +168,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -199,7 +203,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, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -223,7 +227,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, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -530,7 +534,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, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -568,7 +572,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -607,7 +611,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, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested (even with Docker unavailable), got: %v", err) } @@ -639,7 +643,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -668,7 +672,7 @@ func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) { SetDockerImageDownloading(RunnerGuardImage, true) // Request all tools, including runner-guard - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } diff --git a/pkg/cli/mcp_argument_validation_test.go b/pkg/cli/mcp_argument_validation_test.go index f9be889c0eb..a003450e166 100644 --- a/pkg/cli/mcp_argument_validation_test.go +++ b/pkg/cli/mcp_argument_validation_test.go @@ -95,7 +95,7 @@ func TestExtractUnknownParamsFromSchemaError(t *testing.T) { // TestFindSimilarParam verifies the fuzzy matching of parameter names. func TestFindSimilarParam(t *testing.T) { - compileParams := []string{"actionlint", "fix", "max_tokens", "poutine", "runner-guard", "strict", "workflows", "zizmor"} + compileParams := []string{"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "zizmor"} tests := []struct { name string @@ -206,7 +206,7 @@ func TestBuildHelpfulParamError(t *testing.T) { // that the middleware replaces raw schema validation errors with helpful messages. func TestArgumentValidationMiddleware_TransformsAdditionalPropertiesError(t *testing.T) { toolParams := map[string]toolParamEntry{ - "compile": {"actionlint", "fix", "max_tokens", "poutine", "runner-guard", "strict", "workflows", "zizmor"}, + "compile": {"actionlint", "fix", "grype", "max_tokens", "poutine", "runner-guard", "strict", "syft", "workflows", "zizmor"}, } middleware := argumentValidationMiddleware(toolParams) diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index 473168b7db3..a37f72e761a 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"` + Syft bool `json:"syft,omitempty" jsonschema:"Run syft SBOM scanner on container images referenced in compiled .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."` @@ -139,9 +140,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 || args.Grype { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Syft || 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, args.Grype); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -152,6 +153,7 @@ Returns JSON array with validation results for each workflow: args.Poutine = false args.Actionlint = false args.RunnerGuard = false + args.Syft = false args.Grype = false } else { // Images are still downloading — ask the caller to retry. @@ -203,6 +205,9 @@ Returns JSON array with validation results for each workflow: if args.RunnerGuard { cmdArgs = append(cmdArgs, "--runner-guard") } + if args.Syft { + cmdArgs = append(cmdArgs, "--syft") + } if args.Grype { cmdArgs = append(cmdArgs, "--grype") } @@ -215,8 +220,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, grype=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, syft=%v, grype=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: diff --git a/pkg/cli/syft.go b/pkg/cli/syft.go new file mode 100644 index 00000000000..15fe06468d9 --- /dev/null +++ b/pkg/cli/syft.go @@ -0,0 +1,158 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" +) + +var syftLog = logger.New("cli:syft") + +type syftOutput struct { + Artifacts []struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + } `json:"artifacts"` +} + +// SyftScanResult holds the results of a Syft scan. +type SyftScanResult struct { + ImageRef string + PackageCount int + SBOMPath string // Path to the persisted SBOM file +} + +// runSyftOnLockFiles extracts container image references from lock-file manifests +// and runs syft to generate SBOM data for each unique image. +// SBOM files are persisted to disk and paths are returned in the results. +func runSyftOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + + images := collectContainerImagesFromLockFiles(lockFiles) + if len(images) == 0 { + syftLog.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 syft")) + } + return nil + } + + if len(images) == 1 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running syft SBOM scanner on 1 container image")) + } else { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Running syft SBOM scanner on %d container images", len(images)))) + } + + // Create output directory for SBOM files + sbomDir := filepath.Join(os.TempDir(), "gh-aw-syft-sboms") + if err := os.MkdirAll(sbomDir, constants.DirPermPublic); err != nil { + return fmt.Errorf("failed to create SBOM directory: %w", err) + } + + var scanErrors []string + var results []SyftScanResult + + ctx := context.Background() + for _, img := range images { + imageRef := img.PinnedImage + if imageRef == "" { + imageRef = img.Image + } + + result, err := runSyftOnImage(ctx, imageRef, sbomDir, verbose) + if err != nil { + syftLog.Printf("Syft scan failed for %s: %v", img.Image, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", img.Image, err)) + continue + } + results = append(results, *result) + } + + // Report SBOM file locations + if verbose && len(results) > 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("SBOM files saved to: "+sbomDir)) + for _, result := range results { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf(" %s: %s (%d packages)", result.ImageRef, result.SBOMPath, result.PackageCount))) + } + } + + if len(scanErrors) == 0 { + return nil + } + + errMsg := fmt.Sprintf("syft scan failed for %d image(s): %s", len(scanErrors), strings.Join(scanErrors, "; ")) + if strict { + return fmt.Errorf("%s", errMsg) + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(errMsg)) + return nil +} + +func runSyftOnImage(ctx context.Context, imageRef, sbomDir string, verbose bool) (*SyftScanResult, error) { + syftLog.Printf("Scanning %s with syft", imageRef) + + // #nosec G204 -- imageRef comes from compiled lock-file manifests and is passed + // as a direct process argument (no shell interpolation). + cmd := exec.CommandContext( + ctx, + "docker", + "run", + "--rm", + SyftImage, + imageRef, + "-o", "syft-json", + ) + + if verbose { + dockerCmd := fmt.Sprintf("docker run --rm %s %s -o syft-json", SyftImage, imageRef) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run syft directly: "+dockerCmd)) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + syftLog.Printf("syft stderr for %s: %s", imageRef, stderrStr) + return nil, fmt.Errorf("syft failed on %s: %w\nstderr: %s", imageRef, err, stderrStr) + } + return nil, fmt.Errorf("syft failed on %s: %w", imageRef, err) + } + + var output syftOutput + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return nil, fmt.Errorf("failed to parse syft JSON output for %s: %w", imageRef, err) + } + + // Generate a safe filename from the image reference + replacer := strings.NewReplacer("/", "_", ":", "_", "@", "_") + safeImageName := replacer.Replace(imageRef) + sbomPath := filepath.Join(sbomDir, fmt.Sprintf("sbom-%s.json", safeImageName)) + + // Persist the SBOM to disk + if err := os.WriteFile(sbomPath, stdout.Bytes(), constants.FilePermPublic); err != nil { + return nil, fmt.Errorf("failed to write SBOM file for %s: %w", imageRef, err) + } + + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("syft scanned %s (%d packages, SBOM: %s)", imageRef, len(output.Artifacts), sbomPath))) + + return &SyftScanResult{ + ImageRef: imageRef, + PackageCount: len(output.Artifacts), + SBOMPath: sbomPath, + }, nil +} diff --git a/pkg/cli/syft_test.go b/pkg/cli/syft_test.go new file mode 100644 index 00000000000..d1d7f13f985 --- /dev/null +++ b/pkg/cli/syft_test.go @@ -0,0 +1,200 @@ +//go:build !integration + +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/constants" +) + +func TestRunSyftOnImage_NilContext(t *testing.T) { + // This test verifies that we can pass context.Background() + ctx := context.Background() + sbomDir := t.TempDir() + + // We can't actually run Docker in unit tests, so this test + // just verifies the function signature accepts context + _, err := runSyftOnImage(ctx, "alpine:latest", sbomDir, false) + if err != nil { + t.Skip("Docker not available or image not found, skipping") + } +} + +func TestRunSyftOnImage_ContextCancellation(t *testing.T) { + // This test verifies that context cancellation is respected + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel the context immediately before calling the function + cancel() + + sbomDir := t.TempDir() + + _, err := runSyftOnImage(ctx, "alpine:latest", sbomDir, false) + if err == nil { + t.Skip("Expected context cancellation error, but got nil - Docker may not be available") + } + + // Context cancellation should produce an error + if err != nil && ctx.Err() != nil { + // Expected: context was cancelled + t.Logf("Context cancellation worked as expected: %v", err) + } +} + +func TestRunSyftOnImage_JSONParsing(t *testing.T) { + // Test that we can parse syft JSON output + syftJSON := []byte(`{ + "artifacts": [ + {"name": "alpine-baselayout", "version": "3.2.0-r18", "type": "apk"}, + {"name": "busybox", "version": "1.33.1-r6", "type": "apk"} + ] + }`) + + var output syftOutput + if err := json.Unmarshal(syftJSON, &output); err != nil { + t.Fatalf("Failed to parse syft JSON: %v", err) + } + + if len(output.Artifacts) != 2 { + t.Errorf("Expected 2 artifacts, got %d", len(output.Artifacts)) + } + + if output.Artifacts[0].Name != "alpine-baselayout" { + t.Errorf("Expected first artifact name to be 'alpine-baselayout', got '%s'", output.Artifacts[0].Name) + } +} + +func TestRunSyftOnImage_SBOMPersistence(t *testing.T) { + t.Skip("Requires Docker and network access - run as integration test") + + ctx := context.Background() + sbomDir := t.TempDir() + + // Run syft on a small image + result, err := runSyftOnImage(ctx, "alpine:latest", sbomDir, false) + if err != nil { + t.Fatalf("Failed to run syft: %v", err) + } + + // Verify result structure + if result.ImageRef != "alpine:latest" { + t.Errorf("Expected image ref 'alpine:latest', got '%s'", result.ImageRef) + } + + if result.PackageCount <= 0 { + t.Errorf("Expected positive package count, got %d", result.PackageCount) + } + + if result.SBOMPath == "" { + t.Errorf("Expected non-empty SBOM path") + } + + // Verify SBOM file exists + if _, err := os.Stat(result.SBOMPath); os.IsNotExist(err) { + t.Errorf("SBOM file does not exist at %s", result.SBOMPath) + } + + // Verify SBOM file contains valid JSON + data, err := os.ReadFile(result.SBOMPath) + if err != nil { + t.Fatalf("Failed to read SBOM file: %v", err) + } + + var output syftOutput + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("SBOM file does not contain valid syft JSON: %v", err) + } + + if len(output.Artifacts) != result.PackageCount { + t.Errorf("SBOM artifact count (%d) does not match result count (%d)", len(output.Artifacts), result.PackageCount) + } +} + +func TestRunSyftOnImage_SafeFilename(t *testing.T) { + // Test that image references with special characters produce safe filenames + sbomDir := t.TempDir() + + // Manually construct what the expected filename should be for an image like: + // "gcr.io/distroless/static:latest@sha256:abc123" + safeImageName := "gcr.io_distroless_static_latest_sha256_abc123" + expectedPath := filepath.Join(sbomDir, "sbom-"+safeImageName+".json") + + // Create a fake SBOM file with that name to test path construction + sbomJSON := []byte(`{"artifacts": [{"name": "test", "version": "1.0", "type": "test"}]}`) + if err := os.WriteFile(expectedPath, sbomJSON, constants.FilePermPublic); err != nil { + t.Fatalf("Failed to write test SBOM: %v", err) + } + + // Verify the file exists + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Errorf("Expected file at %s to exist", expectedPath) + } +} + +func TestRunSyftOnLockFiles_NoImages(t *testing.T) { + // Test that we handle lock files with no container images gracefully + lockFile := filepath.Join(t.TempDir(), "empty.lock.yml") + content := ` +version: '1.0' +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "no containers here" +` + if err := os.WriteFile(lockFile, []byte(content), constants.FilePermPublic); err != nil { + t.Fatalf("Failed to write test lock file: %v", err) + } + + err := runSyftOnLockFiles([]string{lockFile}, false, false) + if err != nil { + t.Errorf("Expected no error for lock files without images, got: %v", err) + } +} + +func TestRunSyftOnLockFiles_StrictMode(t *testing.T) { + // Test that strict mode propagates errors + lockFile := filepath.Join(t.TempDir(), "test.lock.yml") + content := ` +version: '1.0' +jobs: + test: + container: + image: does-not-exist:latest +` + if err := os.WriteFile(lockFile, []byte(content), constants.FilePermPublic); err != nil { + t.Fatalf("Failed to write test lock file: %v", err) + } + + // In strict mode, a scan failure should return an error + err := runSyftOnLockFiles([]string{lockFile}, false, true) + if err == nil { + t.Skip("Expected error in strict mode, but got nil - Docker may not be available or image exists") + } +} + +func TestRunSyftOnLockFiles_NonStrictMode(t *testing.T) { + // Test that non-strict mode continues on errors + lockFile := filepath.Join(t.TempDir(), "test.lock.yml") + content := ` +version: '1.0' +jobs: + test: + container: + image: does-not-exist:latest +` + if err := os.WriteFile(lockFile, []byte(content), constants.FilePermPublic); err != nil { + t.Fatalf("Failed to write test lock file: %v", err) + } + + // In non-strict mode, a scan failure should only produce a warning + err := runSyftOnLockFiles([]string{lockFile}, false, false) + if err != nil { + t.Errorf("Expected no error in non-strict mode, got: %v", err) + } +}