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
31 changes: 31 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Grype configuration used by `gh aw compile --grype`.
#
# Ignore rules below are documented risk acceptances for findings that have no
# upstream fix available. Each rule is scoped to a specific vulnerability ID,
# package, and version so other builds remain reported. Remove a rule as soon as
# the upstream base image ships a fix; the daily
# `--force-refresh-container-pins` scan picks the fix up automatically.
ignore:
# Debian glibc advisories affecting the Debian base layer of
# ghcr.io/github/github-mcp-server. Debian lists no fixed version for these
# CVEs, so there is nothing to upgrade to; gh-aw only runs this image as an
# MCP server and does not redistribute glibc. Re-evaluate when Debian
# publishes a patched libc6.
- vulnerability: CVE-2026-5450
reason: "Debian lists no fixed libc6 version; risk-accepted until a patched base image ships."
package:
name: libc6
version: 2.36-9+deb12u14
type: deb
- vulnerability: CVE-2026-5928
reason: "Debian lists no fixed libc6 version; risk-accepted until a patched base image ships."
package:
name: libc6
version: 2.36-9+deb12u14
type: deb
- vulnerability: CVE-2026-5435
reason: "Debian lists no fixed libc6 version; risk-accepted until a patched base image ships."
package:
name: libc6
version: 2.36-9+deb12u14
type: deb
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,10 @@ The following licenses are **not allowed** as they conflict with our MIT license

The license policy in `.grant.yaml` is also applied to the container images referenced by compiled workflows (`gh aw compile --grant`). Packages that ship with the upstream base images are listed under `ignore-packages` as a documented exception: the Alpine base OS packages (`busybox`, `apk-tools`, `alpine-baselayout`, `musl-utils`, `git`, `libgcc`, `libstdc++`, and their variants), the Debian base OS packages of the `ghcr.io/github/github-mcp-server` image (`base-files`, `libc6`, `libssl3`, `media-types`, `netbase`, `tzdata`), and the Node.js/npm runtime with npm's bundled dependencies (`node`, `npm`, `tar`, `glob`, `minipass`, and friends). They are executed as part of a third-party image, never linked into or redistributed with gh-aw, and cannot be changed without replacing the upstream image. Every other package in those images is still evaluated against the allowlist above.

### Container Vulnerability Exceptions

`gh aw compile --grype` reads the optional `.grype.yaml` file at the repository root and applies its `ignore` rules. The file is reserved for documented risk acceptances: findings that have no fixed version available upstream, where there is nothing to upgrade to. Each rule is scoped to a specific vulnerability ID, package, and affected version so newly disclosed vulnerabilities and rebuilt packages are still reported, and each carries a `reason` explaining the acceptance. Remove a rule as soon as the upstream image ships a fix — the daily container scan runs `gh aw compile --force-refresh-container-pins` and picks up rebuilt base images automatically. When `.grype.yaml` is absent, grype runs with its defaults.

### Before Adding a Dependency

GitHub Copilot Agent automatically checks licenses when adding dependencies. However, if you're evaluating a dependency:
Expand Down
49 changes: 49 additions & 0 deletions docs/adr/52924-grype-repository-config-for-risk-accepted-cves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ADR-52924: Repository-Level Grype Config for Risk-Accepted CVEs

**Date**: 2026-08-15
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `gh aw compile --grype` scanner gates CI on `[Critical]` vulnerability findings. Three glibc CVEs (CVE-2026-5450, CVE-2026-5928, CVE-2026-5435) are present in the Debian base layer of `ghcr.io/github/github-mcp-server:v1.9.0`. Debian has published no fixed `libc6` version for any of these CVEs, so there is no upgrade path available. The daily container scan was failing consistently with findings that cannot be remediated, blocking the pipeline with no actionable next step. gh-aw only runs this image as an MCP server and does not redistribute glibc.

### Decision

