Skip to content

Commit 97b7009

Browse files
Copilotpelikhangh-aw-bot
authored
Use compatible local zizmor and poutine scanners when available (#66918)
* Prefer compatible local zizmor and poutine before Docker Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Apply remaining changes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Fix local scanner version detection and test isolation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent 1e3b581 commit 97b7009

8 files changed

Lines changed: 288 additions & 87 deletions

File tree

‎docs/src/content/docs/setup/cli.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ gh aw compile --dependabot # Generate dependency manifests
339339
gh aw compile --purge # Remove orphaned .lock.yml files
340340
```
341341

342+
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. The version is checked with `zizmor --version` or `poutine version --disable-version-check`. 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.
343+
342344
If the repository root contains an [`aw.yml` manifest](/gh-aw/reference/aw-yml-package-manifest/), `gh aw compile` validates it before compiling workflows.
343345

344346
Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `--fix`.

‎pkg/cli/docker_images.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@ func CheckAndPrepareDockerImages(ctx context.Context, opts DockerImagesOptions)
266266
return nil
267267
}
268268

269+
// Compatible native scanners do not require Docker or image downloads.
270+
if opts.Zizmor && localScannerPath(ctxutil.OrBackground(ctx), "zizmor") != "" {
271+
opts.Zizmor = false
272+
}
273+
if opts.Poutine && localScannerPath(ctxutil.OrBackground(ctx), "poutine") != "" {
274+
opts.Poutine = false
275+
}
276+
if !opts.Zizmor && !opts.Poutine && !opts.Actionlint && !opts.RunnerGuard && !opts.Syft && !opts.Grype && !opts.Grant && !opts.Yamllint {
277+
return nil
278+
}
279+
269280
// Check if Docker daemon is available before attempting any image operations
270281
if !IsDockerAvailable(ctx) {
271282
var requestedTools []string

‎pkg/cli/docker_images_test.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import (
1010
"time"
1111
)
1212

13+
func useDockerFallback(t *testing.T) {
14+
t.Helper()
15+
t.Setenv("PATH", t.TempDir())
16+
}
17+
1318
func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) {
1419
// Reset state before test
1520
ResetDockerPullState()
@@ -22,6 +27,7 @@ func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) {
2227
}
2328

2429
func TestCheckAndPrepareDockerImages_ImageAlreadyDownloading(t *testing.T) {
30+
useDockerFallback(t)
2531
// Reset state before test
2632
ResetDockerPullState()
2733

@@ -138,6 +144,7 @@ func TestDockerImageConstants(t *testing.T) {
138144
}
139145

140146
func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) {
147+
useDockerFallback(t)
141148
// Reset state before test
142149
ResetDockerPullState()
143150

@@ -169,6 +176,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) {
169176
}
170177

171178
func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) {
179+
useDockerFallback(t)
172180
// Reset state before test
173181
ResetDockerPullState()
174182

@@ -203,6 +211,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) {
203211
}
204212

205213
func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) {
214+
useDockerFallback(t)
206215
// Reset state before test
207216
ResetDockerPullState()
208217

@@ -229,6 +238,7 @@ func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) {
229238
}
230239

231240
func TestCheckAndPrepareDockerImages_ImageAlreadyAvailable(t *testing.T) {
241+
useDockerFallback(t)
232242
// Reset state before test
233243
ResetDockerPullState()
234244

@@ -618,6 +628,7 @@ func TestStartDockerImageDownload_NilContext(t *testing.T) {
618628
}
619629

620630
func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) {
631+
useDockerFallback(t)
621632
// Reset state before test
622633
ResetDockerPullState()
623634

@@ -656,6 +667,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) {
656667
}
657668

658669
func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing.T) {
670+
useDockerFallback(t)
659671
// Reset state before test
660672
ResetDockerPullState()
661673

@@ -751,6 +763,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test
751763
}
752764

753765
func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) {
766+
useDockerFallback(t)
754767
// Reset state before test
755768
ResetDockerPullState()
756769

‎pkg/cli/mcp_tools_readonly.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,12 @@ Returns JSON array with validation results for each workflow:
184184
// compile without the Docker-based tools and surface a warning so
185185
// the caller knows static analysis was skipped.
186186
dockerUnavailableWarning = err.Error()
187-
args.Zizmor = false
188-
args.Poutine = false
187+
if localScannerPath(ctx, "zizmor") == "" {
188+
args.Zizmor = false
189+
}
190+
if localScannerPath(ctx, "poutine") == "" {
191+
args.Poutine = false
192+
}
189193
args.Actionlint = false
190194
args.RunnerGuard = false
191195
args.Syft = false

‎pkg/cli/poutine.go‎

Lines changed: 44 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
@@ -113,53 +114,16 @@ func runPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error
113114
return fmt.Errorf("failed to ensure poutine config: %w", err)
114115
}
115116

116-
// Build the Docker command with JSON output for easier parsing
117-
// docker run --rm -v "$(pwd)":/workdir -w /workdir <PoutineImage> analyze_local . --format json
118-
// #nosec G204 -- gitRoot comes from git rev-parse (trusted source) and is validated as absolute path
119-
// exec.Command with separate args (not shell execution) prevents command injection
120-
volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
121-
if err != nil {
122-
return fmt.Errorf("invalid docker mount path: %w", err)
123-
}
124-
poutineImageRef, err := validateDockerImageRef(PoutineImage)
125-
if err != nil {
126-
return fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
127-
}
128-
dockerPath, err := fileutil.ResolveExecutablePath("docker")
117+
cmd, displayArgs, err := buildPoutineCommand(gitRoot)
129118
if err != nil {
130-
return fmt.Errorf("docker command not found: %w", err)
119+
return err
131120
}
132-
cmd := exec.Command(
133-
dockerPath,
134-
"run",
135-
"--rm",
136-
"-v", volumeMount,
137-
"-w", "/workdir",
138-
poutineImageRef,
139-
"analyze_local",
140-
".",
141-
"--format", "json",
142-
"--quiet", // Disable progress output
143-
)
144121

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

148-
// In verbose mode, also show the command that users can run directly
149125
if verbose {
150-
dockerCmd := shellJoinArgs([]string{
151-
"docker",
152-
"run",
153-
"--rm",
154-
"-v", volumeMount,
155-
"-w", "/workdir",
156-
poutineImageRef,
157-
"analyze_local",
158-
".",
159-
"--format", "json",
160-
"--quiet",
161-
})
162-
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+dockerCmd))
126+
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+shellJoinArgs(displayArgs)))
163127
}
164128

165129
// Capture output
@@ -209,6 +173,43 @@ func runPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error
209173
return nil
210174
}
211175

176+
// buildPoutineCommand uses a compatible local binary when available, falling
177+
// back to the pinned Docker image otherwise.
178+
func buildPoutineCommand(gitRoot string) (*exec.Cmd, []string, error) {
179+
args := []string{"analyze_local", ".", "--format", "json", "--quiet"}
180+
if localPath := localScannerPath(context.Background(), "poutine"); localPath != "" {
181+
cmd := exec.Command(localPath, args...)
182+
cmd.Dir = gitRoot
183+
return cmd, append([]string{localPath}, args...), nil
184+
}
185+
186+
volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
187+
if err != nil {
188+
return nil, nil, fmt.Errorf("invalid docker mount path: %w", err)
189+
}
190+
poutineImageRef, err := validateDockerImageRef(PoutineImage)
191+
if err != nil {
192+
return nil, nil, fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
193+
}
194+
dockerPath, err := fileutil.ResolveExecutablePath("docker")
195+
if err != nil {
196+
return nil, nil, fmt.Errorf("docker command not found: %w", err)
197+
}
198+
dockerArgs := []string{
199+
"run",
200+
"--rm",
201+
"-v", volumeMount,
202+
"-w", "/workdir",
203+
poutineImageRef,
204+
}
205+
dockerArgs = append(dockerArgs, args...)
206+
// #nosec G204 -- gitRoot comes from git rev-parse, the mount and image
207+
// are validated above, and exec.Command does not invoke a shell.
208+
cmd := exec.Command(dockerPath, dockerArgs...)
209+
cmd.Dir = gitRoot
210+
return cmd, append([]string{dockerPath}, dockerArgs...), nil
211+
}
212+
212213
// runPoutineOnFile runs the poutine security scanner on a single .lock.yml file using Docker
213214
// This is a wrapper that filters the directory scan results to a single file for backward compatibility
214215
func runPoutineOnFile(lockFile string, verbose bool, strict bool) error {
@@ -237,53 +238,17 @@ func runPoutineOnFile(lockFile string, verbose bool, strict bool) error {
237238
return fmt.Errorf("failed to get relative path: %w", err)
238239
}
239240

240-
// Build the Docker command with JSON output for easier parsing
241-
// docker run --rm -v "$(pwd)":/workdir -w /workdir <PoutineImage> analyze_local . --format json
242-
// #nosec G204 -- gitRoot comes from git rev-parse (trusted source) and is validated as absolute path
243-
// exec.Command with separate args (not shell execution) prevents command injection
244-
volumeMount, err := buildDockerVolumeMount(gitRoot, "/workdir")
245-
if err != nil {
246-
return fmt.Errorf("invalid docker mount path: %w", err)
247-
}
248-
poutineImageRef, err := validateDockerImageRef(PoutineImage)
249-
if err != nil {
250-
return fmt.Errorf("invalid poutine scanner image reference %q: %w", PoutineImage, err)
251-
}
252-
dockerPath, err := fileutil.ResolveExecutablePath("docker")
241+
cmd, displayArgs, err := buildPoutineCommand(gitRoot)
253242
if err != nil {
254-
return fmt.Errorf("docker command not found: %w", err)
243+
return err
255244
}
256-
cmd := exec.Command(
257-
dockerPath,
258-
"run",
259-
"--rm",
260-
"-v", volumeMount,
261-
"-w", "/workdir",
262-
poutineImageRef,
263-
"analyze_local",
264-
".",
265-
"--format", "json",
266-
"--quiet", // Disable progress output
267-
)
268245

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

272249
// In verbose mode, also show the command that users can run directly
273250
if verbose {
274-
dockerCmd := shellJoinArgs([]string{
275-
"docker",
276-
"run",
277-
"--rm",
278-
"-v", volumeMount,
279-
"-w", "/workdir",
280-
poutineImageRef,
281-
"analyze_local",
282-
".",
283-
"--format", "json",
284-
"--quiet",
285-
})
286-
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+dockerCmd))
251+
fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run poutine directly: "+shellJoinArgs(displayArgs)))
287252
}
288253

289254
// Capture output

‎pkg/cli/scanner_local.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"os/exec"
6+
"regexp"
7+
"strings"
8+
"time"
9+
10+
"github.com/github/gh-aw/pkg/fileutil"
11+
"github.com/github/gh-aw/pkg/semverutil"
12+
)
13+
14+
const (
15+
minZizmorVersion = "1.30.1"
16+
minPoutineVersion = "1.1.6"
17+
)
18+
19+
var scannerVersionPattern = regexp.MustCompile(`(?i)\bv?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9a-z.-]+)?\b`)
20+
21+
// localScannerPath returns a compatible local scanner, or empty when Docker
22+
// should be used instead. An unrecognized version is not assumed compatible.
23+
func localScannerPath(ctx context.Context, name string) string {
24+
var minimum string
25+
switch name {
26+
case "zizmor":
27+
minimum = minZizmorVersion
28+
case "poutine":
29+
minimum = minPoutineVersion
30+
default:
31+
return ""
32+
}
33+
34+
path, err := fileutil.ResolveExecutablePath(name)
35+
if err != nil {
36+
return ""
37+
}
38+
versionCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
39+
defer cancel()
40+
versionArgs := []string{"--version"}
41+
if name == "poutine" {
42+
versionArgs = []string{"version", "--disable-version-check"}
43+
}
44+
output, err := exec.CommandContext(versionCtx, path, versionArgs...).CombinedOutput()
45+
if err != nil {
46+
return ""
47+
}
48+
version := scannerVersionPattern.FindString(strings.TrimSpace(string(output)))
49+
if !semverutil.IsValid(version) || semverutil.Compare(version, minimum) < 0 {
50+
return ""
51+
}
52+
return path
53+
}

0 commit comments

Comments
 (0)