mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
Phase 1 (#33)
* chore: Exclude CLAUDE.md from Cargo.toml * feat: add callgraph module and integrate into main analysis flow * feat: enhance CLI with new severity filtering and analysis modes * feat: update CHANGELOG with recent enhancements and fixes to severity filtering and output handling * feat: implement state-model dataflow analysis for resource lifecycle and auth state * feat: enhance diagnostic output formatting and add evidence structure * feat: implement attack surface ranking for diagnostics with scoring and sorting * feat: add comprehensive documentation for installation, usage, and rules reference * feat: add multiple language support for command execution and evaluation endpoints * feat: implement inline suppression for findings using `nyx:ignore` comments * feat: add confidence levels to AST patterns and update output structure * feat: implement low-noise prioritization system with category filtering, rollup grouping, and configurable budgets * feat: bump version to 0.4.0 and update changelog with new features and improvements * feat: add dead code allowances to various functions in mod.rs and real_world_tests.rs
This commit is contained in:
parent
19b578c5c4
commit
1bbe4b1cfb
456 changed files with 25628 additions and 1228 deletions
984
src/fmt.rs
Normal file
984
src/fmt.rs
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
//! Console output formatting for scan diagnostics.
|
||||
//!
|
||||
//! Produces professional, security-tool-grade aligned output with a clear
|
||||
//! severity hierarchy, normalised taint flow rendering, and stable wrapping.
|
||||
|
||||
use crate::commands::scan::{Diag, SuppressionStats};
|
||||
use crate::patterns::Severity;
|
||||
use console::style;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Default maximum line width when terminal size is unknown.
|
||||
const DEFAULT_WIDTH: usize = 100;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render all diagnostics as grouped, formatted console output with a summary.
|
||||
pub fn render_console(
|
||||
diags: &[Diag],
|
||||
project_name: &str,
|
||||
suppression_stats: Option<&SuppressionStats>,
|
||||
) -> String {
|
||||
let width = terminal_width();
|
||||
let mut out = String::new();
|
||||
|
||||
let mut grouped: BTreeMap<&str, Vec<&Diag>> = BTreeMap::new();
|
||||
for d in diags {
|
||||
grouped.entry(&d.path).or_default().push(d);
|
||||
}
|
||||
|
||||
for (path, issues) in &grouped {
|
||||
// File path header — dim blue, never brighter than severity.
|
||||
out.push_str(&format!("{}\n", style(path).blue().dim().underlined()));
|
||||
for d in issues {
|
||||
out.push_str(&render_diag(d, width));
|
||||
out.push('\n'); // blank line between findings
|
||||
}
|
||||
}
|
||||
|
||||
let suppressed_count = diags.iter().filter(|d| d.suppressed).count();
|
||||
let active_count = diags.len() - suppressed_count;
|
||||
|
||||
if suppressed_count > 0 {
|
||||
out.push_str(&format!(
|
||||
"{} '{}' generated {} {} ({} suppressed).\n\n",
|
||||
style("warning").yellow().bold(),
|
||||
style(project_name).white().bold(),
|
||||
style(active_count).bold(),
|
||||
if active_count == 1 { "issue" } else { "issues" },
|
||||
suppressed_count,
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"{} '{}' generated {} {}.\n\n",
|
||||
style("warning").yellow().bold(),
|
||||
style(project_name).white().bold(),
|
||||
style(diags.len()).bold(),
|
||||
if diags.len() == 1 { "issue" } else { "issues" },
|
||||
));
|
||||
}
|
||||
|
||||
// ── Suppression footer ─────────────────────────────────────────────
|
||||
if let Some(stats) = suppression_stats {
|
||||
let total = stats.total_suppressed();
|
||||
if total > 0 {
|
||||
out.push_str(&format!(
|
||||
"{}\n",
|
||||
style(format!("Suppressed {total} LOW/Quality findings.")).dim()
|
||||
));
|
||||
out.push_str(&format!("{}\n", style("Active filters:").dim()));
|
||||
if !stats.include_quality {
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
style("include_quality =").dim(),
|
||||
style("false").dim()
|
||||
));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
style("max_low =").dim(),
|
||||
style(stats.max_low).dim()
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
style("max_low_per_file =").dim(),
|
||||
style(stats.max_low_per_file).dim()
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
style("max_low_per_rule =").dim(),
|
||||
style(stats.max_low_per_rule).dim()
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"\n{}\n",
|
||||
style("Use --include-quality, --max-low, or --all to adjust.").dim()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Normalise a code snippet for display: collapse whitespace, join lines,
|
||||
/// clean up method-chain spacing, trim, and truncate.
|
||||
pub fn normalize_snippet(s: &str) -> String {
|
||||
// Strip newlines/carriage returns with no replacement, then collapse
|
||||
// runs of spaces into a single space.
|
||||
let no_newlines: String = s.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
let collapsed: String = no_newlines.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// Clean up `) .foo(` → `).foo(` and similar spacing around dots in chains.
|
||||
let cleaned = collapse_chain_spacing(&collapsed);
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.len() > 120 {
|
||||
format!("{}…", &trimmed[..120])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate method chains: keep constructor + first balanced `(...)`, then `…`.
|
||||
///
|
||||
/// E.g. `Command::new("sh").arg("-c").arg(&cmd)` → `Command::new("sh")…`
|
||||
#[allow(dead_code)] // public API, used by consumers
|
||||
pub fn shorten_callee(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let Some(open) = s.find('(') else {
|
||||
return s.to_string();
|
||||
};
|
||||
|
||||
let mut depth = 0u32;
|
||||
let mut close = None;
|
||||
for (i, ch) in s[open..].char_indices() {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = Some(open + i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(close_idx) = close else {
|
||||
return s.to_string();
|
||||
};
|
||||
|
||||
let end = close_idx + 1;
|
||||
if end < s.len() {
|
||||
format!("{}…", &s[..end])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Internal rendering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Indentation for body/evidence lines (spaces).
|
||||
const BODY_INDENT: usize = 6;
|
||||
|
||||
/// Render a single diagnostic block.
|
||||
fn render_diag(d: &Diag, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
// ── Header line ──────────────────────────────────────────────────────
|
||||
// Format: ` 98:5 ⚠ [MEDIUM] taint-unsanitised-flow (Score: 87, Confidence: Medium)`
|
||||
let loc = format!("{}:{}", d.line, d.col);
|
||||
let sev = if d.suppressed {
|
||||
format!("{} {}", style("○").dim(), style("[SUPPRESSED]").dim(),)
|
||||
} else {
|
||||
severity_tag(d.severity)
|
||||
};
|
||||
let meta_suffix = match (d.rank_score, d.confidence) {
|
||||
(Some(s), Some(c)) => format!(
|
||||
" {}",
|
||||
style(format!("(Score: {}, Confidence: {c})", s as u32)).dim()
|
||||
),
|
||||
(Some(s), None) => format!(" {}", style(format!("(Score: {})", s as u32)).dim()),
|
||||
(None, Some(c)) => format!(" {}", style(format!("(Confidence: {c})")).dim()),
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" {} {} {}{}\n",
|
||||
style(&loc).dim(),
|
||||
sev,
|
||||
style(&d.id).dim(),
|
||||
meta_suffix,
|
||||
));
|
||||
|
||||
// ── Rollup body ─────────────────────────────────────────────────────
|
||||
let indent_str = " ".repeat(BODY_INDENT);
|
||||
if let Some(ref rollup) = d.rollup {
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{} ({} occurrences)\n",
|
||||
style(&d.id).dim(),
|
||||
rollup.count
|
||||
));
|
||||
if !rollup.occurrences.is_empty() {
|
||||
let examples: Vec<String> = rollup
|
||||
.occurrences
|
||||
.iter()
|
||||
.map(|loc| format!("{}:{}", loc.line, loc.col))
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{} {}\n",
|
||||
style("Examples:").dim(),
|
||||
style(examples.join(", ")).dim()
|
||||
));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{}\n",
|
||||
style(format!("Run: nyx scan --show-instances {}", d.id)).dim()
|
||||
));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Message body ─────────────────────────────────────────────────────
|
||||
if let Some(msg) = &d.message {
|
||||
let capitalized = capitalize_first(msg);
|
||||
let wrapped = wrap_text(&capitalized, width, BODY_INDENT);
|
||||
out.push_str(&format!("{indent_str}{wrapped}\n"));
|
||||
}
|
||||
|
||||
// ── Evidence labels (Source, Sink, Path guard) ───────────────────────
|
||||
if !d.labels.is_empty() {
|
||||
out.push('\n');
|
||||
let max_label = d.labels.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||
let key_width = max_label + 1; // +1 for ':'
|
||||
for (label, value) in &d.labels {
|
||||
let key_str = format!("{label}:");
|
||||
let value_indent = BODY_INDENT + key_width + 1; // key + space
|
||||
let wrapped_val = wrap_text(value, width, value_indent);
|
||||
if label == "Path guard" {
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{:<kw$} {}\n",
|
||||
style(&key_str).dim(),
|
||||
style(&wrapped_val).cyan(),
|
||||
kw = key_width,
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{:<kw$} {}\n",
|
||||
style(&key_str).dim(),
|
||||
wrapped_val,
|
||||
kw = key_width,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(guard) = &d.guard_kind {
|
||||
out.push_str(&format!(
|
||||
"{indent_str}{} {}\n",
|
||||
style("Path guard:").dim(),
|
||||
style(guard).cyan(),
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Colored severity tag with icon. The tag is the visual anchor of each finding.
|
||||
///
|
||||
/// - HIGH: bold red
|
||||
/// - MEDIUM: bold 208 (orange) — distinct from yellow
|
||||
/// - LOW: dim 67 (muted blue-gray)
|
||||
fn severity_tag(sev: Severity) -> String {
|
||||
match sev {
|
||||
Severity::High => format!(
|
||||
"{} [{}]",
|
||||
style("✖").red().bold(),
|
||||
style("HIGH").red().bold(),
|
||||
),
|
||||
Severity::Medium => format!(
|
||||
"{} [{}]",
|
||||
style("⚠").color256(208).bold(),
|
||||
style("MEDIUM").color256(208).bold(),
|
||||
),
|
||||
Severity::Low => format!(
|
||||
"{} [{}]",
|
||||
style("●").color256(67),
|
||||
style("LOW").color256(67),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Text utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Collapse spacing artefacts in method chains.
|
||||
///
|
||||
/// - `") .foo("` → `").foo("` (space between `)` and `.`)
|
||||
/// - Multiple spaces → single space
|
||||
fn collapse_chain_spacing(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
// Pattern: `)` followed by whitespace then `.`
|
||||
if chars[i] == ')' {
|
||||
out.push(')');
|
||||
i += 1;
|
||||
// Skip whitespace between `)` and `.`
|
||||
let ws_start = i;
|
||||
while i < len && chars[i] == ' ' {
|
||||
i += 1;
|
||||
}
|
||||
if i < len && chars[i] == '.' {
|
||||
// Collapse: emit `.` directly after `)`
|
||||
continue;
|
||||
} else {
|
||||
// Not a chain continuation — emit the whitespace we skipped
|
||||
for c in &chars[ws_start..i] {
|
||||
out.push(*c);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Word-wrap text to fit within `max_width`, with continuation lines indented
|
||||
/// to `indent` spaces. The first line is NOT indented (caller handles that).
|
||||
fn wrap_text(text: &str, max_width: usize, indent: usize) -> String {
|
||||
let available_first = max_width.saturating_sub(indent);
|
||||
let available_cont = max_width.saturating_sub(indent);
|
||||
if available_first == 0 || text.len() <= available_first {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let indent_str = " ".repeat(indent);
|
||||
let mut result = String::new();
|
||||
let mut line_len = 0usize;
|
||||
let mut first_line = true;
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
let wlen = word.len();
|
||||
let avail = if first_line {
|
||||
available_first
|
||||
} else {
|
||||
available_cont
|
||||
};
|
||||
|
||||
if line_len == 0 {
|
||||
result.push_str(word);
|
||||
line_len = wlen;
|
||||
} else if line_len + 1 + wlen > avail {
|
||||
result.push('\n');
|
||||
result.push_str(&indent_str);
|
||||
result.push_str(word);
|
||||
line_len = wlen;
|
||||
first_line = false;
|
||||
} else {
|
||||
result.push(' ');
|
||||
result.push_str(word);
|
||||
line_len += 1 + wlen;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get terminal width, falling back to DEFAULT_WIDTH.
|
||||
fn terminal_width() -> usize {
|
||||
terminal_size::terminal_size()
|
||||
.map(|(w, _)| w.0 as usize)
|
||||
.unwrap_or(DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
/// Capitalise the first character of a string.
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for upper in c.to_uppercase() {
|
||||
out.push(upper);
|
||||
}
|
||||
out.push_str(chars.as_str());
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Strip ANSI escape codes for testing visible content.
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_escape = false;
|
||||
for ch in s.chars() {
|
||||
if ch == '\x1b' {
|
||||
in_escape = true;
|
||||
} else if in_escape {
|
||||
if ch == 'm' {
|
||||
in_escape = false;
|
||||
}
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── normalize_snippet ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_snippet_strips_newlines_no_space() {
|
||||
// Newlines are removed with no whitespace inserted in their place.
|
||||
assert_eq!(normalize_snippet("foo\nbar\rbaz"), "foobarbaz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_snippet_collapses_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_snippet("Command::new(\"tar\") .arg(\"-czf\")"),
|
||||
"Command::new(\"tar\").arg(\"-czf\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_snippet_trims() {
|
||||
assert_eq!(normalize_snippet(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_snippet_truncates_at_120() {
|
||||
let long = "a".repeat(200);
|
||||
let result = normalize_snippet(&long);
|
||||
// 120 chars + '…' (3 bytes UTF-8)
|
||||
assert!(result.len() > 120);
|
||||
assert!(result.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_snippet_short_unchanged() {
|
||||
assert_eq!(normalize_snippet("short"), "short");
|
||||
}
|
||||
|
||||
// ── collapse_chain_spacing ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn collapse_chain_removes_space_before_dot() {
|
||||
assert_eq!(
|
||||
collapse_chain_spacing("foo() .bar() .baz()"),
|
||||
"foo().bar().baz()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_chain_preserves_non_chain_spacing() {
|
||||
assert_eq!(collapse_chain_spacing("foo() + bar()"), "foo() + bar()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_chain_multiple_spaces() {
|
||||
assert_eq!(
|
||||
collapse_chain_spacing("cmd() .arg(\"-c\")"),
|
||||
"cmd().arg(\"-c\")"
|
||||
);
|
||||
}
|
||||
|
||||
// ── shorten_callee ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn shorten_callee_truncates_chain() {
|
||||
assert_eq!(
|
||||
shorten_callee("Command::new(\"sh\").arg(\"-c\").arg(&cmd)"),
|
||||
"Command::new(\"sh\")…"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_callee_no_chain_unchanged() {
|
||||
assert_eq!(shorten_callee("env::var(\"HOME\")"), "env::var(\"HOME\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_callee_nested_parens() {
|
||||
assert_eq!(shorten_callee("foo(bar(1, 2)).baz()"), "foo(bar(1, 2))…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_callee_no_parens() {
|
||||
assert_eq!(shorten_callee("simple_name"), "simple_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_callee_empty() {
|
||||
assert_eq!(shorten_callee(""), "");
|
||||
}
|
||||
|
||||
// ── wrap_text ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn wrap_short_text_unchanged() {
|
||||
assert_eq!(wrap_text("short text", 80, 4), "short text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_breaks_at_boundary() {
|
||||
let text = "word1 word2 word3 word4 word5";
|
||||
let result = wrap_text(text, 20, 4);
|
||||
assert!(result.contains('\n'));
|
||||
for line in result.lines().skip(1) {
|
||||
assert!(line.starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
// ── severity_tag ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn severity_tags_contain_level_name() {
|
||||
let h = strip_ansi(&severity_tag(Severity::High));
|
||||
let m = strip_ansi(&severity_tag(Severity::Medium));
|
||||
let l = strip_ansi(&severity_tag(Severity::Low));
|
||||
assert!(h.contains("HIGH"), "got: {h}");
|
||||
assert!(m.contains("MEDIUM"), "got: {m}");
|
||||
assert!(l.contains("LOW"), "got: {l}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_tags_have_icons() {
|
||||
let h = strip_ansi(&severity_tag(Severity::High));
|
||||
let m = strip_ansi(&severity_tag(Severity::Medium));
|
||||
let l = strip_ansi(&severity_tag(Severity::Low));
|
||||
assert!(h.contains('✖'), "HIGH should have ✖");
|
||||
assert!(m.contains('⚠'), "MEDIUM should have ⚠");
|
||||
assert!(l.contains('●'), "LOW should have ●");
|
||||
}
|
||||
|
||||
// ── render_console ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn render_console_groups_by_file() {
|
||||
let diags = vec![
|
||||
Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 10,
|
||||
col: 5,
|
||||
severity: Severity::High,
|
||||
id: "test-rule".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("test message".into()),
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
},
|
||||
Diag {
|
||||
path: "src/b.rs".into(),
|
||||
line: 20,
|
||||
col: 1,
|
||||
severity: Severity::Low,
|
||||
id: "another-rule".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
},
|
||||
];
|
||||
let output = render_console(&diags, "test-project", None);
|
||||
let stripped = strip_ansi(&output);
|
||||
assert!(stripped.contains("src/a.rs"));
|
||||
assert!(stripped.contains("src/b.rs"));
|
||||
assert!(stripped.contains("2 issues"));
|
||||
assert!(stripped.contains("test-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_console_evidence_displayed() {
|
||||
let diags = vec![Diag {
|
||||
path: "src/main.rs".into(),
|
||||
line: 42,
|
||||
col: 5,
|
||||
severity: Severity::High,
|
||||
id: "taint-unsanitised-flow (source 12:3)".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("unsanitised input".into()),
|
||||
labels: vec![
|
||||
("Source".into(), "env::var(\"HOME\") at 12:3".into()),
|
||||
("Sink".into(), "Command::new(\"sh\")".into()),
|
||||
],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
}];
|
||||
let output = render_console(&diags, "proj", None);
|
||||
let stripped = strip_ansi(&output);
|
||||
assert!(stripped.contains("Source:"), "should contain Source label");
|
||||
assert!(stripped.contains("Sink:"), "should contain Sink label");
|
||||
// No backticks in output
|
||||
assert!(
|
||||
!stripped.contains('`'),
|
||||
"should not contain backticks in evidence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_console_blank_line_between_findings() {
|
||||
let diags = vec![
|
||||
Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::High,
|
||||
id: "rule-a".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("first".into()),
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
},
|
||||
Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 10,
|
||||
col: 1,
|
||||
severity: Severity::Medium,
|
||||
id: "rule-b".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("second".into()),
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
},
|
||||
];
|
||||
let output = render_console(&diags, "proj", None);
|
||||
let stripped = strip_ansi(&output);
|
||||
// There should be a blank line between the two findings
|
||||
assert!(
|
||||
stripped.contains("First\n\n"),
|
||||
"blank line between findings: {stripped}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_omits_empty_labels() {
|
||||
let d = Diag {
|
||||
path: "x.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::Low,
|
||||
id: "test".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
!json.contains("labels"),
|
||||
"empty labels should be omitted from JSON"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_omits_rank_fields_when_none() {
|
||||
let d = Diag {
|
||||
path: "x.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::Low,
|
||||
id: "test".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
!json.contains("rank_score"),
|
||||
"rank_score should be omitted when None"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("rank_reason"),
|
||||
"rank_reason should be omitted when None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_includes_rank_score_when_set() {
|
||||
let d = Diag {
|
||||
path: "x.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::High,
|
||||
id: "taint-unsanitised-flow".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: Some(120.0),
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
json.contains("rank_score"),
|
||||
"rank_score should be present when set"
|
||||
);
|
||||
assert!(json.contains("120"), "rank_score value should appear");
|
||||
}
|
||||
|
||||
// ── capitalize_first ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn capitalize_first_works() {
|
||||
assert_eq!(capitalize_first("hello"), "Hello");
|
||||
assert_eq!(capitalize_first(""), "");
|
||||
assert_eq!(capitalize_first("A"), "A");
|
||||
assert_eq!(capitalize_first("unsanitised"), "Unsanitised");
|
||||
}
|
||||
|
||||
// ── taint flow rendering (integration-style) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn taint_flow_no_broken_backticks_or_weird_spacing() {
|
||||
let raw_sink = "Command::new(\"tar\") .arg(\"-czf\") .arg(\"/backups/nightly.tar.gz\") .arg(\"/var/data\") .output()";
|
||||
let normalised = normalize_snippet(raw_sink);
|
||||
// Chain spacing should be collapsed
|
||||
assert!(
|
||||
!normalised.contains(") ."),
|
||||
"chain spacing should be collapsed: {normalised}"
|
||||
);
|
||||
assert!(!normalised.contains(" "), "no double-spaces: {normalised}");
|
||||
// Should not contain backticks
|
||||
assert!(!normalised.contains('`'), "no backticks: {normalised}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_sink_joined_and_normalised() {
|
||||
let raw = "Command::new(\"tar\")\n .arg(\"-czf\")\n .arg(\"/backups/nightly.tar.gz\")\n .arg(\"/var/data\")\n .output()";
|
||||
let normalised = normalize_snippet(raw);
|
||||
assert_eq!(
|
||||
normalised,
|
||||
"Command::new(\"tar\").arg(\"-czf\").arg(\"/backups/nightly.tar.gz\").arg(\"/var/data\").output()"
|
||||
);
|
||||
}
|
||||
|
||||
// ── confidence display ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn confidence_after_score_on_header_line() {
|
||||
let d = Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 510,
|
||||
col: 5,
|
||||
severity: Severity::Medium,
|
||||
id: "cfg-unguarded-sink".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("dangerous sink".into()),
|
||||
labels: vec![],
|
||||
confidence: Some(crate::evidence::Confidence::Medium),
|
||||
evidence: None,
|
||||
rank_score: Some(36.0),
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let output = render_diag(&d, 120);
|
||||
let stripped = strip_ansi(&output);
|
||||
// Header line should contain score and confidence together
|
||||
let header = stripped.lines().next().unwrap();
|
||||
assert!(
|
||||
header.contains("(Score: 36, Confidence: Medium)"),
|
||||
"header should contain '(Score: 36, Confidence: Medium)': {header}"
|
||||
);
|
||||
// No standalone Confidence line
|
||||
let non_header_lines: Vec<&str> = stripped.lines().skip(1).collect();
|
||||
assert!(
|
||||
!non_header_lines
|
||||
.iter()
|
||||
.any(|l| l.trim().starts_with("Confidence:")),
|
||||
"should not have standalone Confidence line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_title_case() {
|
||||
for (conf, expected) in [
|
||||
(crate::evidence::Confidence::Low, "Confidence: Low"),
|
||||
(crate::evidence::Confidence::Medium, "Confidence: Medium"),
|
||||
(crate::evidence::Confidence::High, "Confidence: High"),
|
||||
] {
|
||||
let d = Diag {
|
||||
path: "x.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::Low,
|
||||
id: "test".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: Some(conf),
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
assert!(
|
||||
stripped.contains(expected),
|
||||
"expected '{expected}' in: {stripped}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_none_only_score() {
|
||||
let d = Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 10,
|
||||
col: 5,
|
||||
severity: Severity::High,
|
||||
id: "test-rule".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: Some("test message".into()),
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: Some(42.0),
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
let header = stripped.lines().next().unwrap();
|
||||
assert!(
|
||||
header.contains("(Score: 42)"),
|
||||
"should show score without confidence: {header}"
|
||||
);
|
||||
assert!(
|
||||
!header.contains("Confidence"),
|
||||
"should not mention confidence when None: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_only_no_score() {
|
||||
let d = Diag {
|
||||
path: "src/a.rs".into(),
|
||||
line: 10,
|
||||
col: 5,
|
||||
severity: Severity::High,
|
||||
id: "test-rule".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: Some(crate::evidence::Confidence::High),
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
let header = stripped.lines().next().unwrap();
|
||||
assert!(
|
||||
header.contains("(Confidence: High)"),
|
||||
"should show confidence without score: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_omits_confidence_when_none() {
|
||||
let d = Diag {
|
||||
path: "x.rs".into(),
|
||||
line: 1,
|
||||
col: 1,
|
||||
severity: Severity::Low,
|
||||
id: "test".into(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
!json.contains("confidence"),
|
||||
"confidence should be omitted when None: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue