From 750bc5213a1a7bc6390c10a83de47d71a6aeda15 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Feb 2026 15:43:47 +0200 Subject: [PATCH 1/2] [PPSC-349] feat(output): add --summary-top flag to display summary before findings Add a new --summary-top flag to the human format output that allows the summary dashboard to be displayed at the top of the output (before findings) instead of at the end. When enabled, the brief status line is also skipped to avoid redundancy. --- internal/cmd/scan.go | 2 ++ internal/cmd/scan_image.go | 7 ++++--- internal/cmd/scan_repo.go | 7 ++++--- internal/output/human.go | 40 ++++++++++++++++++++++++++------------ internal/output/output.go | 7 ++++--- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index efa9894..94aa94b 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -15,6 +15,7 @@ var ( generateVEX bool sbomOutput string vexOutput string + summaryTop bool ) var scanCmd = &cobra.Command{ @@ -34,6 +35,7 @@ func init() { scanCmd.PersistentFlags().BoolVar(&generateVEX, "vex", false, "Generate Vulnerability Exploitability eXchange (VEX) document") scanCmd.PersistentFlags().StringVar(&sbomOutput, "sbom-output", "", "Output file path for SBOM (default: .armis/-sbom.json)") scanCmd.PersistentFlags().StringVar(&vexOutput, "vex-output", "", "Output file path for VEX (default: .armis/-vex.json)") + scanCmd.PersistentFlags().BoolVar(&summaryTop, "summary-top", false, "Display summary at the top of output (before findings)") if rootCmd != nil { rootCmd.AddCommand(scanCmd) } diff --git a/internal/cmd/scan_image.go b/internal/cmd/scan_image.go index 2c75049..e87e422 100644 --- a/internal/cmd/scan_image.go +++ b/internal/cmd/scan_image.go @@ -95,9 +95,10 @@ var scanImageCmd = &cobra.Command{ } opts := output.FormatOptions{ - GroupBy: groupBy, - RepoPath: "", - Debug: debug, + GroupBy: groupBy, + RepoPath: "", + Debug: debug, + SummaryTop: summaryTop, } if err := formatter.FormatWithOptions(result, os.Stdout, opts); err != nil { diff --git a/internal/cmd/scan_repo.go b/internal/cmd/scan_repo.go index 21e42a5..f674242 100644 --- a/internal/cmd/scan_repo.go +++ b/internal/cmd/scan_repo.go @@ -90,9 +90,10 @@ var scanRepoCmd = &cobra.Command{ } opts := output.FormatOptions{ - GroupBy: groupBy, - RepoPath: repoPath, - Debug: debug, + GroupBy: groupBy, + RepoPath: repoPath, + Debug: debug, + SummaryTop: summaryTop, } if err := formatter.FormatWithOptions(result, os.Stdout, opts); err != nil { diff --git a/internal/output/human.go b/internal/output/human.go index bdf8967..a87a619 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -110,12 +110,26 @@ func (f *HumanFormatter) FormatWithOptions(result *model.ScanResult, w io.Writer ew.write("Status: %s\n", result.Status) ew.write("\n") - // 3. Brief status line for immediate orientation - if err := renderBriefStatus(w, result); err != nil { - return err + // 3. Brief status line for immediate orientation (skip if full summary at top) + if !opts.SummaryTop { + if err := renderBriefStatus(w, result); err != nil { + return err + } + } + + // 4. Summary at top if requested + if opts.SummaryTop { + ew.write("\n") + ew.write("───────────────────────────────────────────────────────────────\n") + ew.write(" SUMMARY\n") + ew.write("───────────────────────────────────────────────────────────────\n") + ew.write("\n") + if err := renderSummaryDashboard(w, result); err != nil { + return err + } } - // 4. Findings section + // 5. Findings section if len(result.Findings) > 0 { ew.write("\n") ew.write("───────────────────────────────────────────────────────────────\n") @@ -133,15 +147,17 @@ func (f *HumanFormatter) FormatWithOptions(result *model.ScanResult, w io.Writer } } - // 6. Full detailed summary dashboard at the end - ew.write("───────────────────────────────────────────────────────────────\n") - ew.write(" SUMMARY\n") - ew.write("───────────────────────────────────────────────────────────────\n") - ew.write("\n") - if err := renderSummaryDashboard(w, result); err != nil { - return err + // 6. Full detailed summary dashboard at the end (skip if already shown at top) + if !opts.SummaryTop { + ew.write("───────────────────────────────────────────────────────────────\n") + ew.write(" SUMMARY\n") + ew.write("───────────────────────────────────────────────────────────────\n") + ew.write("\n") + if err := renderSummaryDashboard(w, result); err != nil { + return err + } + ew.write("\n") } - ew.write("\n") ew.write("═══════════════════════════════════════════════════════════════\n") ew.write("\n") diff --git a/internal/output/output.go b/internal/output/output.go index d604b8c..d5600c3 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -17,9 +17,10 @@ var ( // FormatOptions contains options for formatting scan results. type FormatOptions struct { - GroupBy string - RepoPath string - Debug bool + GroupBy string + RepoPath string + Debug bool + SummaryTop bool } // Formatter is the interface for formatting scan results in different output formats. From 18e334e82f95121be05b8207ad645431fe7078bd Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Feb 2026 16:31:27 +0200 Subject: [PATCH 2/2] [PPSC-349] fix(output): add resource limits to loadSnippetFromFile (CWE-770) Add bounds on memory allocation when loading code snippets to prevent potential denial-of-service from maliciously crafted files: - Maximum 10KB per line - Maximum 100KB total snippet size - Graceful truncation when limits are exceeded --- internal/output/human.go | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/output/human.go b/internal/output/human.go index a87a619..0e7b7b4 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -21,6 +21,10 @@ import ( const ( groupBySeverity = "severity" noCWELabel = "No CWE" + + // Resource limits for snippet loading to prevent memory exhaustion (CWE-770) + maxLineLength = 10 * 1024 // 10KB max per line + maxSnippetSize = 100 * 1024 // 100KB max total snippet size ) type errWriter struct { @@ -321,7 +325,11 @@ func loadSnippetFromFile(repoPath string, finding model.Finding) (snippet string contextEnd := end + 4 scanner := bufio.NewScanner(f) + // Set a bounded buffer to prevent memory exhaustion from extremely long lines + scanner.Buffer(make([]byte, 4096), maxLineLength) + var buf []string + var totalSize int lineNum := 0 for scanner.Scan() { lineNum++ @@ -331,10 +339,34 @@ func loadSnippetFromFile(repoPath string, finding model.Finding) (snippet string if lineNum > contextEnd { break } - buf = append(buf, scanner.Text()) + line := scanner.Text() + + // Truncate line if it exceeds max length (shouldn't happen with bounded scanner, + // but provides defense in depth) + if len(line) > maxLineLength { + line = line[:maxLineLength] + "... (truncated)" + } + + // Check total size limit to prevent memory exhaustion + totalSize += len(line) + 1 // +1 for newline + if totalSize > maxSnippetSize { + buf = append(buf, "... (snippet truncated due to size)") + break + } + + buf = append(buf, line) } if err := scanner.Err(); err != nil { - return "", 0, fmt.Errorf("scan file: %w", err) + // Handle bufio.ErrTooLong gracefully - the scanner hit its buffer limit + if err == bufio.ErrTooLong { + if len(buf) > 0 { + buf = append(buf, "... (line too long, truncated)") + } else { + return "", 0, fmt.Errorf("file contains lines exceeding size limit") + } + } else { + return "", 0, fmt.Errorf("scan file: %w", err) + } } if len(buf) == 0 { return "", 0, fmt.Errorf("no lines read")