nyx/src/server/models.rs
Eli Peter 82f18184b1
Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:58:38 -04:00

908 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>,
// ── Tier 1 ──
#[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>,
// ── Tier 2 ──
#[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>,
// ── Tier 3 ──
#[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>,
}
/// Composite repo-health rollup.
#[derive(Debug, Clone, Serialize)]
pub struct HealthScore {
/// 0100 score; higher is better.
pub score: u8,
/// Letter grade AF derived from score.
pub grade: String,
/// Sub-component contributions (0100 each) for transparency.
pub components: Vec<HealthComponent>,
}
/// Single line item in the health-score breakdown.
#[derive(Debug, Clone, Serialize)]
pub struct HealthComponent {
/// Human label (e.g. "Severity pressure", "Trend", "Triage").
pub label: String,
/// 0100 — already inverted so higher = healthier.
pub score: u8,
/// Weight applied when blending into the final score (0.01.0).
pub weight: f64,
/// Short rationale shown in tooltip.
pub detail: String,
}
/// One-line trend posture for the page header.
#[derive(Debug, Clone, Serialize)]
pub struct PostureSummary {
/// "improving" | "regressing" | "stable" | "unknown"
pub trend: String,
/// "success" | "warning" | "danger" | "info"
pub severity: String,
/// Short message shown verbatim in the banner.
pub message: String,
/// Findings that were previously fixed and have re-appeared.
pub reintroduced_count: usize,
}
/// Backlog age statistics computed from finding_first_seen.
#[derive(Debug, Clone, Serialize)]
pub struct BacklogStats {
/// Days since the oldest still-open finding was first seen.
pub oldest_open_days: Option<u32>,
/// Median age of currently-open findings, in days.
pub median_age_days: Option<u32>,
/// Findings older than 30 days that remain open.
pub stale_count: usize,
/// Histogram buckets (label, count) — fixed 5 buckets.
pub age_buckets: Vec<OverviewCount>,
}
/// Top-file row including severity stack for the weighted ranking.
#[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,
}
/// Confidence-level distribution.
#[derive(Debug, Clone, Serialize, Default)]
pub struct ConfidenceDistribution {
pub high: usize,
pub medium: usize,
pub low: usize,
pub none: usize,
}
/// Engine-quality metrics that describe analysis depth/coverage.
#[derive(Debug, Clone, Serialize)]
pub struct ScannerQuality {
pub files_scanned: u64,
pub files_skipped: u64,
/// 0.01.0 — files_scanned / (files_scanned + files_skipped).
pub parse_success_rate: f64,
pub functions_analyzed: u64,
pub call_edges: u64,
pub unresolved_calls: u64,
/// 0.01.0 — call_edges / (call_edges + unresolved_calls).
pub call_resolution_rate: f64,
/// % of taint findings that received a symbolic verdict (Confirmed|Infeasible|Inconclusive).
pub symex_verified_rate: f64,
/// Count broken down by symbolic verdict label.
pub symex_breakdown: HashMap<String, usize>,
}
/// One issue-category bucket (rule-family derived). Broader than OWASP, with
/// engine-friendly labels like "Tainted Flow" or "Code Quality".
#[derive(Debug, Clone, Serialize)]
pub struct IssueCategoryBucket {
pub label: String,
pub count: usize,
}
/// "Hot sink" — a single callee that absorbs many findings.
#[derive(Debug, Clone, Serialize)]
pub struct HotSink {
/// Callee name (best-effort; from flow_steps last Sink).
pub callee: String,
pub count: usize,
}
/// One OWASP Top-10 (2021) bucket.
#[derive(Debug, Clone, Serialize)]
pub struct OwaspBucket {
/// "A01:2021 — Broken Access Control" etc.
pub code: String,
pub label: String,
pub count: usize,
}
/// Per-language posture.
#[derive(Debug, Clone, Serialize)]
pub struct LanguageHealth {
pub language: String,
pub findings: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
}
/// Suppression-quality breakdown.
#[derive(Debug, Clone, Serialize)]
pub struct SuppressionHygiene {
/// Findings explicitly triaged by fingerprint.
pub fingerprint_level: usize,
/// Findings suppressed by rule-level suppression.
pub rule_level: usize,
/// Findings suppressed by file-level suppression.
pub file_level: usize,
/// Findings suppressed by rule-in-file suppression.
pub rule_in_file_level: usize,
/// % of suppressed findings using low-specificity (rule/file/rule_in_file) rules.
pub blanket_rate: f64,
}
/// Pinned baseline scan and current drift relative to it.
#[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,
}
/// 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);
}
}