Skip to content

Commit cf30d7f

Browse files
Copilotpelikhan
andauthored
Fix zizmor output: suppress 0 warnings, show details with location, run per-file (#2663)
* Initial plan * Fix zizmor output: skip 0 warnings, show details, run per-file - Skip displaying "🌈 zizmor 0 warnings" for files with 0 warnings - Display detailed findings (severity and type) for each warning - Run zizmor per-file as each workflow compiles instead of all at the end - Update tests to match new behavior Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Run go fmt * Remove unused runZizmor function and console import Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Fix integration test for new zizmor behavior Updated TestCompileWithZizmor to handle the new behavior where workflows with 0 warnings don't display any zizmor output. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add detailed zizmor output with file, line, column, and description - Updated zizmorFinding struct to capture desc, url, annotation, and location info - Modified display logic to show line number, column number, and description - Used console.FormatErrorMessage for consistent error formatting - Updated all tests with enhanced JSON structure and expected output - Line/column numbers are displayed in 1-based indexing for user readability Co-authored-by: pelikhan <4175913+pelikhan@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>
1 parent f206704 commit cf30d7f

5 files changed

Lines changed: 176 additions & 87 deletions

File tree

pkg/cli/add_command.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ func updateWorkflowTitle(content string, number int) string {
551551
func compileWorkflow(filePath string, verbose bool, engineOverride string) error {
552552
// Create compiler and compile the workflow
553553
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
554-
if err := CompileWorkflowWithValidation(compiler, filePath, verbose); err != nil {
554+
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false); err != nil {
555555
return err
556556
}
557557

@@ -607,7 +607,7 @@ func compileWorkflowWithTracking(filePath string, verbose bool, engineOverride s
607607
// Create compiler and set the file tracker
608608
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
609609
compiler.SetFileTracker(tracker)
610-
if err := CompileWorkflowWithValidation(compiler, filePath, verbose); err != nil {
610+
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false); err != nil {
611611
return err
612612
}
613613

pkg/cli/compile_command.go

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
var compileLog = logger.New("cli:compile_command")
2121

2222
// CompileWorkflowWithValidation compiles a workflow with always-on YAML validation for CLI usage
23-
func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool) error {
23+
func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, strict bool) error {
2424
// Compile the workflow first
2525
if err := compiler.CompileWorkflow(filePath); err != nil {
2626
return err
@@ -46,12 +46,19 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string,
4646
return fmt.Errorf("generated lock file is not valid YAML: %w", err)
4747
}
4848

49+
// Run zizmor on the generated lock file if requested
50+
if runZizmorPerFile {
51+
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
52+
return fmt.Errorf("zizmor security scan failed: %w", err)
53+
}
54+
}
55+
4956
return nil
5057
}
5158

5259
// CompileWorkflowDataWithValidation compiles from already-parsed WorkflowData with validation
5360
// This avoids re-parsing when the workflow data has already been parsed
54-
func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool) error {
61+
func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool, runZizmorPerFile bool, strict bool) error {
5562
// Compile the workflow using already-parsed data
5663
if err := compiler.CompileWorkflowData(workflowData, filePath); err != nil {
5764
return err
@@ -77,6 +84,13 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData
7784
return fmt.Errorf("generated lock file is not valid YAML: %w", err)
7885
}
7986

87+
// Run zizmor on the generated lock file if requested
88+
if runZizmorPerFile {
89+
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
90+
return fmt.Errorf("zizmor security scan failed: %w", err)
91+
}
92+
}
93+
8094
return nil
8195
}
8296

@@ -235,7 +249,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
235249
workflowDataList = append(workflowDataList, workflowData)
236250

237251
compileLog.Printf("Starting compilation of %s", resolvedFile)
238-
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose); err != nil {
252+
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose, zizmor && !noEmit, strict); err != nil {
239253
// Always put error on a new line and don't wrap with "failed to compile workflow"
240254
fmt.Fprintln(os.Stderr, err.Error())
241255
errorMessages = append(errorMessages, err.Error())
@@ -303,25 +317,6 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
303317
return workflowDataList, fmt.Errorf("compilation failed")
304318
}
305319

