From 5a7d7b91f999e20c094f7a86780cfce36447c52e Mon Sep 17 00:00:00 2001 From: harekrishnarai <786hkr@gmail.com> Date: Tue, 9 Jun 2026 19:29:08 +0530 Subject: [PATCH 1/4] fix(report): wrap CLI output to terminal width for readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal CLI report printed descriptions, fix hints, and links as single long lines that ran off the terminal and wrapped raggedly. Rework the renderer to: - wrap description / fix / AI-reasoning / evidence to the terminal width (via terminal.Width(), clamped 60–120) with clean hanging indents - tighten indentation (file at column 0 with a finding count, details at 4) - render the fix with a "→" prefix and aligned continuation lines - truncate over-long offending-code lines instead of overflowing No change to the json/sarif/yaml/markdown formats. --- pkg/report/report.go | 127 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 22 deletions(-) diff --git a/pkg/report/report.go b/pkg/report/report.go index 9c91aa2..70cb17e 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -157,63 +157,146 @@ func (g *Generator) generateCLIReport() error { fmt.Println() for _, path := range order { - bold.Printf(" %s\n", path) - for _, f := range byFile[path] { + findings := byFile[path] + bold.Printf("%s ", path) + dim.Printf("(%d)\n", len(findings)) + for _, f := range findings { g.printFindingCLI(f) } - fmt.Println() } g.printSummaryCLI() return nil } -// printFindingCLI prints a single finding in the minimal CLI style. +// detailIndent is the left padding for a finding's detail lines. +const detailIndent = " " + +// cliWidth returns the wrap width for CLI text, clamped to a readable range. +func (g *Generator) cliWidth() int { + w := 0 + if g.term != nil { + w = g.term.Width() + } + if w <= 0 { + w = 100 // piped / non-interactive + } + if w > 120 { + w = 120 + } + if w < 60 { + w = 60 + } + return w +} + +// printFindingCLI prints a single finding: a header line, then wrapped detail +// lines (description, offending code, link, fix) indented under it. func (g *Generator) printFindingCLI(f rules.Finding) { - loc := "" + textWidth := g.cliWidth() - len(detailIndent) + + // Header: " SEVERITY RULE_ID line N" + header := fmt.Sprintf(" %s %s", severityLabel(f.Severity), color.New(color.Bold).Sprint(f.RuleID)) if f.LineNumber > 0 { - loc = fmt.Sprintf("L%d", f.LineNumber) + header += " " + color.New(color.Faint).Sprintf("line %d", f.LineNumber) } - fmt.Printf(" %s %s %s\n", severityLabel(f.Severity), f.RuleID, color.New(color.Faint).Sprint(loc)) + fmt.Println(header) - if f.Description != "" { - fmt.Printf(" %s\n", f.Description) + // Description (wrapped to terminal width). + for _, line := range wrapLines(f.Description, textWidth) { + fmt.Println(detailIndent + line) } - if line, ok := offendingCodeLine(f); ok { - color.New(color.Faint).Printf(" %d │ %s\n", f.LineNumber, line) + // Offending source line, trimmed so it never overflows. + if code, ok := offendingCodeLine(f); ok { + code = truncate(code, textWidth-8) + color.New(color.Faint).Printf("%s%d │ %s\n", detailIndent, f.LineNumber, code) } - if f.GitHubURL != "" { - color.New(color.Faint, color.Underline).Printf(" %s\n", f.GitHubURL) - } else if f.GitLabURL != "" { - color.New(color.Faint, color.Underline).Printf(" %s\n", f.GitLabURL) + // Link to the exact location (single dim reference line). + if u := f.GitHubURL; u != "" { + color.New(color.Faint).Println(detailIndent + u) + } else if u := f.GitLabURL; u != "" { + color.New(color.Faint).Println(detailIndent + u) } // Compact AI verdict. if f.AIVerified { switch { case f.AIError != "": - color.New(color.FgMagenta).Printf(" AI: analysis failed\n") + color.New(color.FgMagenta).Println(detailIndent + "AI: analysis failed") case f.AILikelyFalsePositive != nil && *f.AILikelyFalsePositive: - color.New(color.FgYellow).Printf(" AI: likely false positive (%.0f%%)\n", f.AIConfidence*100) + color.New(color.FgYellow).Printf("%sAI: likely false positive (%.0f%%)\n", detailIndent, f.AIConfidence*100) case f.AILikelyFalsePositive != nil: - color.New(color.FgRed).Printf(" AI: likely true positive (%.0f%%)\n", f.AIConfidence*100) + color.New(color.FgRed).Printf("%sAI: likely true positive (%.0f%%)\n", detailIndent, f.AIConfidence*100) } if f.AIReasoning != "" && g.Verbose { - fmt.Printf(" AI reasoning: %s\n", f.AIReasoning) + for _, line := range wrapLines("AI reasoning: "+f.AIReasoning, textWidth) { + fmt.Println(detailIndent + line) + } } } else if f.AISkipped && g.Verbose { - color.New(color.Faint).Printf(" AI: skipped (%s)\n", f.AISkipReason) + color.New(color.Faint).Printf("%sAI: skipped (%s)\n", detailIndent, f.AISkipReason) } if g.Verbose && strings.TrimSpace(f.Evidence) != "" { - fmt.Printf(" evidence: %s\n", strings.ReplaceAll(MaskSecrets(f.Evidence), "\n", "\n ")) + for _, line := range wrapLines("evidence: "+MaskSecrets(f.Evidence), textWidth) { + fmt.Println(detailIndent + line) + } } + // Fix (wrapped, cyan, with a hanging indent under the arrow). if f.Remediation != "" { - color.New(color.FgCyan).Printf(" fix: %s\n", f.Remediation) + c := color.New(color.FgCyan) + for i, line := range wrapLines(f.Remediation, textWidth-2) { + if i == 0 { + c.Println(detailIndent + "→ " + line) + } else { + c.Println(detailIndent + " " + line) + } + } + } + + fmt.Println() // separate findings +} + +// truncate shortens s to width characters, adding an ellipsis when cut. +func truncate(s string, width int) string { + if width <= 1 || len(s) <= width { + return s + } + if width <= 3 { + return s[:width] + } + return s[:width-1] + "…" +} + +// wrapLines word-wraps s to width, preserving existing hard line breaks. +func wrapLines(s string, width int) []string { + if strings.TrimSpace(s) == "" { + return nil + } + if width < 20 { + width = 20 + } + var lines []string + for _, paragraph := range strings.Split(s, "\n") { + words := strings.Fields(paragraph) + if len(words) == 0 { + continue + } + cur := words[0] + for _, w := range words[1:] { + if len(cur)+1+len(w) > width { + lines = append(lines, cur) + cur = w + } else { + cur += " " + w + } + } + lines = append(lines, cur) } + return lines } // printSummaryCLI prints the closing one-line severity summary. From 463289c5d0cb356a8ebf490ed5f0bd6e86745213 Mon Sep 17 00:00:00 2001 From: harekrishnarai <786hkr@gmail.com> Date: Tue, 9 Jun 2026 21:39:01 +0530 Subject: [PATCH 2/4] feat(report): world-class CLI output with pinpointed code snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the CLI renderer into a semgrep-class layout: - a colored title and per-file headers with finding counts - each finding shows a severity-colored "❯" marker, the rule, and the line - a multi-line code snippet with a line-number gutter: context lines are dimmed and the offending line is marked "❱" and highlighted - description / fix / AI notes wrap to the terminal width with clean hanging indents; over-long code lines are truncated with an ellipsis - a width-aware summary footer with color-coded counts json/sarif/yaml/markdown output is unchanged. --- pkg/report/report.go | 199 ++++++++++++++++++++++++------------------- 1 file changed, 113 insertions(+), 86 deletions(-) diff --git a/pkg/report/report.go b/pkg/report/report.go index 70cb17e..acee192 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -118,24 +118,26 @@ func (g *Generator) Generate() error { // semgrep / scorecard): a short header, findings grouped by file with the // offending line and a fix hint, and a one-line summary. func (g *Generator) generateCLIReport() error { - bold := color.New(color.Bold) dim := color.New(color.Faint) + // Header. fmt.Println() - header := "Flowlyt scan" + title := color.New(color.Bold, color.FgHiCyan).Sprint("● Flowlyt") if g.Result.Repository != "" { - header += " · " + g.Result.Repository + fmt.Printf("%s %s\n", title, dim.Sprint(g.Result.Repository)) + } else { + fmt.Println(title) } - bold.Println(header) - dim.Printf("%d workflow(s) · %d rules · %s\n", + meta := fmt.Sprintf("%d workflows · %d rules · scanned in %s", g.Result.WorkflowsCount, g.Result.RulesCount, g.Result.Duration.Round(time.Millisecond)) if g.Result.SuppressedCount > 0 { - dim.Printf("%d finding(s) suppressed via reachability analysis\n", g.Result.SuppressedCount) + meta += fmt.Sprintf(" · %d suppressed (reachability)", g.Result.SuppressedCount) } + dim.Println(meta) if len(g.Result.Findings) == 0 { fmt.Println() - color.New(color.FgGreen, color.Bold).Println("✓ No security issues found") + color.New(color.FgGreen, color.Bold).Println(" ✓ No security issues found") fmt.Println() return nil } @@ -155,11 +157,12 @@ func (g *Generator) generateCLIReport() error { } sort.Strings(order) - fmt.Println() + fileStyle := color.New(color.Bold, color.FgCyan) for _, path := range order { findings := byFile[path] - bold.Printf("%s ", path) - dim.Printf("(%d)\n", len(findings)) + fmt.Println() + fmt.Printf("%s %s\n", fileStyle.Sprint(path), dim.Sprintf("%d finding(s)", len(findings))) + fmt.Println() for _, f := range findings { g.printFindingCLI(f) } @@ -169,9 +172,6 @@ func (g *Generator) generateCLIReport() error { return nil } -// detailIndent is the left padding for a finding's detail lines. -const detailIndent = " " - // cliWidth returns the wrap width for CLI text, clamped to a readable range. func (g *Generator) cliWidth() int { w := 0 @@ -190,69 +190,74 @@ func (g *Generator) cliWidth() int { return w } -// printFindingCLI prints a single finding: a header line, then wrapped detail -// lines (description, offending code, link, fix) indented under it. -func (g *Generator) printFindingCLI(f rules.Finding) { - textWidth := g.cliWidth() - len(detailIndent) +// body is the left padding for a finding's detail lines (description, snippet, +// link, fix), indented under the finding header. +const body = " " - // Header: " SEVERITY RULE_ID line N" - header := fmt.Sprintf(" %s %s", severityLabel(f.Severity), color.New(color.Bold).Sprint(f.RuleID)) +// printFindingCLI renders one finding semgrep-style: a severity-marked header, +// a wrapped description, a pinpointed code snippet, the link, and a fix hint. +func (g *Generator) printFindingCLI(f rules.Finding) { + width := g.cliWidth() + textWidth := width - len(body) + sevC := severityColor(f.Severity) + + // Header: " ❯ CRITICAL RULE_ID line 38" + header := fmt.Sprintf(" %s %s %s", + sevC.Sprint("❯"), + sevC.Sprintf("%-8s", strings.ToUpper(string(f.Severity))), + color.New(color.Bold).Sprint(f.RuleID)) if f.LineNumber > 0 { header += " " + color.New(color.Faint).Sprintf("line %d", f.LineNumber) } fmt.Println(header) - // Description (wrapped to terminal width). + // Description (wrapped). for _, line := range wrapLines(f.Description, textWidth) { - fmt.Println(detailIndent + line) + fmt.Println(body + line) } - // Offending source line, trimmed so it never overflows. - if code, ok := offendingCodeLine(f); ok { - code = truncate(code, textWidth-8) - color.New(color.Faint).Printf("%s%d │ %s\n", detailIndent, f.LineNumber, code) - } + // Pinpointed code snippet with a line-number gutter. + g.printSnippet(f) - // Link to the exact location (single dim reference line). - if u := f.GitHubURL; u != "" { - color.New(color.Faint).Println(detailIndent + u) - } else if u := f.GitLabURL; u != "" { - color.New(color.Faint).Println(detailIndent + u) + // Link to the exact location. + if u := findingURL(f); u != "" { + color.New(color.Faint).Printf("%s%s %s\n", body, color.New(color.Faint).Sprint("↳"), u) } // Compact AI verdict. if f.AIVerified { switch { case f.AIError != "": - color.New(color.FgMagenta).Println(detailIndent + "AI: analysis failed") + color.New(color.FgMagenta).Println(body + "AI: analysis failed") case f.AILikelyFalsePositive != nil && *f.AILikelyFalsePositive: - color.New(color.FgYellow).Printf("%sAI: likely false positive (%.0f%%)\n", detailIndent, f.AIConfidence*100) + color.New(color.FgYellow).Printf("%sAI: likely false positive (%.0f%%)\n", body, f.AIConfidence*100) case f.AILikelyFalsePositive != nil: - color.New(color.FgRed).Printf("%sAI: likely true positive (%.0f%%)\n", detailIndent, f.AIConfidence*100) + color.New(color.FgRed).Printf("%sAI: likely true positive (%.0f%%)\n", body, f.AIConfidence*100) } if f.AIReasoning != "" && g.Verbose { for _, line := range wrapLines("AI reasoning: "+f.AIReasoning, textWidth) { - fmt.Println(detailIndent + line) + fmt.Println(body + line) } } } else if f.AISkipped && g.Verbose { - color.New(color.Faint).Printf("%sAI: skipped (%s)\n", detailIndent, f.AISkipReason) + color.New(color.Faint).Printf("%sAI: skipped (%s)\n", body, f.AISkipReason) } if g.Verbose && strings.TrimSpace(f.Evidence) != "" { for _, line := range wrapLines("evidence: "+MaskSecrets(f.Evidence), textWidth) { - fmt.Println(detailIndent + line) + fmt.Println(body + line) } } - // Fix (wrapped, cyan, with a hanging indent under the arrow). + // Fix (wrapped, cyan, with a hanging indent aligned under the text). if f.Remediation != "" { c := color.New(color.FgCyan) - for i, line := range wrapLines(f.Remediation, textWidth-2) { + label := color.New(color.FgCyan, color.Bold).Sprint("fix:") + for i, line := range wrapLines(f.Remediation, textWidth-5) { if i == 0 { - c.Println(detailIndent + "→ " + line) + fmt.Printf("%s%s %s\n", body, label, c.Sprint(line)) } else { - c.Println(detailIndent + " " + line) + c.Printf("%s %s\n", body, line) // align under text after "fix: " } } } @@ -260,6 +265,45 @@ func (g *Generator) printFindingCLI(f rules.Finding) { fmt.Println() // separate findings } +// printSnippet renders the source lines around a finding with a line-number +// gutter, marking and highlighting the offending line. No-op when the source +// cannot be read (e.g. line 0, or a cleaned-up clone). +func (g *Generator) printSnippet(f rules.Finding) { + ctx := buildCodeContext(f.FilePath, f.LineNumber) + if ctx == nil || len(ctx.Lines) == 0 { + return + } + sevC := severityColor(f.Severity) + dim := color.New(color.Faint) + gutter := len(fmt.Sprintf("%d", ctx.EndLine)) + // body + marker(1) + space + gutter + space + "│" + space + codeWidth := g.cliWidth() - len(body) - gutter - 5 + if codeWidth < 20 { + codeWidth = 20 + } + + fmt.Println() + for _, ln := range ctx.Lines { + code := truncate(strings.ReplaceAll(ln.Content, "\t", " "), codeWidth) + num := fmt.Sprintf("%*d", gutter, ln.Line) + if ln.Highlight { + fmt.Printf("%s%s %s %s %s\n", + body, sevC.Sprint("❱"), sevC.Sprint(num), dim.Sprint("│"), color.New(color.Bold).Sprint(code)) + } else { + fmt.Printf("%s %s %s %s\n", body, dim.Sprint(num), dim.Sprint("│"), dim.Sprint(code)) + } + } + fmt.Println() +} + +// findingURL returns the platform link for a finding, if any. +func findingURL(f rules.Finding) string { + if f.GitHubURL != "" { + return f.GitHubURL + } + return f.GitLabURL +} + // truncate shortens s to width characters, adding an ellipsis when cut. func truncate(s string, width int) string { if width <= 1 || len(s) <= width { @@ -299,64 +343,47 @@ func wrapLines(s string, width int) []string { return lines } -// printSummaryCLI prints the closing one-line severity summary. +// printSummaryCLI prints the closing severity summary. func (g *Generator) printSummaryCLI() { s := g.Result.Summary - color.New(color.Faint).Println(strings.Repeat("─", 50)) + color.New(color.Faint).Println(strings.Repeat("─", g.cliWidth())) parts := []string{} - addPart := func(n int, name string, c *color.Color) { + addPart := func(n int, name string, sev rules.Severity) { if n > 0 { - parts = append(parts, c.Sprintf("%d %s", n, name)) + parts = append(parts, severityColor(sev).Sprintf("%d %s", n, name)) } } - addPart(s.Critical, "critical", color.New(color.FgHiRed, color.Bold)) - addPart(s.High, "high", color.New(color.FgRed, color.Bold)) - addPart(s.Medium, "medium", color.New(color.FgYellow, color.Bold)) - addPart(s.Low, "low", color.New(color.FgBlue)) - addPart(s.Info, "info", color.New(color.FgCyan)) + addPart(s.Critical, "critical", rules.Critical) + addPart(s.High, "high", rules.High) + addPart(s.Medium, "medium", rules.Medium) + addPart(s.Low, "low", rules.Low) + addPart(s.Info, "info", rules.Info) - detail := "" + total := color.New(color.Bold).Sprintf("%d finding(s)", s.Total) if len(parts) > 0 { - detail = " (" + strings.Join(parts, ", ") + ")" - } - fmt.Printf("%d finding(s)%s\n\n", s.Total, detail) -} - -// severityLabel renders a fixed-width, color-coded severity label. -func severityLabel(sev rules.Severity) string { - styles := map[rules.Severity]*color.Color{ - rules.Critical: color.New(color.FgHiRed, color.Bold), - rules.High: color.New(color.FgRed, color.Bold), - rules.Medium: color.New(color.FgYellow, color.Bold), - rules.Low: color.New(color.FgBlue, color.Bold), - rules.Info: color.New(color.FgCyan), - } - c, ok := styles[sev] - if !ok { - c = color.New(color.FgWhite) + fmt.Printf("%s %s\n\n", total, strings.Join(parts, color.New(color.Faint).Sprint(" · "))) + } else { + fmt.Printf("%s\n\n", total) } - return c.Sprintf("%-8s", strings.ToUpper(string(sev))) } -// offendingCodeLine returns the source line a finding points at, trimmed. -func offendingCodeLine(f rules.Finding) (string, bool) { - ctx := buildCodeContext(f.FilePath, f.LineNumber) - if ctx == nil { - return "", false - } - for _, ln := range ctx.Lines { - if ln.Highlight { - content := strings.TrimRight(ln.Content, " \t") - // Skip blank lines (e.g. findings about a missing key point at an - // empty line); showing "N │" with no content adds noise. - if strings.TrimSpace(content) == "" { - return "", false - } - return content, true - } +// severityColor returns the color style for a severity level. +func severityColor(sev rules.Severity) *color.Color { + switch sev { + case rules.Critical: + return color.New(color.FgHiRed, color.Bold) + case rules.High: + return color.New(color.FgRed, color.Bold) + case rules.Medium: + return color.New(color.FgYellow, color.Bold) + case rules.Low: + return color.New(color.FgBlue, color.Bold) + case rules.Info: + return color.New(color.FgCyan) + default: + return color.New(color.FgWhite) } - return "", false } // generateJSONReport creates a JSON report From d49b1c06746b31c68024ef9507c3d7774f16d1c1 Mon Sep 17 00:00:00 2001 From: harekrishnarai <786hkr@gmail.com> Date: Tue, 9 Jun 2026 21:46:28 +0530 Subject: [PATCH 3/4] fix(rules): correct IMPOSTOR_COMMIT severity and pinpoint findings - IMPOSTOR_COMMIT: evaluate each line of a run block independently. Previously a benign `git config user.name "github-actions[bot]"` was escalated to CRITICAL whenever the block contained any `${...}` elsewhere, and the finding was mis-located to the first `run:` line. Now the official bot identity is LOW and a variable-based identity is CRITICAL, each pinpointed to its line. - GITHUB_ENV_UNTRUSTED_WRITE: locate the exact offending `>> $GITHUB_ENV` line inside the run block (and use it as evidence) instead of the `run:` block line. --- pkg/rules/injection.go | 29 +++++++-- pkg/rules/supply_chain_checks.go | 101 +++++++++++++++++-------------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/pkg/rules/injection.go b/pkg/rules/injection.go index 0305f3a..51fd9d5 100644 --- a/pkg/rules/injection.go +++ b/pkg/rules/injection.go @@ -79,11 +79,30 @@ func checkGithubEnvUntrustedWrite(workflow parser.WorkflowFile) []Finding { stepName = fmt.Sprintf("Step %d", stepIdx+1) } - pat := linenum.FindPattern{Key: "run", Value: step.Run} - lineResult := lineMapper.FindLineNumber(pat) + // Pinpoint the exact offending line inside the run block (rather than + // the `run:` block-scalar line) for accurate localization + evidence. + offending := "" + for _, raw := range strings.Split(step.Run, "\n") { + l := strings.TrimSpace(raw) + writesLine := strings.Contains(l, "$GITHUB_ENV") && strings.Contains(l, ">>") + if writesLine && (strings.Contains(l, "${{") || ldPreloadRe.MatchString(l)) { + offending = l + break + } + if writesLine && offending == "" { + offending = l // fallback: the write itself, if the expression is on another line + } + } + lineNumber := 0 - if lineResult != nil { - lineNumber = lineResult.LineNumber + evidence := step.Run + if offending != "" { + if res := lineMapper.FindLineNumber(linenum.FindPattern{Value: offending}); res != nil { + lineNumber = res.LineNumber + } + evidence = offending + } else if res := lineMapper.FindLineNumber(linenum.FindPattern{Key: "run", Value: step.Run}); res != nil { + lineNumber = res.LineNumber } findings = append(findings, Finding{ @@ -95,7 +114,7 @@ func checkGithubEnvUntrustedWrite(workflow parser.WorkflowFile) []Finding { FilePath: workflow.Path, JobName: jobName, StepName: stepName, - Evidence: step.Run, + Evidence: evidence, Remediation: "Validate and sanitize all data before writing to $GITHUB_ENV. Never write untrusted ${{ }} expressions directly to $GITHUB_ENV.", LineNumber: lineNumber, }) diff --git a/pkg/rules/supply_chain_checks.go b/pkg/rules/supply_chain_checks.go index 600690a..f405b04 100644 --- a/pkg/rules/supply_chain_checks.go +++ b/pkg/rules/supply_chain_checks.go @@ -419,6 +419,15 @@ func checkRefConfusion(workflow parser.WorkflowFile) []Finding { } // checkImpostorCommit detects commits that may be impersonating legitimate authors +// gitIdentityVarRe matches a git committer identity sourced from a variable +// (e.g. `git config user.name "${{ github.actor }}"`) — the identity is not +// fixed and could be attacker-influenced. +var gitIdentityVarRe = regexp.MustCompile(`(?i)git\s+config\s+.*user\.(name|email).*\$[\{(]`) + +// gitIdentityBotRe matches setting the official github-actions / dependabot bot +// identity, which is the standard, legitimate way for CI to commit. +var gitIdentityBotRe = regexp.MustCompile(`(?i)git\s+config\s+.*user\.(name|email).*(github-actions|dependabot)`) + func checkImpostorCommit(workflow parser.WorkflowFile) []Finding { var findings []Finding lineMapper := linenum.NewLineMapper(workflow.Content) @@ -434,55 +443,55 @@ func checkImpostorCommit(workflow parser.WorkflowFile) []Finding { stepName = fmt.Sprintf("Step %d", stepIdx+1) } - // Check for git config commands that might impersonate others - gitConfigPatterns := []string{ - `git\s+config\s+.*user\.name.*github-actions`, - `git\s+config\s+.*user\.email.*github-actions`, - `git\s+config\s+.*user\.name.*dependabot`, - `git\s+config\s+.*user\.email.*dependabot`, - `git\s+config\s+.*user\.name.*\$\{`, // Variable-based user names - `git\s+config\s+.*user\.email.*\$\{`, // Variable-based emails - } - - for _, pattern := range gitConfigPatterns { - re := regexp.MustCompile(`(?i)` + pattern) - if re.MatchString(step.Run) { - linePattern := linenum.FindPattern{ - Key: "run", - Value: step.Run, - } - lineResult := lineMapper.FindLineNumber(linePattern) - lineNumber := 0 - if lineResult != nil { - lineNumber = lineResult.LineNumber - } + // Evaluate each line of the run block individually so the severity + // reflects the matched line (not unrelated `${...}` elsewhere in the + // block) and the finding points at the exact line. + seen := make(map[int]bool) + for _, raw := range strings.Split(step.Run, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } - severity := High - if strings.Contains(step.Run, "${") { - severity = Critical // Variable-based identity is more dangerous - } else { - // Known official GitHub service bots are legitimate automation. - // Still emit the finding but at LOW so it can be triaged separately. - runLower := strings.ToLower(step.Run) - if knownBotRe.MatchString(runLower) { - severity = Low - } - } + var severity Severity + var desc string + switch { + case gitIdentityVarRe.MatchString(line): + // Committer identity sourced from a variable could be + // attacker-influenced — the genuinely risky case. + severity = Critical + desc = "Git committer identity is set from a variable, which could impersonate another author" + case gitIdentityBotRe.MatchString(line): + // Setting the official github-actions/dependabot bot identity + // is standard, legitimate automation — deprioritised to LOW. + severity = Low + desc = "Workflow sets the github-actions/dependabot bot git identity (standard automation)" + default: + continue + } - findings = append(findings, Finding{ - RuleID: "IMPOSTOR_COMMIT", - RuleName: "Impostor Commit Detection", - Description: "Git configuration may impersonate legitimate authors or services", - Severity: severity, - Category: SupplyChain, - FilePath: workflow.Path, - JobName: jobName, - StepName: stepName, - Evidence: step.Run, - Remediation: "Use official actions for git operations or verify committer identity", - LineNumber: lineNumber, - }) + lineNumber := 0 + if res := lineMapper.FindLineNumber(linenum.FindPattern{Value: line}); res != nil { + lineNumber = res.LineNumber + } + if lineNumber > 0 && seen[lineNumber] { + continue } + seen[lineNumber] = true + + findings = append(findings, Finding{ + RuleID: "IMPOSTOR_COMMIT", + RuleName: "Impostor Commit Detection", + Description: desc, + Severity: severity, + Category: SupplyChain, + FilePath: workflow.Path, + JobName: jobName, + StepName: stepName, + Evidence: line, + Remediation: "Use official actions for git operations or verify the committer identity is not user-controlled", + LineNumber: lineNumber, + }) } } } From 343e1bd9f3944f100e8aa717ad76e91a61efac29 Mon Sep 17 00:00:00 2001 From: harekrishnarai <786hkr@gmail.com> Date: Tue, 9 Jun 2026 21:47:08 +0530 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v2.0.1=20=E2=80=94=20CLI?= =?UTF-8?q?=20output=20overhaul=20and=20accuracy=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 20 ++++++++++++++++++++ pkg/constants/constants.go | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b35bc81..e50f175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [2.0.1] - 2026-06-09 + +### 💅 CLI output + +- Reworked the default terminal report into a polished, semgrep-style layout: + a colored title, per-file headers with finding counts, severity-marked + findings, and a **pinpointed multi-line code snippet** with a line-number + gutter (the offending line is marked and highlighted). Description / fix / + AI notes now wrap to the terminal width with clean hanging indents. + +### 🎯 Accuracy + +- `IMPOSTOR_COMMIT`: a benign `git config user.name "github-actions[bot]"` is no + longer reported as CRITICAL when the surrounding run block happens to contain + `${...}`. Each run-block line is evaluated independently; the official bot + identity is LOW and a variable-based identity is CRITICAL, each pinpointed to + its exact line. +- `GITHUB_ENV_UNTRUSTED_WRITE`: now points at the exact `>> $GITHUB_ENV` line + inside the run block instead of the `run:` block-scalar line. + ## [2.0.0] - 2026-06-09 A major release focused on correctness, finding precision, and a cleaner CLI. diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 58cd053..437cb9e 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -22,7 +22,7 @@ import "os" const ( // Version information AppName = "flowlyt" - AppVersion = "2.0.0" + AppVersion = "2.0.1" AppUsage = "Multi-Platform CI/CD Security Analyzer" // Default configuration values