Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion .github/workflows/aw-failure-investigator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/bot-detection.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/daily-squid-image-scan.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/eslint-monster.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/lint-monster.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/poem-bot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/pr-sous-chef.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/smoke-ci.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/sub-issue-closer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/workflow-generator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/workflow-health-manager.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ gh aw compile --dependabot # Generate dependency manifests
gh aw compile --purge # Remove orphaned .lock.yml files
```

For `--zizmor` and `--poutine`, compilation automatically uses a binary on `PATH` when its `--version` is at least 1.30.1 or 1.1.6, respectively. If the binary is missing, too old, or its version cannot be determined, the scanner uses the pinned Docker image instead. Other Docker-based checks still require Docker.

If the repository root contains an [`aw.yml` manifest](/gh-aw/reference/aw-yml-package-manifest/), `gh aw compile` validates it before compiling workflows.

Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `--fix`.
Expand Down
11 changes: 11 additions & 0 deletions pkg/cli/docker_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,17 @@ func CheckAndPrepareDockerImages(ctx context.Context, opts DockerImagesOptions)
return nil
}

// Compatible native scanners do not require Docker or image downloads.
if opts.Zizmor && localScannerPath(ctxutil.OrBackground(ctx), "zizmor") != "" {
opts.Zizmor = false
Comment on lines +270 to +271

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added an isolated PATH to Docker-fallback tests that request zizmor or poutine, preventing host-installed scanners from bypassing the Docker mocks. Commit: 6270c641.

}
if opts.Poutine && localScannerPath(ctxutil.OrBackground(ctx), "poutine") != "" {
opts.Poutine = false
}
if !opts.Zizmor && !opts.Poutine && !opts.Actionlint && !opts.RunnerGuard && !opts.Syft && !opts.Grype && !opts.Grant && !opts.Yamllint {
return nil
}

// Check if Docker daemon is available before attempting any image operations
if !IsDockerAvailable(ctx) {
var requestedTools []string
Expand Down
8 changes: 6 additions & 2 deletions pkg/cli/mcp_tools_readonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,12 @@ Returns JSON array with validation results for each workflow:
// compile without the Docker-based tools and surface a warning so
// the caller knows static analysis was skipped.
dockerUnavailableWarning = err.Error()
args.Zizmor = false
args.Poutine = false
if localScannerPath(ctx, "zizmor") == "" {
args.Zizmor = false
}
if localScannerPath(ctx, "poutine") == "" {
args.Poutine = false
}
args.Actionlint = false
args.RunnerGuard = false
args.Syft = false
Expand Down
123 changes: 44 additions & 79 deletions pkg/cli/poutine.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -113,53 +114,16 @@ func runPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error
return fmt.Errorf("failed to ensure poutine config: %w", err)
}

// Build the Docker command with JSON output for easier parsing
// docker run --rm -v "$(pwd)":/workdir -w /workdir <PoutineImage> analyze_local . --format json
// #nosec G204 -- gitRoot comes from git rev-parse (trusted source) and is validated as absolute path
// exec.Command with separate args (not shell execution) prevents command injection
volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
if err != nil {
return fmt.Errorf("invalid docker mount path: %w", err)
}
poutineImageRef, err := validateDockerImageRef(PoutineImage)
if err != nil {
return fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
}
dockerPath, err := fileutil.ResolveExecutablePath("docker")
cmd, displayArgs, err := buildPoutineCommand(gitRoot)
if err != nil {
return fmt.Errorf("docker command not found: %w", err)
return err
}
cmd := exec.Command(
dockerPath,
"run",
"--rm",
"-v", volumeMount,
"-w", "/workdir",
poutineImageRef,
"analyze_local",
".",
"--format", "json",
"--quiet", // Disable progress output
)

// Always show that poutine is running (regular verbosity)
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running poutine security scanner"))

// In verbose mode, also show the command that users can run directly
if verbose {
dockerCmd := shellJoinArgs([]string{
"docker",
"run",
"--rm",
"-v", volumeMount,
"-w", "/workdir",
poutineImageRef,
"analyze_local",
".",
"--format", "json",
"--quiet",
})
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+dockerCmd))
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+shellJoinArgs(displayArgs)))
}

// Capture output
Expand Down Expand Up @@ -209,6 +173,43 @@ func runPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error
return nil
}

// buildPoutineCommand uses a compatible local binary when available, falling
// back to the pinned Docker image otherwise.
func buildPoutineCommand(gitRoot string) (*exec.Cmd, []string, error) {
args := []string{"analyze_local", ".", "--format", "json", "--quiet"}
if localPath := localScannerPath(context.Background(), "poutine"); localPath != "" {
cmd := exec.Command(localPath, args...)
cmd.Dir = gitRoot
return cmd, append([]string{localPath}, args...), nil
}

volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
if err != nil {
return nil, nil, fmt.Errorf("invalid docker mount path: %w", err)
}
poutineImageRef, err := validateDockerImageRef(PoutineImage)
if err != nil {
return nil, nil, fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
}
dockerPath, err := fileutil.ResolveExecutablePath("docker")
if err != nil {
return nil, nil, fmt.Errorf("docker command not found: %w", err)
}
dockerArgs := []string{
"run",
"--rm",
"-v", volumeMount,
"-w", "/workdir",
poutineImageRef,
}
dockerArgs = append(dockerArgs, args...)
// #nosec G204 -- gitRoot comes from git rev-parse, the mount and image
// are validated above, and exec.Command does not invoke a shell.
cmd := exec.Command(dockerPath, dockerArgs...)
cmd.Dir = gitRoot
return cmd, append([]string{dockerPath}, dockerArgs...), nil
}

// runPoutineOnFile runs the poutine security scanner on a single .lock.yml file using Docker
// This is a wrapper that filters the directory scan results to a single file for backward compatibility
func runPoutineOnFile(lockFile string, verbose bool, strict bool) error {
Expand Down Expand Up @@ -237,53 +238,17 @@ func runPoutineOnFile(lockFile string, verbose bool, strict bool) error {
return fmt.Errorf("failed to get relative path: %w", err)
}

// Build the Docker command with JSON output for easier parsing
// docker run --rm -v "$(pwd)":/workdir -w /workdir <PoutineImage> analyze_local . --format json
// #nosec G204 -- gitRoot comes from git rev-parse (trusted source) and is validated as absolute path
// exec.Command with separate args (not shell execution) prevents command injection
volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
if err != nil {
return fmt.Errorf("invalid docker mount path: %w", err)
}
poutineImageRef, err := validateDockerImageRef(PoutineImage)
if err != nil {
return fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
}
dockerPath, err := fileutil.ResolveExecutablePath("docker")
cmd, displayArgs, err := buildPoutineCommand(gitRoot)
if err != nil {
return fmt.Errorf("docker command not found: %w", err)
return err
}
cmd := exec.Command(
dockerPath,
"run",
"--rm",
"-v", volumeMount,
"-w", "/workdir",
poutineImageRef,
"analyze_local",
".",
"--format", "json",
"--quiet", // Disable progress output
)

// Always show that poutine is running (regular verbosity)
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running poutine security scanner"))

// In verbose mode, also show the command that users can run directly
if verbose {
dockerCmd := shellJoinArgs([]string{
"docker",
"run",
"--rm",
"-v", volumeMount,
"-w", "/workdir",
poutineImageRef,
"analyze_local",
".",
"--format", "json",
"--quiet",
})
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+dockerCmd))
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+shellJoinArgs(displayArgs)))
}

// Capture output
Expand Down
49 changes: 49 additions & 0 deletions pkg/cli/scanner_local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cli

import (
"context"
"os/exec"
"regexp"
"strings"
"time"

"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/semverutil"
)

const (
minZizmorVersion = "1.30.1"
minPoutineVersion = "1.1.6"
)

var scannerVersionPattern = regexp.MustCompile(`(?i)\bv?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9a-z.-]+)?\b`)

// localScannerPath returns a compatible local scanner, or empty when Docker
// should be used instead. An unrecognized version is not assumed compatible.
func localScannerPath(ctx context.Context, name string) string {
var minimum string
switch name {
case "zizmor":
minimum = minZizmorVersion
case "poutine":
minimum = minPoutineVersion
default:
return ""
}

path, err := fileutil.ResolveExecutablePath(name)
if err != nil {
return ""
}
versionCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := exec.CommandContext(versionCtx, path, "--version").CombinedOutput()
Comment thread
Copilot marked this conversation as resolved.
Resolved
if err != nil {
return ""
}
version := scannerVersionPattern.FindString(strings.TrimSpace(string(output)))
if !semverutil.IsValid(version) || semverutil.Compare(version, minimum) < 0 {
return ""
}
return path
}
Loading
Loading