306-
// Run zizmor security scanner if requested and compilation was successful
307-
if zizmor && !noEmit {
308-
// Resolve workflow directory path
309-
if workflowDir == "" {
310-
workflowDir = ".github/workflows"
311-
}
312-
absWorkflowDir := workflowDir
313-
if !filepath.IsAbs(absWorkflowDir) {
314-
gitRoot, err := findGitRoot()
315-
if err == nil {
316-
absWorkflowDir = filepath.Join(gitRoot, workflowDir)
317-
}
318-
}
319-
320-
if err := runZizmor(absWorkflowDir, verbose, strict); err != nil {
321-
return workflowDataList, fmt.Errorf("zizmor security scan failed: %w", err)
322-
}
323-
}
324-
325320
return workflowDataList, nil
326321
}
327322

@@ -395,7 +390,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
395390
}
396391
workflowDataList = append(workflowDataList, workflowData)
397392

398-
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose); err != nil {
393+
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, strict); err != nil {
399394
// Print the error to stderr (errors from CompileWorkflow are already formatted)
400395
fmt.Fprintln(os.Stderr, err.Error())
401396
errorCount++
@@ -482,13 +477,6 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
482477
return workflowDataList, fmt.Errorf("compilation failed")
483478
}
484479

485-
// Run zizmor security scanner if requested and compilation was successful
486-
if zizmor && !noEmit {
487-
if err := runZizmor(workflowsDir, verbose, strict); err != nil {
488-
return workflowDataList, fmt.Errorf("zizmor security scan failed: %w", err)
489-
}
490-
}
491-
492480
return workflowDataList, nil
493481
}
494482

@@ -590,7 +578,7 @@ func watchAndCompileWorkflows(markdownFile string, compiler *workflow.Compiler,
590578
if verbose {
591579
fmt.Fprintf(os.Stderr, "🔨 Initial compilation of %s...\n", markdownFile)
592580
}
593-
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose); err != nil {
581+
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose, false, false); err != nil {
594582
// Always show initial compilation errors on new line without wrapping
595583
fmt.Fprintln(os.Stderr, err.Error())
596584
stats.Errors++
@@ -702,7 +690,7 @@ func compileAllWorkflowFiles(compiler *workflow.Compiler, workflowsDir string, v
702690
if verbose {
703691
fmt.Printf("🔨 Compiling: %s\n", file)
704692
}
705-
if err := CompileWorkflowWithValidation(compiler, file, verbose); err != nil {
693+
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false); err != nil {
706694
// Always show compilation errors on new line
707695
fmt.Fprintln(os.Stderr, err.Error())
708696
stats.Errors++
@@ -759,7 +747,7 @@ func compileModifiedFiles(compiler *workflow.Compiler, files []string, verbose b
759747
fmt.Fprintf(os.Stderr, "🔨 Compiling: %s\n", file)
760748
}
761749

762-
if err := CompileWorkflowWithValidation(compiler, file, verbose); err != nil {
750+
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false); err != nil {
763751
// Always show compilation errors on new line
764752
fmt.Fprintln(os.Stderr, err.Error())
765753
stats.Errors++

pkg/cli/compile_integration_test.go

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -324,26 +324,16 @@ This workflow tests the zizmor security scanner integration.
324324

325325
outputStr := string(output)
326326

327-
// Check that zizmor was run
328-
if !strings.Contains(outputStr, "zizmor") && !strings.Contains(outputStr, "Zizmor") {
329-
t.Errorf("Output should mention zizmor scanner")
330-
}
331-
332-
// Check for the new formatted output: "🌈 zizmor X warnings in <filepath>"
333-
if !strings.Contains(outputStr, "🌈 zizmor") {
334-
t.Errorf("Output should contain formatted zizmor message with rainbow emoji")
335-
}
336-
337-
// Verify the format includes "warnings in" or "warning in"
338-
hasWarningsFormat := strings.Contains(outputStr, "warnings in") || strings.Contains(outputStr, "warning in")
339-
if !hasWarningsFormat {
340-
t.Errorf("Output should contain 'warnings in' or 'warning in' format")
341-
}
327+
// Note: With the new behavior, if there are 0 warnings, no zizmor output is displayed
328+
// The test just verifies that the command succeeds with --zizmor flag
329+
// If there are warnings, they will be shown in the format:
330+
// "🌈 zizmor X warnings in <filepath>"
331+
// - [Severity] finding-type
342332

343333
// The lock file should still exist after zizmor scan
344334
if _, err := os.Stat(lockFilePath); os.IsNotExist(err) {
345335
t.Fatalf("Lock file was removed after zizmor scan")
346336
}
347337

348-
t.Logf("Integration test passed - zizmor flag works correctly with new format")
338+
t.Logf("Integration test passed - zizmor flag works correctly\nOutput: %s", outputStr)
349339
}

pkg/cli/zizmor.go

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
// zizmorFinding represents a single finding from zizmor JSON output
1717
type zizmorFinding struct {
1818
Ident string `json:"ident"`
19+
Desc string `json:"desc"`
20+
URL string `json:"url"`
1921
Determinations struct {
2022
Severity string `json:"severity"`
2123
} `json:"determinations"`
@@ -26,36 +28,37 @@ type zizmorFinding struct {
2628
GivenPath string `json:"given_path"`
2729
} `json:"Local"`
2830
} `json:"key"`
31+
Annotation string `json:"annotation"`
2932
} `json:"symbolic"`
33+
Concrete struct {
34+
Location struct {
35+
StartPoint struct {
36+
Row int `json:"row"`
37+
Column int `json:"column"`
38+
} `json:"start_point"`
39+
} `json:"location"`
40+
} `json:"concrete"`
3041
} `json:"locations"`
3142
}
3243

