diff --git a/cmd/gh-aw/compile_flags_test.go b/cmd/gh-aw/compile_flags_test.go index 7111c027359..b846b513844 100644 --- a/cmd/gh-aw/compile_flags_test.go +++ b/cmd/gh-aw/compile_flags_test.go @@ -28,4 +28,12 @@ func TestCompileCommandShortFlags(t *testing.T) { if noModelsDevLookupFlag.DefValue != "false" { t.Fatalf("expected --no-models-dev-lookup default to be false, got %s", noModelsDevLookupFlag.DefValue) } + + grantFlag := compileCmd.Flags().Lookup("grant") + if grantFlag == nil { + t.Fatal("expected --grant flag on compile command") + } + if grantFlag.DefValue != "false" { + t.Fatalf("expected --grant default to be false, got %s", grantFlag.DefValue) + } } diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 159d07096b6..0ad0fa97311 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -324,6 +324,7 @@ Unlike ` + "`gh aw upgrade`" + `, ` + "`gh aw compile`" + ` only applies codemod runnerGuard, _ := cmd.Flags().GetBool("runner-guard") syft, _ := cmd.Flags().GetBool("syft") grype, _ := cmd.Flags().GetBool("grype") + grant, _ := cmd.Flags().GetBool("grant") yamllint, _ := cmd.Flags().GetBool("yamllint") jsonOutput, _ := cmd.Flags().GetBool("json") showAllErrors, _ := cmd.Flags().GetBool("show-all") @@ -392,6 +393,7 @@ Unlike ` + "`gh aw upgrade`" + `, ` + "`gh aw compile`" + ` only applies codemod RunnerGuard: runnerGuard, Syft: syft, Grype: grype, + Grant: grant, Yamllint: yamllint, JSONOutput: jsonOutput, ShowAllErrors: showAllErrors, @@ -753,6 +755,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all compileCmd.Flags().Bool("runner-guard", false, "Run runner-guard taint analysis scanner on generated .lock.yml files (uses Docker image "+cli.RunnerGuardImage+")") compileCmd.Flags().Bool("syft", false, "Run syft SBOM scanner on container images referenced in compiled .lock.yml files (uses Docker image "+cli.SyftImage+")") compileCmd.Flags().Bool("grype", false, "Run grype vulnerability scanner on container images referenced in compiled .lock.yml files (uses Docker image "+cli.GrypeImage+")") + compileCmd.Flags().Bool("grant", false, "Run grant license scanner on container images referenced in compiled .lock.yml files (uses Docker image "+cli.GrantImage+")") compileCmd.Flags().Bool("yamllint", false, "Run yamllint YAML linter on generated .lock.yml files (uses Docker image "+cli.YamllintImage+")") compileCmd.Flags().Bool("fix", false, "Apply automatic codemod fixes to workflows before compiling") compileCmd.Flags().BoolP("json", "j", false, "Output results in JSON format") diff --git a/docs/adr/47516-add-grant-container-license-scan.md b/docs/adr/47516-add-grant-container-license-scan.md new file mode 100644 index 00000000000..f5431f58459 --- /dev/null +++ b/docs/adr/47516-add-grant-container-license-scan.md @@ -0,0 +1,53 @@ +# ADR-47516: Add Grant Container Image License Scanning to Compile Pipeline + +**Date**: 2026-07-23 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +Compiled GitHub Actions workflows reference container images recorded in each `.lock.yml` file under a `gh-aw-manifest` header. The compile pipeline already enforces container image vulnerability policy via `--grype` (Anchore Grype), but has no mechanism to enforce software license compliance on those images. A repository-level license policy file (`.grant.yaml`) exists but is not applied at compile time. Developers and security teams need a way to detect denied-license packages in container images before workflows ship, without requiring a native scanner binary on every machine. + +### Decision + +We will integrate Anchore Grant as an opt-in post-compilation step (`--grant`) that scans container images extracted from `gh-aw-manifest` headers in compiled `.lock.yml` files. Grant will be invoked via Docker (`docker run --rm -v .grant.yaml:/tmp/policy.yaml:ro anchore/grant:latest --config /tmp/policy.yaml --output json check `) so no native install is required. Images are collected from lock files using the same `collectContainerImagesFromLockFiles` helper used by Grype, deduplicated by pinned reference before scanning. Findings with `"decision": "deny"` are surfaced as `console.CompilerError` entries. In strict mode (`--strict`), any denied-license finding or scan error causes a non-zero exit. + +### Alternatives Considered + +#### Alternative 1: Extend Grype to report license findings alongside CVEs + +Grype's primary focus is CVE detection; it does not evaluate license policy. Adding license output to Grype would require maintaining a fork or relying on unstable experimental features. Rejected because it conflates two distinct concerns (security vulnerabilities vs. license compliance) into a single tool, and Grant is purpose-built for license policy enforcement with first-class policy-file support. + +#### Alternative 2: Use FOSSA or Snyk for license scanning + +FOSSA and Snyk offer license scanning as a service with richer policy management and audit trails. Both require network access to a managed service and API credentials, making them unsuitable for offline or air-gapped compile runs. Grant runs fully locally via Docker and reads the existing `.grant.yaml` policy file, requiring no external service or credential management. + +#### Alternative 3: Perform license scanning as a separate CI step outside the compile pipeline + +A dedicated CI job could invoke grant on images listed in manifests after compilation. This keeps the compile pipeline lightweight and lets scanning scale independently. Rejected because coupling the scan to `gh aw compile --grant` gives developers immediate local feedback without requiring CI configuration changes, and aligns with the existing pattern established by `--grype`, `--zizmor`, `--poutine`, and `--runner-guard`. + +### Consequences + +#### Positive +- License compliance is enforced at compile time using the repository's own `.grant.yaml` policy, with no additional configuration needed. +- No native Grant installation required; Docker is the only prerequisite (already required by `--runner-guard`, `--poutine`, and `--grype`). +- Grant findings are surfaced through the existing `console.CompilerError` format, giving a unified developer experience alongside other scanner output. +- Strict mode integration (`--strict`) allows CI pipelines to gate on license violations without additional scripting. +- Follows the established opt-in scanner plugin pattern; existing compile invocations are unaffected. + +#### Negative +- Docker must be running; users without a running Docker daemon silently skip grant scanning rather than receiving a hard error, which may create a false sense of license compliance. +- `anchore/grant:latest` is pinned to a mutable tag. Upstream releases may produce different results or break scanning non-deterministically across environments. +- Requires `.grant.yaml` to exist at the repository root; if absent, the scan fails rather than skipping gracefully, blocking the compile run. +- Each unique container image requires a separate `docker run` invocation; the first use per machine triggers a Docker image pull, adding significant latency. + +#### Neutral +- The `--grant` flag follows the same opt-in model as `--zizmor`, `--poutine`, `--actionlint`, `--runner-guard`, and `--grype`. +- The MCP tool interface (`mcp_tools_readonly.go`) exposes `grant` as a JSON schema field, making it available to AI-assisted compile invocations. +- Grant's policy evaluation is determined by `.grant.yaml`; changes to that file change scan outcomes without code changes to this feature. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/docs/src/content/docs/reference/compilation-process.md b/docs/src/content/docs/reference/compilation-process.md index 9765d5bc9b4..c9dafe4d40d 100644 --- a/docs/src/content/docs/reference/compilation-process.md +++ b/docs/src/content/docs/reference/compilation-process.md @@ -228,6 +228,7 @@ Pre-activation runs gating checks sequentially before any AI execution. Any fail | `gh aw compile --verbose` | Enable verbose output | | `gh aw compile --strict` | Enhanced security validation | | `gh aw compile --no-emit` | Validate without generating files | +| `gh aw compile --actionlint --zizmor --poutine --grant` | Run security scanners | | `gh aw compile --actionlint --zizmor --poutine --yamllint` | Run security scanners | | `gh aw compile --purge` | Remove orphaned `.lock.yml` files | | `gh aw compile --output /path/to/output` | Custom output directory | diff --git a/docs/src/content/docs/reference/gh-aw-as-mcp-server.md b/docs/src/content/docs/reference/gh-aw-as-mcp-server.md index d261814384b..69c25058ba1 100644 --- a/docs/src/content/docs/reference/gh-aw-as-mcp-server.md +++ b/docs/src/content/docs/reference/gh-aw-as-mcp-server.md @@ -130,13 +130,13 @@ Compile Markdown workflows to GitHub Actions YAML with optional static analysis. - `workflows` (optional): Array of workflow files to compile (empty for all) - `strict` (optional): Enforce strict mode validation (default: true) - `fix` (optional): Apply automatic codemod fixes before compiling -- `zizmor`, `poutine`, `actionlint` (optional): Run security scanners/linters +- `zizmor`, `poutine`, `actionlint`, `grant` (optional): Run security scanners/linters - `jq` (optional): Apply jq filter to JSON output Returns a JSON array with `workflow`, `valid`, `errors`, `warnings`, and `compiled_file` fields. > [!NOTE] -> The `actionlint`, `zizmor`, and `poutine` scanners use Docker images that download on first use. If images are still being pulled, the tool returns a "Docker images are being downloaded. Please wait and retry the compile command." message. Wait 15–30 seconds, then retry the request. +> The `actionlint`, `zizmor`, `poutine`, and `grant` scanners use Docker images that download on first use. If images are still being pulled, the tool returns a "Docker images are being downloaded. Please wait and retry the compile command." message. Wait 15–30 seconds, then retry the request. ### `logs` diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index d61f3c0e7fd..638f231a068 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -321,6 +321,7 @@ gh aw compile --validate --strict # Schema + strict mode validation gh aw compile --fix # Run fix before compilation gh aw compile --zizmor # Security scan (warnings) gh aw compile --strict --zizmor # Security scan (fails on findings) +gh aw compile --grant # License scan container images gh aw compile --yamllint # Lint generated YAML output gh aw compile --dependabot # Generate dependency manifests gh aw compile --purge # Remove orphaned .lock.yml files @@ -330,7 +331,7 @@ If the repository root contains an [`aw.yml` manifest](/gh-aw/reference/aw-yml-p Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `--fix`. -**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--gh-aw-ref`, `--ghes`, `--json/-j`, `--logical-repo/-l`, `--no-check-update`, `--no-emit`, `--no-models-dev-lookup`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--show-all`, `--staged`, `--stats`, `--strict`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--yamllint`, `--zizmor` +**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--gh-aw-ref`, `--ghes`, `--grant`, `--json/-j`, `--logical-repo/-l`, `--no-check-update`, `--no-emit`, `--no-models-dev-lookup`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--show-all`, `--staged`, `--stats`, `--strict`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--yamllint`, `--zizmor` **`--gh-aw-ref` flag:** Convenience alias for `--action-mode release --action-tag `. Accepts a branch name, tag, or commit SHA targeting the `github/gh-aw` repository. Branch and tag names are resolved to their full commit SHA at compile time, so the baked-in reference is immutable and reproducible. Useful for E2E-testing workflows compiled against a specific gh-aw revision. diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 6eec4d6b3e1..66614c65d01 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -27,6 +27,7 @@ type CompileConfig struct { 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 + Grant bool // Run grant license scanner on container images referenced in compiled .lock.yml files Yamllint bool // Run yamllint YAML linter on generated .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 88cac82a708..ed19332f357 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -1,7 +1,8 @@ // This file provides external tool runners for workflow compilation. // // This file contains functions that invoke external analysis tools -// (actionlint, zizmor, poutine, runner-guard, syft, grype, yamllint) +// (actionlint, zizmor, poutine, runner-guard, syft, grype, grant) on compiled workflow files. +// (actionlint, zizmor, poutine, runner-guard, syft, grype, grant, yamllint) // on compiled workflow files. // // # Organization Rationale @@ -19,6 +20,7 @@ // - RunZizmorOnFiles() - Run zizmor on multiple lock files // - RunPoutineOnDirectory() - Run poutine security scanner on a directory // - RunRunnerGuardOnDirectory() - Run runner-guard taint analysis on a directory +// - RunGrantOnLockFiles() - Run grant license scanning on container images // - RunYamllintOnFiles() - Run yamllint YAML linter on multiple lock files package cli @@ -67,6 +69,12 @@ func RunGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { return runBatchLockFileTool("grype", lockFiles, verbose, strict, runGrypeOnLockFiles) } +// RunGrantOnLockFiles runs the grant license scanner on container images extracted +// from the gh-aw-manifest headers in the provided lock files. +func RunGrantOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + return runBatchLockFileTool("grant", lockFiles, verbose, strict, runGrantOnLockFiles) +} + // RunYamllintOnFiles runs yamllint on multiple lock files in a single batch. // This is more efficient than running yamllint once per file. func RunYamllintOnFiles(lockFiles []string, verbose bool, strict bool) error { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 7a6b16c106c..4144dcbcf17 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -66,6 +66,8 @@ func compileSpecificFiles( 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 + var lockFilesForGrant []string // lock files for grant container image license scanning + var strictGrantErr error var lockFilesForYamllint []string // lock files for yamllint YAML linter // Compile each specified file @@ -152,6 +154,12 @@ func compileSpecificFiles( if config.Poutine || config.RunnerGuard { lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) } + if config.Grype { + lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) + } + if config.Grant { + lockFilesForGrant = append(lockFilesForGrant, fileResult.lockFile) + } if config.Syft { lockFilesForSyft = append(lockFilesForSyft, fileResult.lockFile) } @@ -241,6 +249,29 @@ func compileSpecificFiles( } } + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, "grant", 1, []string{err.Error()}) + *validationResults = append(*validationResults, ValidationResult{ + Workflow: "grant", + Valid: false, + Errors: []CompileValidationError{{ + Type: "grant_error", + Message: err.Error(), + }}, + }) + strictGrantErr = err + } + } + } + // Run yamllint on all collected lock files. if config.Yamllint && !config.NoEmit && len(lockFilesForYamllint) > 0 { if err := ctx.Err(); err != nil { @@ -276,6 +307,9 @@ func compileSpecificFiles( // Don't return the detailed error message here since it's already printed in the summary // Returning a simple error prevents duplication in the output if errorCount > 0 { + if strictGrantErr != nil { + return workflowDataList, strictGrantErr + } return workflowDataList, errors.New("compilation failed") } @@ -350,6 +384,8 @@ func compileAllFilesInDirectory( 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 + var lockFilesForGrant []string // lock files for grant container image license scanning + var strictGrantErr error var lockFilesForYamllint []string // lock files for yamllint YAML linter for _, file := range mdFiles { @@ -411,6 +447,9 @@ func compileAllFilesInDirectory( if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } + if config.Grant { + lockFilesForGrant = append(lockFilesForGrant, fileResult.lockFile) + } if config.Yamllint { lockFilesForYamllint = append(lockFilesForYamllint, fileResult.lockFile) } @@ -493,6 +532,29 @@ func compileAllFilesInDirectory( } } + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, "grant", 1, []string{err.Error()}) + *validationResults = append(*validationResults, ValidationResult{ + Workflow: "grant", + Valid: false, + Errors: []CompileValidationError{{ + Type: "grant_error", + Message: err.Error(), + }}, + }) + strictGrantErr = err + } + } + } + // Run batch yamllint if config.Yamllint && !config.NoEmit && len(lockFilesForYamllint) > 0 { if err := ctx.Err(); err != nil { @@ -543,6 +605,9 @@ func compileAllFilesInDirectory( // Return error if any compilations failed if errorCount > 0 { + if strictGrantErr != nil { + return workflowDataList, strictGrantErr + } return workflowDataList, errors.New("compilation failed") } diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index 8da2b9adb8b..0ec6b1d73e5 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -35,6 +35,7 @@ const ( RunnerGuardImage = "ghcr.io/vigilant-llc/runner-guard:latest" SyftImage = "anchore/syft:v1.48.0" GrypeImage = "anchore/grype:latest" + GrantImage = "anchore/grant:latest" YamllintImage = "pipelinecomponents/yamllint:latest" ) @@ -227,9 +228,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, useSyft, useGrype, useYamllint bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useSyft, useGrype, useGrant, useYamllint bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useSyft && !useGrype && !useYamllint { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useSyft && !useGrype && !useGrant && !useYamllint { return nil } @@ -267,6 +268,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useGrant { + tool := "grant" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } if useYamllint { tool := "yamllint" requestedTools = append(requestedTools, tool) @@ -296,6 +302,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {useRunnerGuard, RunnerGuardImage, "runner-guard"}, {useSyft, SyftImage, "syft"}, {useGrype, GrypeImage, "grype"}, + {useGrant, GrantImage, "grant"}, {useYamllint, YamllintImage, "yamllint"}, } diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index d7d9ef7a663..3d4c39f8128 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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, 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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false, false) if err == nil { t.Error("Expected error when image is downloading, got nil") } @@ -110,6 +110,9 @@ func TestDockerImageConstants(t *testing.T) { if GrypeImage == "" { t.Error("GrypeImage constant should not be empty") } + if GrantImage == "" { + t.Error("GrantImage constant should not be empty") + } // Verify they are docker image references expectedImages := map[string]string{ @@ -119,6 +122,7 @@ func TestDockerImageConstants(t *testing.T) { "runner-guard": RunnerGuardImage, "syft": SyftImage, "grype": GrypeImage, + "grant": GrantImage, } for name, image := range expectedImages { @@ -142,7 +146,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -168,7 +172,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -203,7 +207,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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -227,7 +231,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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -534,7 +538,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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -572,7 +576,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -611,7 +615,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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, 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) } @@ -643,7 +647,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -672,7 +676,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, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } diff --git a/pkg/cli/grant.go b/pkg/cli/grant.go new file mode 100644 index 00000000000..b28c28b02cb --- /dev/null +++ b/pkg/cli/grant.go @@ -0,0 +1,263 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/logger" +) + +var grantLog = logger.New("cli:grant") + +const grantPolicyFilename = ".grant.yaml" + +type grantOutput struct { + Tool string `json:"tool"` + Run struct { + Targets []grantTargetResult `json:"targets"` + } `json:"run"` +} + +type grantTargetResult struct { + Source struct { + Ref string `json:"ref"` + } `json:"source"` + Evaluation grantTargetEvaluation `json:"evaluation"` +} + +type grantTargetEvaluation struct { + Status string `json:"status"` + Findings struct { + Packages []grantPackageFinding `json:"packages"` + } `json:"findings"` +} + +type grantPackageFinding struct { + Name string `json:"name"` + Version string `json:"version"` + Decision string `json:"decision"` + Licenses []grantLicenseDetail `json:"licenses"` +} + +type grantLicenseDetail struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// runGrantOnLockFiles extracts container image references from the gh-aw-manifest +// headers in the provided lock files, deduplicates them, and runs the grant +// license scanner on each unique image via Docker. +func runGrantOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + + images := collectContainerImagesFromLockFiles(lockFiles) + if len(images) == 0 { + grantLog.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 grant")) + } + return nil + } + + policyFile, err := grantPolicyFile() + if err != nil { + return err + } + + if len(images) == 1 { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running grant license scanner on 1 container image")) + } else { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage( + fmt.Sprintf("Running grant license scanner on %d container images", len(images)))) + } + + totalFindings := 0 + var scanErrors []string + + for _, img := range images { + imageRef := img.PinnedImage + if imageRef == "" { + imageRef = img.Image + } + displayName := img.Image + if displayName == "" { + displayName = imageRef + } + + output, err := grantRunOnImage(imageRef, policyFile, verbose) + if err != nil { + grantLog.Printf("Grant scan failed for %s: %v", displayName, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", displayName, err)) + continue + } + + count, err := grantDisplayFindings(displayName, output) + if err != nil { + grantLog.Printf("Grant findings invalid for %s: %v", displayName, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", displayName, err)) + continue + } + totalFindings += count + } + + if len(scanErrors) > 0 { + errMsg := fmt.Sprintf("grant 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: grant found %d license policy finding(s) in container images", totalFindings) + } + + return nil +} + +func grantPolicyFile() (string, error) { + repoRoot, err := gitutil.FindGitRoot() + if err != nil { + return "", fmt.Errorf("grant requires a git repository checkout to locate %s: %w", grantPolicyFilename, err) + } + + policyFile := filepath.Join(repoRoot, grantPolicyFilename) + info, err := os.Stat(policyFile) + if err != nil { + return "", fmt.Errorf( + "grant requires %s at the repository root (create it or run compile without --grant): %w", + grantPolicyFilename, + err, + ) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("grant policy file is not a regular file: %s", policyFile) + } + + return policyFile, nil +} + +func grantRunOnImage(imageRef, policyFile string, verbose bool) (*grantOutput, error) { + containerPolicyPath := "/tmp/gh-aw-grant-policy.yaml" + + // #nosec G204 -- imageRef and policyFile are derived from compiled lock files and the + // current repository checkout. exec.Command passes arguments directly without a shell. + cmd := exec.Command( + "docker", + "run", + "--rm", + "-v", policyFile+":"+containerPolicyPath+":ro", + GrantImage, + "--config", containerPolicyPath, + "--output", "json", + "check", + imageRef, + ) + + if verbose { + dockerCmd := fmt.Sprintf("docker run --rm -v %s:%s:ro %s --config %s --output json check %s", + policyFile, containerPolicyPath, GrantImage, containerPolicyPath, imageRef) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run grant directly: "+dockerCmd)) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + + var output grantOutput + if stdout.Len() > 0 && strings.HasPrefix(strings.TrimSpace(stdout.String()), "{") { + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return nil, fmt.Errorf("failed to parse grant JSON output for %s: %w", imageRef, err) + } + } + + if len(output.Run.Targets) == 0 { + if runErr != nil { + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + return nil, fmt.Errorf("grant failed for %s: %s", imageRef, stderrStr) + } + return nil, fmt.Errorf("grant failed for %s: %w", imageRef, runErr) + } + return nil, fmt.Errorf("grant produced no scan targets for %s", imageRef) + } + + for _, target := range output.Run.Targets { + if target.Evaluation.Status == "error" { + targetRef := target.Source.Ref + if targetRef == "" { + targetRef = imageRef + } + return nil, fmt.Errorf("grant reported an evaluation error for %s", targetRef) + } + } + + return &output, nil +} + +func grantDisplayFindings(imageTag string, output *grantOutput) (int, error) { + if output == nil || len(output.Run.Targets) == 0 { + return 0, nil + } + + total := 0 + for _, target := range output.Run.Targets { + for _, pkg := range target.Evaluation.Findings.Packages { + if pkg.Decision != "deny" { + continue + } + + licenses := "no licenses found" + if len(pkg.Licenses) > 0 { + names := make([]string, 0, len(pkg.Licenses)) + for _, license := range pkg.Licenses { + name := license.ID + if name == "" { + name = license.Name + } + if name != "" { + names = append(names, name) + } + } + if len(names) > 0 { + licenses = strings.Join(names, ", ") + } + } + + message := fmt.Sprintf("license policy violation: %s (%s)", grantPackageRef(pkg), licenses) + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: imageTag, + Line: 1, + Column: 1, + }, + Type: "error", + Message: message, + } + fmt.Fprint(os.Stderr, console.FormatError(compilerErr)) + total++ + } + } + + return total, nil +} + +func grantPackageRef(pkg grantPackageFinding) string { + if pkg.Version == "" { + return pkg.Name + } + return pkg.Name + "@" + pkg.Version +} diff --git a/pkg/cli/grant_test.go b/pkg/cli/grant_test.go new file mode 100644 index 00000000000..052a89529c0 --- /dev/null +++ b/pkg/cli/grant_test.go @@ -0,0 +1,75 @@ +//go:build !integration + +package cli + +import ( + "path/filepath" + "testing" +) + +func TestGrantDisplayFindings_NilOutput(t *testing.T) { + count, err := grantDisplayFindings("test-image:latest", nil) + if err != nil { + t.Fatalf("Expected no error for nil output, got: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 findings for nil output, got %d", count) + } +} + +func TestGrantDisplayFindings_WithDeniedPackages(t *testing.T) { + output := &grantOutput{} + output.Run.Targets = []grantTargetResult{ + { + Evaluation: grantTargetEvaluation{ + Status: "noncompliant", + Findings: struct { + Packages []grantPackageFinding `json:"packages"` + }{ + Packages: []grantPackageFinding{ + { + Name: "openssl", + Version: "1.0.0", + Decision: "deny", + Licenses: []grantLicenseDetail{{ID: "GPL-3.0-only"}}, + }, + { + Name: "nolicense", + Decision: "deny", + }, + { + Name: "allowed", + Version: "1.0.0", + Decision: "allow", + }, + }, + }, + }, + }, + } + + count, err := grantDisplayFindings("ubuntu:24.04", output) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if count != 2 { + t.Errorf("Expected 2 denied packages, got %d", count) + } +} + +func TestRunGrantOnLockFiles_NoLockFiles(t *testing.T) { + err := runGrantOnLockFiles([]string{}, false, false) + if err != nil { + t.Errorf("Expected no error for empty lock file list, got: %v", err) + } +} + +func TestGrantPolicyFile(t *testing.T) { + policyFile, err := grantPolicyFile() + if err != nil { + t.Fatalf("Expected grant policy file, got: %v", err) + } + if filepath.Base(policyFile) != grantPolicyFilename { + t.Fatalf("Expected policy file basename %q, got %q", grantPolicyFilename, filepath.Base(policyFile)) + } +} diff --git a/pkg/cli/mcp_server_defaults_test.go b/pkg/cli/mcp_server_defaults_test.go index 3227e2c0f36..9cd4f9f1d16 100644 --- a/pkg/cli/mcp_server_defaults_test.go +++ b/pkg/cli/mcp_server_defaults_test.go @@ -19,6 +19,7 @@ func TestMCPToolElicitationDefaults(t *testing.T) { Zizmor bool `json:"zizmor,omitempty" jsonschema:"Run zizmor security scanner on generated .lock.yml files"` 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"` + Grant bool `json:"grant,omitempty" jsonschema:"Run grant license scanner on container images referenced in compiled .lock.yml files"` Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` } diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index ab3f4079a6f..1e13f8e3a22 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -77,6 +77,7 @@ type compileArgs struct { 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"` + Grant bool `json:"grant,omitempty" jsonschema:"Run grant license scanner on container images referenced in compiled .lock.yml files"` Yamllint bool `json:"yamllint,omitempty" jsonschema:"Run yamllint YAML linter on generated .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."` @@ -141,9 +142,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.Syft || args.Grype || args.Yamllint { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Syft || args.Grype || args.Grant || args.Yamllint { // 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.Syft, args.Grype, args.Yamllint); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype, args.Grant, args.Yamllint); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -156,6 +157,7 @@ Returns JSON array with validation results for each workflow: args.RunnerGuard = false args.Syft = false args.Grype = false + args.Grant = false args.Yamllint = false } else { // Images are still downloading — ask the caller to retry. @@ -213,6 +215,9 @@ Returns JSON array with validation results for each workflow: if args.Grype { cmdArgs = append(cmdArgs, "--grype") } + if args.Grant { + cmdArgs = append(cmdArgs, "--grant") + } if args.Yamllint { cmdArgs = append(cmdArgs, "--yamllint") } @@ -225,8 +230,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, syft=%v, grype=%v, yamllint=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype, args.Yamllint) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, syft=%v, grype=%v, grant=%v, yamllint=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Syft, args.Grype, args.Grant, args.Yamllint) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: