Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/gh-aw/compile_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
53 changes: 53 additions & 0 deletions docs/adr/47516-add-grant-container-license-scan.md
Original file line number Diff line number Diff line change
@@ -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 <image>`) 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.*
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/compilation-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/gh-aw-as-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <ref>`. 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.

Expand Down
1 change: 1 addition & 0 deletions pkg/cli/compile_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion pkg/cli/compile_external_tools.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions pkg/cli/compile_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}

Expand Down
11 changes: 9 additions & 2 deletions pkg/cli/docker_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"},
}

Expand Down
Loading
Loading