use crate::commands::scan::Diag;
use crate::evidence::{Confidence, Evidence};
use crate::patterns::{FindingCategory, Severity};
use crate::utils::path::{DEFAULT_UI_MAX_FILE_BYTES, open_repo_text_file};
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
#[derive(Debug, Clone, Serialize)]
pub struct RelatedFindingView {
pub index: usize,
pub rule_id: String,
pub path: String,
pub line: usize,
pub severity: Severity,
}
pub const VALID_TRIAGE_STATES: &[&str] = &[
"open",
"investigating",
"false_positive",
"accepted_risk",
"suppressed",
"fixed",
];
pub fn is_valid_triage_state(s: &str) -> bool {
VALID_TRIAGE_STATES.contains(&s)
}
#[derive(Debug, Clone, Serialize)]
pub struct FindingView {
pub index: usize,
pub fingerprint: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub portable_fingerprint: String,
pub path: String,
pub line: usize,
pub col: usize,
pub severity: Severity,
pub rule_id: String,
pub category: FindingCategory,
pub confidence: Option<Confidence>,
pub rank_score: Option<f64>,
pub message: Option<String>,
pub labels: Vec<(String, String)>,
pub path_validated: bool,
pub suppressed: bool,
pub language: Option<String>,
pub status: String,
pub triage_state: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub triage_note: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_context: Option<CodeContextView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<Evidence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guard_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rank_reason: Option<Vec<(String, String)>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sanitizer_status: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related_findings: Vec<RelatedFindingView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CodeContextView {
pub start_line: usize,
pub lines: Vec<String>,
pub highlight_line: usize,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct FindingSummary {
pub total: usize,
pub by_severity: HashMap<String, usize>,
pub by_category: HashMap<String, usize>,
pub by_rule: HashMap<String, usize>,
pub by_file: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScanView {
pub id: String,
pub status: String,
pub scan_root: String,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub duration_secs: Option<f64>,
pub finding_count: Option<usize>,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engine_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub files_scanned: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timing: Option<crate::server::progress::TimingBreakdown>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics: Option<crate::server::progress::ScanMetricsSnapshot>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct RuleView {
pub lang: String,
pub matchers: Vec<String>,
pub kind: String,
pub cap: String,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct TerminatorView {
pub lang: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RuleListItem {
pub id: String,
pub title: String,
pub language: String,
pub kind: String,
pub cap: String,
pub matchers: Vec<String>,
pub enabled: bool,
pub is_custom: bool,
pub is_gated: bool,
pub case_sensitive: bool,
pub finding_count: usize,
pub suppression_rate: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RuleDetailView {
pub id: String,
pub title: String,
pub language: String,
pub kind: String,
pub cap: String,
pub matchers: Vec<String>,
pub case_sensitive: bool,
pub enabled: bool,
pub is_custom: bool,
pub is_gated: bool,
pub finding_count: usize,
pub suppression_rate: f64,
pub example_findings: Vec<RelatedFindingView>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct LabelEntryView {
pub lang: String,
pub matchers: Vec<String>,
pub cap: String,
#[serde(default)]
pub case_sensitive: bool,
#[serde(default)]
pub is_builtin: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileView {
pub name: String,
pub is_builtin: bool,
pub settings: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct FilterValues {
pub severities: Vec<String>,
pub categories: Vec<String>,
pub confidences: Vec<String>,
pub languages: Vec<String>,
pub rules: Vec<String>,
pub statuses: Vec<String>,
}
pub fn collect_filter_values(findings: &[Diag]) -> FilterValues {
let mut severities = BTreeSet::new();
let mut categories = BTreeSet::new();
let mut confidences = BTreeSet::new();
let mut languages = BTreeSet::new();
let mut rules = BTreeSet::new();
let mut statuses = BTreeSet::new();
for d in findings {
severities.insert(d.severity.as_db_str().to_string());
categories.insert(d.category.to_string());
if let Some(c) = d.confidence {
confidences.insert(format!("{c:?}"));
}
if let Some(lang) = lang_for_finding_path(&d.path) {
languages.insert(lang);
}
rules.insert(d.id.clone());
statuses.insert(status_for_diag(d).to_string());
}
for s in VALID_TRIAGE_STATES {
statuses.insert(s.to_string());
}
FilterValues {
severities: severities.into_iter().collect(),
categories: categories.into_iter().collect(),
confidences: confidences.into_iter().collect(),
languages: languages.into_iter().collect(),
rules: rules.into_iter().collect(),
statuses: statuses.into_iter().collect(),
}
}
pub fn lang_for_finding_path(path: &str) -> Option<String> {
let ext = path.rsplit('.').next()?;
match ext.to_ascii_lowercase().as_str() {
"rs" => Some("Rust".into()),
"c" => Some("C".into()),
"cpp" => Some("C++".into()),
"java" => Some("Java".into()),
"go" => Some("Go".into()),
"php" => Some("PHP".into()),
"py" => Some("Python".into()),
"ts" => Some("TypeScript".into()),
"js" => Some("JavaScript".into()),
"rb" => Some("Ruby".into()),
_ => None,
}
}
fn status_for_diag(d: &Diag) -> &'static str {
if d.suppressed {
"suppressed"
} else if d.path_validated {
"validated"
} else {
"open"
}
}
pub fn finding_from_diag(index: usize, d: &Diag) -> FindingView {
FindingView {
index,
fingerprint: compute_fingerprint(d),
portable_fingerprint: String::new(), path: d.path.clone(),
line: d.line,
col: d.col,
severity: d.severity,
rule_id: d.id.clone(),
category: d.category,
confidence: d.confidence,
rank_score: d.rank_score,
message: d.message.clone(),
labels: d.labels.clone(),
path_validated: d.path_validated,
suppressed: d.suppressed,
language: lang_for_finding_path(&d.path),
status: status_for_diag(d).to_string(),
triage_state: "open".to_string(),
triage_note: String::new(),
code_context: None,
evidence: None,
guard_kind: None,
rank_reason: None,
sanitizer_status: None,
related_findings: vec![],
}
}
pub fn finding_from_diag_with_context(index: usize, d: &Diag, scan_root: &Path) -> FindingView {
let mut view = finding_from_diag(index, d);
view.code_context = load_code_context(&d.path, d.line, scan_root);
view
}
pub fn finding_from_diag_with_detail(
index: usize,
d: &Diag,
scan_root: &Path,
all_findings: &[Diag],
) -> FindingView {
let mut view = finding_from_diag_with_context(index, d, scan_root);
view.evidence = d.evidence.clone();
view.guard_kind = d.guard_kind.clone();
view.rank_reason = d.rank_reason.clone();
view.sanitizer_status = Some(compute_sanitizer_status(d));
let mut related = Vec::new();
for (i, other) in all_findings.iter().enumerate() {
if i == index {
continue;
}
if other.id == d.id || other.path == d.path {
related.push(RelatedFindingView {
index: i,
rule_id: other.id.clone(),
path: other.path.clone(),
line: other.line,
severity: other.severity,
});
if related.len() >= 10 {
break;
}
}
}
view.related_findings = related;
view
}
fn compute_sanitizer_status(d: &Diag) -> String {
match &d.evidence {
Some(ev) if !ev.sanitizers.is_empty() => {
if d.suppressed {
"applied".into()
} else {
"bypassed".into()
}
}
_ => "none".into(),
}
}
fn load_code_context(path: &str, line: usize, scan_root: &Path) -> Option<CodeContextView> {
let opened = open_repo_text_file(scan_root, path, DEFAULT_UI_MAX_FILE_BYTES).ok()?;
let content = opened.content;
let all_lines: Vec<&str> = content.lines().collect();
if line == 0 || line > all_lines.len() {
return None;
}
let context_radius = 5;
let start = line.saturating_sub(context_radius).max(1);
let end = (line + context_radius).min(all_lines.len());
let lines: Vec<String> = all_lines[start - 1..end]
.iter()
.map(|l| (*l).to_string())
.collect();
Some(CodeContextView {
start_line: start,
lines,
highlight_line: line,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct CompareResponse {
pub left_scan: CompareScanInfo,
pub right_scan: CompareScanInfo,
pub summary: CompareSummary,
pub new_findings: Vec<ComparedFinding>,
pub fixed_findings: Vec<ComparedFinding>,
pub changed_findings: Vec<ChangedFinding>,
pub unchanged_findings: Vec<ComparedFinding>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CompareScanInfo {
pub id: String,
pub started_at: Option<String>,
pub finding_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct CompareSummary {
pub new_count: usize,
pub fixed_count: usize,
pub changed_count: usize,
pub unchanged_count: usize,
pub severity_delta: HashMap<String, i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComparedFinding {
pub fingerprint: String,
#[serde(flatten)]
pub finding: FindingView,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangedFinding {
pub fingerprint: String,
#[serde(flatten)]
pub finding: FindingView,
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldChange {
pub field: String,
pub old_value: String,
pub new_value: String,
}
pub fn compute_fingerprint(d: &Diag) -> String {
let sink_snippet = d
.evidence
.as_ref()
.and_then(|e| e.sink.as_ref())
.and_then(|s| s.snippet.as_deref())
.unwrap_or("");
let source_snippet = d
.evidence
.as_ref()
.and_then(|e| e.source.as_ref())
.and_then(|s| s.snippet.as_deref())
.unwrap_or("");
let func_ctx = d
.evidence
.as_ref()
.and_then(|e| e.flow_steps.iter().find_map(|s| s.function.as_deref()))
.unwrap_or("");
let input = format!(
"{}\0{}\0{}\0{}\0{}",
d.id, d.path, sink_snippet, source_snippet, func_ctx
);
blake3::hash(input.as_bytes()).to_hex().to_string()
}
pub fn overlay_triage_states(
views: &mut [FindingView],
triage_map: &std::collections::HashMap<String, (String, String, String)>,
suppression_rules: &[crate::database::index::SuppressionRule],
) {
for view in views.iter_mut() {
if let Some((state, note, _)) = triage_map.get(&view.fingerprint) {
view.triage_state = state.clone();
view.triage_note = note.clone();
view.status = state.clone();
} else {
for rule in suppression_rules {
let matches = match rule.suppress_by.as_str() {
"fingerprint" => view.fingerprint == rule.match_value,
"rule" => view.rule_id == rule.match_value,
"rule_in_file" => {
let key = format!("{}:{}", view.rule_id, view.path);
key == rule.match_value
}
"file" => view.path == rule.match_value,
_ => false,
};
if matches {
view.triage_state = rule.state.clone();
view.triage_note = rule.note.clone();
view.status = rule.state.clone();
break;
}
}
}
}
}
pub fn compute_portable_fingerprint(d: &Diag, scan_root: &Path) -> String {
let rel_path = d
.path
.strip_prefix(scan_root.to_string_lossy().as_ref())
.unwrap_or(&d.path)
.trim_start_matches('/');
let sink_snippet = d
.evidence
.as_ref()
.and_then(|e| e.sink.as_ref())
.and_then(|s| s.snippet.as_deref())
.unwrap_or("");
let source_snippet = d
.evidence
.as_ref()
.and_then(|e| e.source.as_ref())
.and_then(|s| s.snippet.as_deref())
.unwrap_or("");
let func_ctx = d
.evidence
.as_ref()
.and_then(|e| e.flow_steps.iter().find_map(|s| s.function.as_deref()))
.unwrap_or("");
let input = format!(
"{}\0{}\0{}\0{}\0{}",
d.id, rel_path, sink_snippet, source_snippet, func_ctx
);
blake3::hash(input.as_bytes()).to_hex().to_string()
}
pub fn summarize_findings(findings: &[Diag]) -> FindingSummary {
let mut summary = FindingSummary {
total: findings.len(),
..Default::default()
};
for d in findings {
let sev_key = d.severity.as_db_str().to_string();
*summary.by_severity.entry(sev_key).or_insert(0) += 1;
*summary
.by_category
.entry(d.category.to_string())
.or_insert(0) += 1;
*summary.by_rule.entry(d.id.clone()).or_insert(0) += 1;
*summary.by_file.entry(d.path.clone()).or_insert(0) += 1;
}
summary
}
#[derive(Debug, Clone, Serialize)]
pub struct OverviewResponse {
pub state: String,
pub total_findings: usize,
pub new_since_last: usize,
pub fixed_since_last: usize,
pub high_confidence_rate: f64,
pub triage_coverage: f64,
pub latest_scan_duration_secs: Option<f64>,
pub latest_scan_id: Option<String>,
pub latest_scan_at: Option<String>,
pub by_severity: HashMap<String, usize>,
pub by_category: HashMap<String, usize>,
pub by_language: HashMap<String, usize>,
pub top_files: Vec<OverviewCount>,
pub top_directories: Vec<OverviewCount>,
pub top_rules: Vec<OverviewCount>,
pub noisy_rules: Vec<NoisyRule>,
pub recent_scans: Vec<ScanSummary>,
pub insights: Vec<Insight>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health: Option<HealthScore>,
#[serde(skip_serializing_if = "Option::is_none")]
pub posture: Option<PostureSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backlog: Option<BacklogStats>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub weighted_top_files: Vec<WeightedFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence_distribution: Option<ConfidenceDistribution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scanner_quality: Option<ScannerQuality>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issue_categories: Vec<IssueCategoryBucket>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hot_sinks: Vec<HotSink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owasp_buckets: Vec<OwaspBucket>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cross_file_ratio: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline: Option<BaselineInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub language_health: Vec<LanguageHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_hygiene: Option<SuppressionHygiene>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthScore {
pub score: u8,
pub grade: String,
pub components: Vec<HealthComponent>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthComponent {
pub label: String,
pub score: u8,
pub weight: f64,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PostureSummary {
pub trend: String,
pub severity: String,
pub message: String,
pub reintroduced_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct BacklogStats {
pub oldest_open_days: Option<u32>,
pub median_age_days: Option<u32>,
pub stale_count: usize,
pub age_buckets: Vec<OverviewCount>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WeightedFile {
pub name: String,
pub score: u32,
pub high: usize,
pub medium: usize,
pub low: usize,
pub total: usize,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ConfidenceDistribution {
pub high: usize,
pub medium: usize,
pub low: usize,
pub none: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScannerQuality {
pub files_scanned: u64,
pub files_skipped: u64,
pub parse_success_rate: f64,
pub functions_analyzed: u64,
pub call_edges: u64,
pub unresolved_calls: u64,
pub call_resolution_rate: f64,
pub symex_verified_rate: f64,
pub symex_breakdown: HashMap<String, usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IssueCategoryBucket {
pub label: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct HotSink {
pub callee: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct OwaspBucket {
pub code: String,
pub label: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LanguageHealth {
pub language: String,
pub findings: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct SuppressionHygiene {
pub fingerprint_level: usize,
pub rule_level: usize,
pub file_level: usize,
pub rule_in_file_level: usize,
pub blanket_rate: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BaselineInfo {
pub scan_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
pub baseline_total: usize,
pub drift_new: usize,
pub drift_fixed: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct OverviewCount {
pub name: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct NoisyRule {
pub rule_id: String,
pub finding_count: usize,
pub suppression_rate: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScanSummary {
pub id: String,
pub status: String,
pub started_at: Option<String>,
pub duration_secs: Option<f64>,
pub finding_count: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Insight {
pub kind: String,
pub message: String,
pub severity: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub action_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrendPoint {
pub scan_id: String,
pub timestamp: String,
pub total: usize,
pub by_severity: HashMap<String, usize>,
}
pub fn by_language_from_findings(findings: &[Diag]) -> HashMap<String, usize> {
let mut map = HashMap::new();
for d in findings {
if let Some(lang) = lang_for_finding_path(&d.path) {
*map.entry(lang).or_insert(0) += 1;
}
}
map
}
pub fn top_directories_from_findings(findings: &[Diag], limit: usize) -> Vec<OverviewCount> {
let mut dir_counts: HashMap<String, usize> = HashMap::new();
for d in findings {
let dir = match d.path.rfind('/') {
Some(i) => &d.path[..i],
None => ".",
};
*dir_counts.entry(dir.to_string()).or_insert(0) += 1;
}
let mut sorted: Vec<_> = dir_counts.into_iter().collect();
sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
sorted.truncate(limit);
sorted
.into_iter()
.map(|(name, count)| OverviewCount { name, count })
.collect()
}
pub fn top_n_from_map(map: &HashMap<String, usize>, limit: usize) -> Vec<OverviewCount> {
let mut sorted: Vec<_> = map.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
sorted
.into_iter()
.take(limit)
.map(|(name, &count)| OverviewCount {
name: name.clone(),
count,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn diag_for_path(path: String) -> Diag {
Diag {
path,
line: 1,
col: 1,
severity: Severity::Low,
id: "test.rule".to_string(),
category: FindingCategory::Security,
path_validated: false,
guard_kind: None,
message: None,
labels: Vec::new(),
confidence: None,
evidence: None,
rank_score: None,
rank_reason: None,
suppressed: false,
suppression: None,
rollup: None,
finding_id: String::new(),
alternative_finding_ids: Vec::new(),
}
}
#[test]
fn code_context_does_not_read_outside_repo_for_absolute_paths() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::fs::write(outside.path(), "secret").unwrap();
let diag = diag_for_path(outside.path().to_string_lossy().to_string());
let view = finding_from_diag_with_context(0, &diag, root.path());
assert!(view.code_context.is_none());
}
#[test]
fn code_context_reads_repo_files() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join("src.rs");
std::fs::write(&file, "line1\nline2\n").unwrap();
let diag = diag_for_path(file.to_string_lossy().to_string());
let view = finding_from_diag_with_context(0, &diag, root.path());
assert!(view.code_context.is_some());
assert_eq!(view.code_context.unwrap().highlight_line, 1);
}
}