diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 16cadf6218d..1cc28a4a86c 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -155,6 +155,7 @@ Examples: logicalRepo, _ := cmd.Flags().GetString("logical-repo") dependabot, _ := cmd.Flags().GetBool("dependabot") forceOverwrite, _ := cmd.Flags().GetBool("force") + zizmor, _ := cmd.Flags().GetBool("zizmor") verbose, _ := cmd.Flags().GetBool("verbose") if err := validateEngine(engineOverride); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) @@ -175,6 +176,7 @@ Examples: Strict: strict, Dependabot: dependabot, ForceOverwrite: forceOverwrite, + Zizmor: zizmor, } if _, err := cli.CompileWorkflows(config); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) @@ -282,6 +284,7 @@ func init() { compileCmd.Flags().String("logical-repo", "", "Repository to simulate workflow execution against (for trial mode)") compileCmd.Flags().Bool("dependabot", false, "Generate dependency manifests (package.json, requirements.txt, go.mod) and Dependabot config when dependencies are detected") compileCmd.Flags().Bool("force", false, "Force overwrite of existing files (e.g., dependabot.yml)") + compileCmd.Flags().Bool("zizmor", false, "Run zizmor security scanner on generated .lock.yml files") rootCmd.AddCommand(compileCmd) // Add flags to remove command diff --git a/pkg/cli/compile_command.go b/pkg/cli/compile_command.go index ea0ebcd37e0..4a0d1f9e694 100644 --- a/pkg/cli/compile_command.go +++ b/pkg/cli/compile_command.go @@ -1,9 +1,11 @@ package cli import ( + "bytes" "errors" "fmt" "os" + "os/exec" "os/signal" "path/filepath" "strings" @@ -96,6 +98,7 @@ type CompileConfig struct { Strict bool // Enable strict mode validation Dependabot bool // Generate Dependabot manifests for npm dependencies ForceOverwrite bool // Force overwrite of existing files (dependabot.yml) + Zizmor bool // Run zizmor security scanner on generated .lock.yml files } // CompilationStats tracks the results of workflow compilation @@ -120,8 +123,9 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { strict := config.Strict dependabot := config.Dependabot forceOverwrite := config.ForceOverwrite + zizmor := config.Zizmor - compileLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v, dependabot=%v", len(markdownFiles), validate, watch, noEmit, dependabot) + compileLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v, dependabot=%v, zizmor=%v", len(markdownFiles), validate, watch, noEmit, dependabot, zizmor) // Track compilation statistics stats := &CompilationStats{} @@ -301,6 +305,25 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { return workflowDataList, fmt.Errorf("compilation failed") } + // Run zizmor security scanner if requested and compilation was successful + if zizmor && !noEmit { + // Resolve workflow directory path + if workflowDir == "" { + workflowDir = ".github/workflows" + } + absWorkflowDir := workflowDir + if !filepath.IsAbs(absWorkflowDir) { + gitRoot, err := findGitRoot() + if err == nil { + absWorkflowDir = filepath.Join(gitRoot, workflowDir) + } + } + + if err := runZizmor(absWorkflowDir, verbose); err != nil { + return workflowDataList, fmt.Errorf("zizmor security scan failed: %w", err) + } + } + return workflowDataList, nil } @@ -461,6 +484,13 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) { return workflowDataList, fmt.Errorf("compilation failed") } + // Run zizmor security scanner if requested and compilation was successful + if zizmor && !noEmit { + if err := runZizmor(workflowsDir, verbose); err != nil { + return workflowDataList, fmt.Errorf("zizmor security scan failed: %w", err) + } + } + return workflowDataList, nil } @@ -799,3 +829,87 @@ func printCompilationSummary(stats *CompilationStats) { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(summary)) } } + +// runZizmor runs the zizmor security scanner on generated .lock.yml files using Docker +func runZizmor(workflowsDir string, verbose bool) error { + compileLog.Print("Running zizmor security scanner") + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running zizmor security scanner on generated .lock.yml files...")) + } + + // Find git root to get the absolute path for Docker volume mount + gitRoot, err := findGitRoot() + if err != nil { + return fmt.Errorf("failed to find git root: %w", err) + } + + // Get the absolute path of the workflows directory + var absWorkflowsDir string + if filepath.IsAbs(workflowsDir) { + absWorkflowsDir = workflowsDir + } else { + absWorkflowsDir = filepath.Join(gitRoot, workflowsDir) + } + + compileLog.Printf("Running zizmor on directory: %s", absWorkflowsDir) + + // Build the Docker command + // docker run --rm -v "$(pwd)":/workdir -w /workdir ghcr.io/zizmorcore/zizmor:latest . + cmd := exec.Command( + "docker", + "run", + "--rm", + "-v", fmt.Sprintf("%s:/workdir", gitRoot), + "-w", "/workdir", + "ghcr.io/zizmorcore/zizmor:latest", + ".", + ) + + // Capture output + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Run the command + err = cmd.Run() + + // Always show zizmor output + if stdout.Len() > 0 { + fmt.Fprint(os.Stderr, stdout.String()) + } + if stderr.Len() > 0 { + fmt.Fprint(os.Stderr, stderr.String()) + } + + // Check if the error is due to findings (expected) or actual failure + if err != nil { + // zizmor uses exit codes to indicate findings: + // 0 = no findings + // 10-13 = findings at different severity levels + // 14 = findings with mixed severities + // Other codes = actual errors + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + compileLog.Printf("Zizmor exited with code %d", exitCode) + // Exit codes 10-14 indicate findings, not failures + // Treat these as success but log them + if exitCode >= 10 && exitCode <= 14 { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Zizmor found security findings (see output above)")) + } + return nil + } + // Other exit codes are actual errors + return fmt.Errorf("zizmor failed with exit code %d", exitCode) + } + // Non-ExitError errors (e.g., command not found) + return fmt.Errorf("zizmor failed: %w", err) + } + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Zizmor security scan completed - no findings")) + } + + return nil +} diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go index 1e8f6b1732c..30d62ae8389 100644 --- a/pkg/cli/compile_integration_test.go +++ b/pkg/cli/compile_integration_test.go @@ -255,3 +255,84 @@ Please check the repository for any open issues and create a summary. t.Logf("Integration test passed - successfully compiled workflow to %s", lockFilePath) } + +// TestCompileWithZizmor tests the compile command with --zizmor flag +func TestCompileWithZizmor(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Initialize git repository for zizmor to work (it needs git root) + gitInitCmd := exec.Command("git", "init") + gitInitCmd.Dir = setup.tempDir + if output, err := gitInitCmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to initialize git repository: %v\nOutput: %s", err, string(output)) + } + + // Configure git user for the repository + gitConfigEmail := exec.Command("git", "config", "user.email", "test@test.com") + gitConfigEmail.Dir = setup.tempDir + if output, err := gitConfigEmail.CombinedOutput(); err != nil { + t.Fatalf("Failed to configure git user email: %v\nOutput: %s", err, string(output)) + } + + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = setup.tempDir + if output, err := gitConfigName.CombinedOutput(); err != nil { + t.Fatalf("Failed to configure git user name: %v\nOutput: %s", err, string(output)) + } + + // Create a test markdown workflow file + testWorkflow := `--- +name: Zizmor Test Workflow +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +--- + +# Zizmor Test Workflow + +This workflow tests the zizmor security scanner integration. +` + + testWorkflowPath := filepath.Join(setup.workflowsDir, "zizmor-test.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // First compile without zizmor to create the lock file + compileCmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + if output, err := compileCmd.CombinedOutput(); err != nil { + t.Fatalf("Initial compile failed: %v\nOutput: %s", err, string(output)) + } + + // Check that the lock file was created + lockFilePath := filepath.Join(setup.workflowsDir, "zizmor-test.lock.yml") + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + t.Fatalf("Expected lock file %s was not created", lockFilePath) + } + + // Now compile with --zizmor flag + zizmorCmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath, "--zizmor", "--verbose") + output, err := zizmorCmd.CombinedOutput() + + // The command should succeed even if zizmor finds issues + if err != nil { + t.Fatalf("Compile with --zizmor failed: %v\nOutput: %s", err, string(output)) + } + + outputStr := string(output) + + // Check that zizmor was run + if !strings.Contains(outputStr, "zizmor") && !strings.Contains(outputStr, "Zizmor") { + t.Errorf("Output should mention zizmor scanner") + } + + // The lock file should still exist after zizmor scan + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + t.Fatalf("Lock file was removed after zizmor scan") + } + + t.Logf("Integration test passed - zizmor flag works correctly") +}