We will add optional repository-level grype configuration support to `pkg/cli/grype.go`. When `.grype.yaml` exists at the repository root it is mounted read-only into the grype scanner container and passed via `--config`, applying its `ignore` rules to the scan. The `.grype.yaml` file is reserved for documented, CVE-scoped risk acceptances where no upstream fix exists, mirroring the existing pattern used by `--grant` with `.grant.yaml`. Each ignore rule is scoped to a specific CVE ID, package, and affected version so newly disclosed vulnerabilities and rebuilt packages remain visible. Rules carry a `reason` field for auditability and must be removed as soon as Debian ships a patched base image.

### Alternatives Considered

#### Alternative 1: Disable the Critical gate for the affected image

Remove or relax the `[Critical]` severity gate for `ghcr.io/github/github-mcp-server` entirely so the scan passes without further changes. This was rejected because it would suppress all future Critical findings in that image, not just the three unfixable CVEs, removing meaningful signal for vulnerabilities that do have fixes available.

#### Alternative 2: Suppress findings via CLI flags at the call site (not checked in)

Pass grype `--ignore-wont-fix` or ad-hoc `--config` flags from the gh-aw CLI invocation code rather than committing a `.grype.yaml` file to the repository. This was rejected because the rules would not be visible to code review, would not be co-located with the codebase they protect, and would make it harder to audit which CVEs are accepted and why. Checked-in ignore rules surface through normal PR review.

#### Alternative 3: Pin to an older image that predates the CVEs

Roll back the pinned digest for `ghcr.io/github/github-mcp-server` to a tag unaffected by these CVEs. This was rejected because the CVEs affect the upstream Debian base layer across all current releases; no available tag is unaffected. Additionally, pinning to an older image would introduce other unpatched vulnerabilities and diverge from the upstream release track.

### Consequences

#### Positive
- Daily container scans pass again without manual intervention once Debian ships patches.
- Each risk acceptance is explicitly documented with a CVE ID, package/version scope, and `reason`, making the security posture auditable via normal code review.
- Newly disclosed `libc6` vulnerabilities and rebuilt packages are still reported because rules are scoped to specific CVE IDs and versions, not to the package as a whole.
- The approach is consistent with the existing `.grant.yaml` pattern already used for license policy, reducing cognitive overhead.

#### Negative
- The three accepted CVEs are suppressed from scan output, reducing the visible finding count. Reviewers must consult `.grype.yaml` to understand the full risk picture.
- Ignore rules require discipline to remove: if Debian ships a fix and nobody cleans up the rule, the patched CVE continues to be silenced. The daily `--force-refresh-container-pins` scan mitigates this but does not enforce rule removal.

