Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
258 changes: 184 additions & 74 deletions pkg/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -155,125 +157,233 @@ func (g *Generator) generateCLIReport() error {
}
sort.Strings(order)

fmt.Println()
fileStyle := color.New(color.Bold, color.FgCyan)
for _, path := range order {
bold.Printf(" %s\n", path)
for _, f := range byFile[path] {
findings := byFile[path]
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)
}
fmt.Println()
}

g.printSummaryCLI()
return nil
}

// printFindingCLI prints a single finding in the minimal CLI style.
// 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
}

// body is the left padding for a finding's detail lines (description, snippet,
// link, fix), indented under the finding header.
const body = " "

// 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) {
loc := ""
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 {
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).
for _, line := range wrapLines(f.Description, textWidth) {
fmt.Println(body + line)
}

if line, ok := offendingCodeLine(f); ok {
color.New(color.Faint).Printf(" %d │ %s\n", f.LineNumber, line)
}
// Pinpointed code snippet with a line-number gutter.
g.printSnippet(f)

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.
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).Printf(" AI: analysis failed\n")
color.New(color.FgMagenta).Println(body + "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", body, 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", body, 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(body + 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", body, 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(body + line)
}
}

// Fix (wrapped, cyan, with a hanging indent aligned under the text).
if f.Remediation != "" {
color.New(color.FgCyan).Printf(" fix: %s\n", f.Remediation)
c := color.New(color.FgCyan)
label := color.New(color.FgCyan, color.Bold).Sprint("fix:")
for i, line := range wrapLines(f.Remediation, textWidth-5) {
if i == 0 {
fmt.Printf("%s%s %s\n", body, label, c.Sprint(line))
} else {
c.Printf("%s %s\n", body, line) // align under text after "fix: "
}
}
}

fmt.Println() // separate findings
}

// printSummaryCLI prints the closing one-line severity summary.
func (g *Generator) printSummaryCLI() {
s := g.Result.Summary
color.New(color.Faint).Println(strings.Repeat("─", 50))
// 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
}

parts := []string{}
addPart := func(n int, name string, c *color.Color) {
if n > 0 {
parts = append(parts, c.Sprintf("%d %s", n, name))
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))
}
}
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))
fmt.Println()
}

detail := ""
if len(parts) > 0 {
detail = " (" + strings.Join(parts, ", ") + ")"
// findingURL returns the platform link for a finding, if any.
func findingURL(f rules.Finding) string {
if f.GitHubURL != "" {
return f.GitHubURL
}
fmt.Printf("%d finding(s)%s\n\n", s.Total, detail)
return f.GitLabURL
}

// 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),
// 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
}
c, ok := styles[sev]
if !ok {
c = color.New(color.FgWhite)
if width <= 3 {
return s[:width]
}
return c.Sprintf("%-8s", strings.ToUpper(string(sev)))
return s[:width-1] + "…"
}

// 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
// wrapLines word-wraps s to width, preserving existing hard line breaks.
func wrapLines(s string, width int) []string {
if strings.TrimSpace(s) == "" {
return nil
}
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
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
}
return content, true
}
lines = append(lines, cur)
}
return lines
}

// printSummaryCLI prints the closing severity summary.
func (g *Generator) printSummaryCLI() {
s := g.Result.Summary
color.New(color.Faint).Println(strings.Repeat("─", g.cliWidth()))

parts := []string{}
addPart := func(n int, name string, sev rules.Severity) {
if n > 0 {
parts = append(parts, severityColor(sev).Sprintf("%d %s", n, name))
}
}
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)

total := color.New(color.Bold).Sprintf("%d finding(s)", s.Total)
if len(parts) > 0 {
fmt.Printf("%s %s\n\n", total, strings.Join(parts, color.New(color.Faint).Sprint(" · ")))
} else {
fmt.Printf("%s\n\n", total)
}
}

// 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
Expand Down
Loading
Loading