33-
// runZizmor runs the zizmor security scanner on generated .lock.yml files using Docker
34-
func runZizmor(workflowsDir string, verbose bool, strict bool) error {
35-
compileLog.Print("Running zizmor security scanner")
36-
37-
if verbose {
38-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running zizmor security scanner on generated .lock.yml files..."))
39-
}
44+
// runZizmorOnFile runs the zizmor security scanner on a single .lock.yml file using Docker
45+
func runZizmorOnFile(lockFile string, verbose bool, strict bool) error {
46+
compileLog.Printf("Running zizmor security scanner on %s", lockFile)
4047

4148
// Find git root to get the absolute path for Docker volume mount
4249
gitRoot, err := findGitRoot()
4350
if err != nil {
4451
return fmt.Errorf("failed to find git root: %w", err)
4552
}
4653

47-
// Get the absolute path of the workflows directory
48-
var absWorkflowsDir string
49-
if filepath.IsAbs(workflowsDir) {
50-
absWorkflowsDir = workflowsDir
51-
} else {
52-
absWorkflowsDir = filepath.Join(gitRoot, workflowsDir)
54+
// Get the relative path from git root
55+
relPath, err := filepath.Rel(gitRoot, lockFile)
56+
if err != nil {
57+
return fmt.Errorf("failed to get relative path: %w", err)
5358
}
5459

55-
compileLog.Printf("Running zizmor on directory: %s", absWorkflowsDir)
56-
5760
// Build the Docker command with JSON output for easier parsing
58-
// docker run --rm -v "$(pwd)":/workdir -w /workdir ghcr.io/zizmorcore/zizmor:latest --format json .
61+
// docker run --rm -v "$(pwd)":/workdir -w /workdir ghcr.io/zizmorcore/zizmor:latest --format json <file>
5962
cmd := exec.Command(
6063
"docker",
6164
"run",
@@ -64,7 +67,7 @@ func runZizmor(workflowsDir string, verbose bool, strict bool) error {
6467
"-w", "/workdir",
6568
"ghcr.io/zizmorcore/zizmor:latest",
6669
"--format", "json",
67-
".",
70+
relPath,
6871
)
6972

7073
// Capture output
@@ -102,20 +105,16 @@ func runZizmor(workflowsDir string, verbose bool, strict bool) error {
102105
if exitCode >= 10 && exitCode <= 14 {
103106
// In strict mode, findings are treated as errors
104107
if strict {
105-
return fmt.Errorf("strict mode: zizmor found %d security warnings/errors - workflows must have no zizmor findings in strict mode", totalWarnings)
108+
return fmt.Errorf("strict mode: zizmor found %d security warnings/errors in %s - workflows must have no zizmor findings in strict mode", totalWarnings, filepath.Base(lockFile))
106109
}
107110
// In non-strict mode, findings are logged but not treated as errors
108111
return nil
109112
}
110113
// Other exit codes are actual errors
111-
return fmt.Errorf("zizmor failed with exit code %d", exitCode)
114+
return fmt.Errorf("zizmor failed with exit code %d on %s", exitCode, filepath.Base(lockFile))
112115
}
113116
// Non-ExitError errors (e.g., command not found)
114-
return fmt.Errorf("zizmor failed: %w", err)
115-
}
116-
117-
if verbose {
118-
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Zizmor security scan completed - no findings"))
117+
return fmt.Errorf("zizmor failed on %s: %w", filepath.Base(lockFile), err)
119118
}
120119

121120
return nil
@@ -124,8 +123,8 @@ func runZizmor(workflowsDir string, verbose bool, strict bool) error {
124123
// parseAndDisplayZizmorOutput parses zizmor JSON output and displays it in the desired format
125124
// Returns the total number of warnings found
126125
func parseAndDisplayZizmorOutput(stdout, stderr string, verbose bool) (int, error) {
127-
// Count findings per file
128-
fileFindings := make(map[string]int)
126+
// Map findings to files for detailed display
127+
fileFindings := make(map[string][]zizmorFinding)
129128

130129
// Parse stderr for "completed" messages to get list of files
131130
completedFiles := []string{}
@@ -138,8 +137,10 @@ func parseAndDisplayZizmorOutput(stdout, stderr string, verbose bool) (int, erro
138137
if len(parts) == 2 {
139138
filePath := strings.TrimSpace(parts[1])
140139
completedFiles = append(completedFiles, filePath)
141-
// Initialize count to 0
142-
fileFindings[filePath] = 0
140+
// Initialize empty findings slice
141+
if _, exists := fileFindings[filePath]; !exists {
142+
fileFindings[filePath] = []zizmorFinding{}
143+
}
143144
}
144145
}
145146
}
@@ -152,15 +153,15 @@ func parseAndDisplayZizmorOutput(stdout, stderr string, verbose bool) (int, erro
152153
return 0, fmt.Errorf("failed to parse zizmor JSON output: %w", err)
153154
}
154155

155-
// Count findings per file - each finding counts as 1 regardless of how many locations it has
156+
// Organize findings by file
156157
for _, finding := range findings {
157-
// Track which files this finding affects
158+
// Track which files this finding affects (avoid duplicates)
158159
affectedFiles := make(map[string]bool)
159160
for _, location := range finding.Locations {
160161
filePath := location.Symbolic.Key.Local.GivenPath
161162
if filePath != "" && !affectedFiles[filePath] {
162163
affectedFiles[filePath] = true
163-
fileFindings[filePath]++
164+
fileFindings[filePath] = append(fileFindings[filePath], finding)
164165
totalWarnings++
165166
}
166167
}
@@ -169,13 +170,42 @@ func parseAndDisplayZizmorOutput(stdout, stderr string, verbose bool) (int, erro
169170

170171
// Display reformatted output for each completed file
171172
for _, filePath := range completedFiles {
172-
count := fileFindings[filePath]
173+
findings := fileFindings[filePath]
174+
count := len(findings)
175+
176+
// Skip files with 0 warnings
177+
if count == 0 {
178+
continue
179+
}
180+
173181
// Format: 🌈 zizmor xx warnings in <filepath>
174182
warningText := "warnings"
175183
if count == 1 {
176184
warningText = "warning"
177185
}
178186
fmt.Fprintf(os.Stderr, "🌈 zizmor %d %s in %s\n", count, warningText, filePath)
187+
188+
// Display detailed findings
189+
for _, finding := range findings {
190+
severity := finding.Determinations.Severity
191+
ident := finding.Ident
192+
desc := finding.Desc
193+
194+
// Find the primary location (first location in the list)
195+
var locationInfo string
196+
if len(finding.Locations) > 0 {
197+
loc := finding.Locations[0]
198+
row := loc.Concrete.Location.StartPoint.Row
199+
col := loc.Concrete.Location.StartPoint.Column
200+
// Zizmor uses 0-based indexing, convert to 1-based for user display
201+
if row > 0 || col > 0 {
202+
locationInfo = fmt.Sprintf(" at line %d, column %d", row+1, col+1)
203+
}
204+
}
205+
206+
errorMsg := fmt.Sprintf(" - [%s] %s%s: %s", severity, ident, locationInfo, desc)
207+
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errorMsg))
208+
}
179209
}
180210

181211
return totalWarnings, nil

0 commit comments

Comments
 (0)