mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
728 lines
23 KiB
Rust
728 lines
23 KiB
Rust
|
|
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;
|
||
|
|
|
||
|
|
/// Compact related-finding reference for the detail panel.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct RelatedFindingView {
|
||
|
|
pub index: usize,
|
||
|
|
pub rule_id: String,
|
||
|
|
pub path: String,
|
||
|
|
pub line: usize,
|
||
|
|
pub severity: Severity,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Valid triage states for findings.
|
||
|
|
pub const VALID_TRIAGE_STATES: &[&str] = &[
|
||
|
|
"open",
|
||
|
|
"investigating",
|
||
|
|
"false_positive",
|
||
|
|
"accepted_risk",
|
||
|
|
"suppressed",
|
||
|
|
"fixed",
|
||
|
|
];
|
||
|
|
|
||
|
|
/// Check if a string is a valid triage state.
|
||
|
|
pub fn is_valid_triage_state(s: &str) -> bool {
|
||
|
|
VALID_TRIAGE_STATES.contains(&s)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Serializable API representation of a Diag finding.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Lines of source code around a finding for display.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct CodeContextView {
|
||
|
|
pub start_line: usize,
|
||
|
|
pub lines: Vec<String>,
|
||
|
|
pub highlight_line: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Aggregate statistics for a set of findings.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A scan job as seen by the API.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Custom rule view for the config API.
|
||
|
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||
|
|
pub struct RuleView {
|
||
|
|
pub lang: String,
|
||
|
|
pub matchers: Vec<String>,
|
||
|
|
pub kind: String,
|
||
|
|
pub cap: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Terminator view for the config API.
|
||
|
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||
|
|
pub struct TerminatorView {
|
||
|
|
pub lang: String,
|
||
|
|
pub name: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rule list item for GET /api/rules (built-in + custom, with metadata).
|
||
|
|
#[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,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Full rule detail for GET /api/rules/:id
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Label entry for sources/sinks/sanitizers listing.
|
||
|
|
///
|
||
|
|
/// `case_sensitive` and `is_builtin` default to `false` on deserialize so POST
|
||
|
|
/// bodies from the UI (which only supply `lang`, `matchers`, `cap`) succeed.
|
||
|
|
#[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,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Profile view for profile listing.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct ProfileView {
|
||
|
|
pub name: String,
|
||
|
|
pub is_builtin: bool,
|
||
|
|
pub settings: serde_json::Value,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Distinct filter values available in a set of findings.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Collect distinct filter values from a slice of diagnostics.
|
||
|
|
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());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Always include all valid triage states so the filter dropdown is complete
|
||
|
|
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(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Map a finding file path extension to a human-readable language name.
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compute the status string for a diagnostic.
|
||
|
|
fn status_for_diag(d: &Diag) -> &'static str {
|
||
|
|
if d.suppressed {
|
||
|
|
"suppressed"
|
||
|
|
} else if d.path_validated {
|
||
|
|
"validated"
|
||
|
|
} else {
|
||
|
|
"open"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convert a Diag to a FindingView at a given index.
|
||
|
|
pub fn finding_from_diag(index: usize, d: &Diag) -> FindingView {
|
||
|
|
FindingView {
|
||
|
|
index,
|
||
|
|
fingerprint: compute_fingerprint(d),
|
||
|
|
portable_fingerprint: String::new(), // set by caller with scan_root
|
||
|
|
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![],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convert a Diag to a FindingView with code context loaded from disk.
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convert a Diag to a FindingView with full detail (evidence, related findings).
|
||
|
|
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);
|
||
|
|
|
||
|
|
// Evidence (pass through the core type directly)
|
||
|
|
view.evidence = d.evidence.clone();
|
||
|
|
view.guard_kind = d.guard_kind.clone();
|
||
|
|
view.rank_reason = d.rank_reason.clone();
|
||
|
|
|
||
|
|
// Sanitizer status
|
||
|
|
view.sanitizer_status = Some(compute_sanitizer_status(d));
|
||
|
|
|
||
|
|
// Related findings: same rule_id OR same file, excluding self, capped at 10
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compute the sanitizer status for a diagnostic based on its evidence.
|
||
|
|
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(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Load surrounding lines of code for a finding.
|
||
|
|
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,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Scan Comparison Types ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Full response from the scan comparison endpoint.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Minimal scan metadata for comparison headers.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct CompareScanInfo {
|
||
|
|
pub id: String,
|
||
|
|
pub started_at: Option<String>,
|
||
|
|
pub finding_count: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Aggregate counts and severity deltas for a comparison.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A finding annotated with its fingerprint for comparison views.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct ComparedFinding {
|
||
|
|
pub fingerprint: String,
|
||
|
|
#[serde(flatten)]
|
||
|
|
pub finding: FindingView,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A finding that exists in both scans but with changed properties.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct ChangedFinding {
|
||
|
|
pub fingerprint: String,
|
||
|
|
#[serde(flatten)]
|
||
|
|
pub finding: FindingView,
|
||
|
|
pub changes: Vec<FieldChange>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A single field that differs between two scans for the same fingerprint.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct FieldChange {
|
||
|
|
pub field: String,
|
||
|
|
pub old_value: String,
|
||
|
|
pub new_value: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compute a stable fingerprint for a finding based on identity fields.
|
||
|
|
///
|
||
|
|
/// The fingerprint is a blake3 hash of (rule_id, file_path, sink_snippet,
|
||
|
|
/// source_snippet, function_context). Line/col are intentionally excluded
|
||
|
|
/// so that fingerprints survive code movement.
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Overlay triage states from the database onto a slice of FindingViews.
|
||
|
|
///
|
||
|
|
/// For each finding, first checks for an explicit triage state by fingerprint.
|
||
|
|
/// If none, checks suppression rules in order: fingerprint → rule → rule_in_file → file.
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compute a portable fingerprint using a path relative to scan_root.
|
||
|
|
///
|
||
|
|
/// This fingerprint is stable across machines because it strips the absolute
|
||
|
|
/// path prefix. Used for `.nyx/triage.json` sync files that get committed to git.
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Build a summary from a slice of findings.
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Overview Types ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Full response for GET /api/overview.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A name + count pair for overview top-N lists.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct OverviewCount {
|
||
|
|
pub name: String,
|
||
|
|
pub count: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A rule that has high finding count + high suppression rate.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct NoisyRule {
|
||
|
|
pub rule_id: String,
|
||
|
|
pub finding_count: usize,
|
||
|
|
pub suppression_rate: f64,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compact scan info for the overview recent-scans list.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// An actionable insight for the overview page.
|
||
|
|
#[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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A single trend data point for GET /api/overview/trends.
|
||
|
|
#[derive(Debug, Clone, Serialize)]
|
||
|
|
pub struct TrendPoint {
|
||
|
|
pub scan_id: String,
|
||
|
|
pub timestamp: String,
|
||
|
|
pub total: usize,
|
||
|
|
pub by_severity: HashMap<String, usize>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Count findings grouped by language.
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract top N directories by finding count.
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Sort a HashMap by value descending, take top N, return as OverviewCount.
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|