#### Neutral
- Docker arg construction was extracted into a testable `grypeDockerArgs` helper; the change is a refactor with no behaviour change when `.grype.yaml` is absent.
- The feature is opt-in: repositories without `.grype.yaml` are unaffected.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 1 addition & 1 deletion docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `-

**Security and Compliance Scanners:**
- **`--syft`:** Generates a Software Bill of Materials (SBOM) for container images referenced in compiled workflows using the Syft scanner.
- **`--grype`:** Scans container images referenced in compiled workflows for known vulnerabilities using the Grype vulnerability scanner.
- **`--grype`:** Scans container images referenced in compiled workflows for known vulnerabilities using the Grype vulnerability scanner. When a `.grype.yaml` file exists at the repository root it is mounted into the scanner and passed to grype via `--config`, so repository-level ignore rules (documented risk acceptances for findings with no upstream fix) are applied.
- **`--runner-guard`:** Runs taint analysis on compiled workflows to detect unsafe data flows from untrusted inputs to sensitive runner operations.

**Shared Workflows:** Workflows without an `on` field are detected as shared components. Validated with relaxed schema and skip compilation. See [Imports reference](/gh-aw/reference/imports/).
Expand Down
123 changes: 104 additions & 19 deletions pkg/cli/grype.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,33 @@ package cli

import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
)

var grypeLog = logger.New("cli:grype")

const (
// grypeConfigFilename is the optional grype configuration file at the repository
// root. It carries documented, risk-accepted ignore rules for vulnerabilities that
// have no upstream fix available.
grypeConfigFilename = ".grype.yaml"
grypeContainerConfigPath = "/tmp/gh-aw-grype-config.yaml"
)

// grypeFinding represents a single vulnerability match from grype JSON output.
type grypeFinding struct {
Vulnerability struct {
Expand All @@ -61,8 +72,8 @@ type grypeOutput struct {
Matches []grypeFinding `json:"matches"`
}

// grypeCache caches grype scan results by image reference to avoid rescanning
// the same image within a single compile run.
// grypeCache caches grype scan results by image reference and config content to
// avoid rescanning identical scans within a single compile run.
type grypeCache struct {
mu sync.Mutex
results map[string]*grypeOutput
Expand Down Expand Up @@ -96,7 +107,8 @@ func (c *grypeCache) setError(key string, err error) {
c.errors[key] = err
}

// grypeScanResultCache is the process-wide grype result cache.
// grypeScanResultCache is the process-wide grype result cache. Its keys include
// the image reference and the grype configuration content.
var grypeScanResultCache = &grypeCache{
results: make(map[string]*grypeOutput),
errors: make(map[string]error),
Expand Down Expand Up @@ -180,14 +192,19 @@ func runGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error {
totalFindings := 0
var scanErrors []string

configFile := grypeConfigFile()
if configFile != "" {
grypeLog.Printf("Using grype config %s", configFile)
}

for _, img := range images {
// Prefer the pinned reference (image@sha256:...) for immutability guarantees.
imageRef := img.PinnedImage
if imageRef == "" {
imageRef = img.Image
}

output, err := grypeRunOnImage(imageRef, verbose)
output, err := grypeRunOnImage(imageRef, configFile, verbose)
if err != nil {
grypeLog.Printf("Grype scan failed for %s: %v", img.Image, err)
scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", img.Image, err))
Expand All @@ -214,11 +231,81 @@ func runGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error {
return nil
}

// grypeConfigFile returns the path to the optional repository-root grype configuration
// file, or an empty string when the file is absent or the current directory is not a
// git checkout. The config carries documented ignore rules for risk-accepted findings.
func grypeConfigFile() string {
repoRoot, err := gitutil.FindGitRoot()
if err != nil {
grypeLog.Printf("Skipping grype config lookup: %v", err)
return ""
}

configFile := filepath.Join(repoRoot, grypeConfigFilename)
info, err := os.Stat(configFile)
if err != nil || !info.Mode().IsRegular() {
grypeLog.Printf("No grype config found at %s", configFile)
return ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] validateContainerMountPath is called on grypeContainerConfigPath (a compile-time constant) on every invocation. This validation can never fail and adds dead-error-path noise. Consider validating it once (e.g., in a TestMain or init guard) or asserting it at compile time with a constant-fold check.

💡 Detail

grypeContainerConfigPath = "/tmp/gh-aw-grype-config.yaml" is a fixed constant that cannot change at runtime. validateContainerMountPath will always succeed for it, so the error branch at line 262 can never be reached. Either remove the runtime call and use the constant directly (trusting the existing test TestGrypeDockerArgs_WithConfig), or add a package-level init() assertion:

func init() {
    if _, err := validateContainerMountPath(grypeContainerConfigPath); err != nil {
        panic("invalid grypeContainerConfigPath constant: " + err.Error())
    }
}

@copilot please address this.

}

return configFile
}

// grypeDockerArgs builds the `docker run` arguments used to scan a single image.
// When configFile is non-empty, it is mounted read-only into the scanner container
// and passed to grype via --config so repository-level ignore rules are applied.
func grypeDockerArgs(validatedImageRef, configFile string) ([]string, error) {
args := []string{"run", "--rm"}

var configArgs []string
if configFile != "" {
containerConfigPath, err := validateContainerMountPath(grypeContainerConfigPath)
if err != nil {
return nil, fmt.Errorf("invalid grype container config path %q: %w", grypeContainerConfigPath, err)
}
volumeMount, err := buildDockerReadonlyFileMount(configFile, containerConfigPath)
if err != nil {
return nil, fmt.Errorf("invalid grype config mount: %w", err)
}
args = append(args, "-v", volumeMount)
configArgs = []string{"--config", containerConfigPath}
}

args = append(args, GrypeImage)
args = append(args, configArgs...)
args = append(args, validatedImageRef, "-o", "json")

return args, nil
}

// grypeCacheKey returns a cache key that varies with the image and the contents
// of the optional Grype configuration. Configuration paths alone are insufficient
// because separate repositories can use different policies at the same path.
func grypeCacheKey(imageRef, configFile string) (string, error) {
if configFile == "" {
return imageRef + "\x00", nil
}

content, err := os.ReadFile(configFile)
if err != nil {
return "", fmt.Errorf("read grype config %q: %w", configFile, err)
}
digest := sha256.Sum256(content)
return fmt.Sprintf("%s\x00%x", imageRef, digest), nil
}

// grypeRunOnImage runs grype on a single container image reference via Docker,
// using the result cache to avoid re-scanning images already checked in this run.
func grypeRunOnImage(imageRef string, verbose bool) (*grypeOutput, error) {
// When configFile is non-empty it is mounted read-only into the scanner container
// and passed to grype via --config.
func grypeRunOnImage(imageRef, configFile string, verbose bool) (*grypeOutput, error) {
cacheKey, err := grypeCacheKey(imageRef, configFile)
if err != nil {
return nil, err
}

// Check cache first.
if result, err, ok := grypeScanResultCache.get(imageRef); ok {
if result, err, ok := grypeScanResultCache.get(cacheKey); ok {
grypeLog.Printf("Grype cache hit for %s", imageRef)
return result, err
}
Expand All @@ -238,20 +325,18 @@ func grypeRunOnImage(imageRef string, verbose bool) (*grypeOutput, error) {
return nil, fmt.Errorf("docker command not found: %w", err)
}

dockerArgs, err := grypeDockerArgs(validatedImageRef, configFile)
if err != nil {
return nil, err
}

// #nosec G204 -- dockerPath is resolved from the fixed executable name "docker" and
// validatedImageRef is allow-list validated above. exec.Command passes args directly to
// the OS without shell interpretation, preventing command injection.
cmd := exec.Command(
dockerPath,
"run",
"--rm",
GrypeImage,
validatedImageRef,
"-o", "json",
)
cmd := exec.Command(dockerPath, dockerArgs...)

if verbose {
dockerCmd := shellJoinArgs([]string{"docker", "run", "--rm", GrypeImage, validatedImageRef, "-o", "json"})
dockerCmd := shellJoinArgs(append([]string{"docker"}, dockerArgs...))
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run grype directly: "+dockerCmd))
}

Expand All @@ -274,7 +359,7 @@ func grypeRunOnImage(imageRef string, verbose bool) (*grypeOutput, error) {
if !errors.As(runErr, &exitErr) {
// Command could not be started (e.g., Docker not found).
scanErr := fmt.Errorf("grype failed: %w", runErr)
grypeScanResultCache.setError(imageRef, scanErr)
grypeScanResultCache.setError(cacheKey, scanErr)
return nil, scanErr
}
exitCode := exitErr.ExitCode()
Expand All @@ -286,19 +371,19 @@ func grypeRunOnImage(imageRef string, verbose bool) (*grypeOutput, error) {
grypeLog.Printf("grype stderr for %s: %s", imageRef, stderrStr)
}
scanErr := fmt.Errorf("grype failed with exit code %d on %s", exitCode, imageRef)
grypeScanResultCache.setError(imageRef, scanErr)
grypeScanResultCache.setError(cacheKey, scanErr)
return nil, scanErr
}
// Exit code 1 with JSON output — vulnerability findings were returned normally.
}

if parseErr != nil {
scanErr := fmt.Errorf("failed to parse grype JSON output for %s: %w", imageRef, parseErr)
grypeScanResultCache.setError(imageRef, scanErr)
grypeScanResultCache.setError(cacheKey, scanErr)
return nil, scanErr
}

grypeScanResultCache.set(imageRef, &output)
grypeScanResultCache.set(cacheKey, &output)
return &output, nil
}

Expand Down
Loading
Loading