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>
This commit is contained in:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -1,16 +1,17 @@
use crate::commands::config as config_cmd;
use crate::labels;
use crate::server::app::{AppState, ServerEvent};
use crate::server::models::{LabelEntryView, ProfileView, RuleView, TerminatorView};
use crate::utils::config::{CapName, RuleKind, ScanProfile};
use crate::utils::config::{CapName, Config, RuleKind, ScanProfile};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use std::fs;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/config", get(get_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw))
.route(
"/config/rules",
get(list_rules).post(add_rule).delete(remove_rule),
@ -55,6 +56,67 @@ async fn get_config(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(serde_json::to_value(&*config).unwrap_or_default())
}
// ── Raw nyx.local read/write ─────────────────────────────────────────────────
async fn get_config_raw(State(state): State<AppState>) -> Json<serde_json::Value> {
let local_path = state.config_dir.join("nyx.local");
let exists = local_path.exists();
let content = if exists {
fs::read_to_string(&local_path).unwrap_or_default()
} else {
String::new()
};
Json(serde_json::json!({
"path": local_path.display().to_string(),
"exists": exists,
"content": content,
}))
}
async fn put_config_raw(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let content = body
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| bad_request("missing content field"))?
.to_string();
// Validate by parsing into Config (round-trip check).
let parsed: Config =
toml::from_str(&content).map_err(|e| bad_request(&format!("invalid TOML: {e}")))?;
if let Err(errs) = parsed.validate() {
let joined = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
return Err(bad_request(&format!("config validation failed: {joined}")));
}
let local_path = state.config_dir.join("nyx.local");
fs::write(&local_path, &content)
.map_err(|e| bad_request(&format!("failed to write {}: {e}", local_path.display())))?;
// Reload the merged config so live state matches the file.
match Config::load(&state.config_dir) {
Ok((reloaded, _note)) => {
*state.config.write() = reloaded;
}
Err(e) => return Err(bad_request(&format!("config reload failed: {e}"))),
}
let _ = state.event_tx.send(ServerEvent::ConfigChanged);
Ok(Json(serde_json::json!({
"status": "ok",
"path": local_path.display().to_string(),
"bytes": content.len(),
})))
}
// ── Custom rules (existing endpoints) ────────────────────────────────────────
async fn list_rules(State(state): State<AppState>) -> Json<Vec<RuleView>> {
@ -220,29 +282,17 @@ async fn remove_terminator(
// ── Sources / Sinks / Sanitizers (by kind) ───────────────────────────────────
fn list_by_kind(state: &AppState, target_kind: &str) -> Vec<LabelEntryView> {
let builtins = labels::enumerate_builtin_rules();
let config = state.config.read();
let mut out: Vec<LabelEntryView> = builtins
.iter()
.filter(|r| r.kind == target_kind && !r.is_gated)
.map(|r| LabelEntryView {
lang: r.language.clone(),
matchers: r.matchers.clone(),
cap: r.cap.clone(),
case_sensitive: r.case_sensitive,
is_builtin: true,
})
.collect();
// Add custom rules of the target kind
// Built-in rules live on /api/rules — keep this endpoint focused on the
// user's own additions in nyx.local.
let target_rule_kind = match target_kind {
"source" => RuleKind::Source,
"sanitizer" => RuleKind::Sanitizer,
"sink" => RuleKind::Sink,
_ => return out,
_ => return Vec::new(),
};
let config = state.config.read();
let mut out: Vec<LabelEntryView> = Vec::new();
for (lang, lang_cfg) in &config.analysis.languages {
for cr in &lang_cfg.rules {
if cr.kind == target_rule_kind {
@ -256,7 +306,6 @@ fn list_by_kind(state: &AppState, target_kind: &str) -> Vec<LabelEntryView> {
}
}
}
out
}

View file

@ -26,6 +26,9 @@ pub fn routes() -> Router<AppState> {
.route("/debug/call-graph", get(get_call_graph))
.route("/debug/abstract-interp", get(get_abstract_interp))
.route("/debug/symex", get(get_symex))
.route("/debug/pointer", get(get_pointer))
.route("/debug/type-facts", get(get_type_facts))
.route("/debug/auth", get(get_auth))
}
// ── Query params ─────────────────────────────────────────────────────────────
@ -117,7 +120,7 @@ async fn get_ssa(
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, _opt) = debug::analyse_function_ssa(&analysis, &q.function)?;
let (ssa, _opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
Ok(Json(SsaBodyView::from_ssa(&ssa, &analysis.bytes)))
}
@ -130,7 +133,7 @@ async fn get_taint(
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, opt) = debug::analyse_function_ssa(&analysis, &q.function)?;
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
// Try to load global summaries from DB for cross-file context
let global = load_global_summaries(&state);
@ -141,7 +144,7 @@ async fn get_taint(
let (events, _entry_states, exit_states) = debug::analyse_function_taint(
&ssa,
analysis.cfg(),
body_cfg,
analysis.lang,
analysis.summaries(),
global.as_ref(),
@ -168,13 +171,13 @@ async fn get_abstract_interp(
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, opt) = debug::analyse_function_ssa(&analysis, &q.function)?;
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
let global = load_global_summaries(&state);
let (_events, block_states, _exit_states) = debug::analyse_function_taint(
&ssa,
analysis.cfg(),
body_cfg,
analysis.lang,
analysis.summaries(),
global.as_ref(),
@ -262,16 +265,59 @@ async fn get_symex(
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, opt) = debug::analyse_function_ssa(&analysis, &q.function)?;
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
let global = load_global_summaries(&state);
let sym_state =
debug::analyse_function_symex(&ssa, analysis.cfg(), analysis.lang, &opt, global.as_ref());
debug::analyse_function_symex(&ssa, body_cfg, analysis.lang, &opt, global.as_ref());
Ok(Json(SymexView::from_symbolic_state(&sym_state, &ssa)))
}
/// GET /api/debug/pointer?file=<path>&function=<name>
/// Return the field-sensitive Steensgaard points-to facts for a function.
async fn get_pointer(
State(state): State<AppState>,
Query(q): Query<FileFunctionQuery>,
) -> Result<Json<PointerView>, StatusCode> {
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, facts) = debug::analyse_function_pointer(&analysis, &q.function)?;
Ok(Json(PointerView::from_facts(&facts, &ssa)))
}
/// GET /api/debug/type-facts?file=<path>&function=<name>
/// Return per-function type-fact details derived from the SSA optimiser.
async fn get_type_facts(
State(state): State<AppState>,
Query(q): Query<FileFunctionQuery>,
) -> Result<Json<TypeFactsView>, StatusCode> {
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let analysis = debug::analyse_file(&path, &config)?;
let (ssa, opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
Ok(Json(TypeFactsView::from_optimize(
&opt,
&ssa,
&analysis.bytes,
)))
}
/// GET /api/debug/auth?file=<path>
/// Return the file-scoped authorization model — routes, units,
/// sensitive operations, and auth checks — for the debug UI.
async fn get_auth(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> Result<Json<AuthAnalysisView>, StatusCode> {
let path = validate_and_resolve(&state.scan_root, &q.file)?;
let config = state.config.read();
let (model, bytes, enabled) = debug::analyse_file_auth(&path, &config)?;
Ok(Json(AuthAnalysisView::from_model(&model, &bytes, enabled)))
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Load global summaries from DB if available.
@ -396,7 +442,9 @@ mod tests {
abstract_transfer: vec![],
param_return_paths: vec![],
points_to: Default::default(),
field_points_to: Default::default(),
return_path_facts: smallvec::SmallVec::new(),
typed_call_receivers: vec![],
},
)],
)
@ -466,6 +514,8 @@ mod tests {
value_defs: vec![],
cfg_node_map: std::collections::HashMap::new(),
exception_edges: vec![],
field_interner: crate::ssa::ir::FieldInterner::default(),
field_writes: std::collections::HashMap::new(),
},
false,
false,
@ -486,6 +536,8 @@ mod tests {
value_defs: vec![],
cfg_node_map: std::collections::HashMap::new(),
exception_edges: vec![],
field_interner: crate::ssa::ir::FieldInterner::default(),
field_writes: std::collections::HashMap::new(),
},
true,
true,
@ -506,6 +558,8 @@ mod tests {
value_defs: vec![],
cfg_node_map: std::collections::HashMap::new(),
exception_edges: vec![],
field_interner: crate::ssa::ir::FieldInterner::default(),
field_writes: std::collections::HashMap::new(),
},
true,
false,
@ -599,7 +653,9 @@ mod tests {
abstract_transfer: vec![],
param_return_paths: vec![],
points_to: Default::default(),
field_points_to: Default::default(),
return_path_facts: smallvec::SmallVec::new(),
typed_call_receivers: vec![],
},
)],
)

View file

@ -54,7 +54,19 @@ struct TreeEntry {
#[derive(Debug, Serialize)]
struct SymbolEntry {
name: String,
/// Legacy display kind (`"function"` / `"method"`) used by existing CSS
/// classes in the frontend. Kept for backward-compat — new consumers
/// should prefer `func_kind`.
kind: String,
/// Structural [`crate::symbol::FuncKind`] slug (`"fn"`, `"method"`,
/// `"closure"`, `"ctor"`, `"getter"`, `"setter"`, `"toplevel"`). Lets
/// the UI distinguish anonymous closures (`<anon#N>`) from named
/// functions and offer a default-hide toggle.
func_kind: String,
/// Enclosing container path (class / impl / module / outer function).
/// Empty for free top-level functions. Surfaced so the UI can render
/// closures as `<anon#N> [in outer_fn]`.
container: String,
line: Option<usize>,
finding_count: usize,
namespace: Option<String>,
@ -278,16 +290,21 @@ async fn get_symbols(
let entries: Vec<SymbolEntry> = symbols
.into_iter()
.map(|(name, arity, _lang, namespace)| {
let kind = if !namespace.is_empty() && namespace != name {
"method".to_string()
} else {
"function".to_string()
.map(|(name, arity, _lang, namespace, container, func_kind)| {
// Legacy `kind` field — still used by existing CSS classes
// (`symbol-kind-method`, `symbol-kind-function`). Map any
// method-like FuncKind onto `"method"` and everything else
// onto `"function"` so the rendered icon stays sensible.
let kind = match func_kind.as_str() {
"method" | "ctor" | "getter" | "setter" => "method".to_string(),
_ => "function".to_string(),
};
let finding_count = func_finding_counts.get(&name).copied().unwrap_or(0);
SymbolEntry {
name,
kind,
func_kind,
container,
line: None,
finding_count,
namespace: if namespace.is_empty() {

View file

@ -1,7 +1,7 @@
use crate::server::app::AppState;
use crate::server::error::{ApiError, ApiResult};
use crate::utils::path::{DEFAULT_UI_MAX_FILE_BYTES, RepoPathError, open_repo_text_file};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
@ -33,9 +33,9 @@ struct FileResponse {
async fn get_file(
State(state): State<AppState>,
Query(query): Query<FileQuery>,
) -> Result<Json<FileResponse>, StatusCode> {
) -> ApiResult<Json<FileResponse>> {
let opened = open_repo_text_file(&state.scan_root, &query.path, DEFAULT_UI_MAX_FILE_BYTES)
.map_err(map_path_error)?;
.map_err(|e| map_path_error(e, &query.path))?;
let content = opened.content;
let all_lines: Vec<&str> = content.lines().collect();
let total_lines = all_lines.len();
@ -64,14 +64,25 @@ async fn get_file(
}))
}
fn map_path_error(err: RepoPathError) -> StatusCode {
fn map_path_error(err: RepoPathError, path: &str) -> ApiError {
match err {
RepoPathError::InvalidPath | RepoPathError::OutsideRoot => StatusCode::FORBIDDEN,
RepoPathError::NotFound => StatusCode::NOT_FOUND,
RepoPathError::TooLarge
| RepoPathError::InvalidText
| RepoPathError::NotFile
| RepoPathError::NotDirectory => StatusCode::BAD_REQUEST,
RepoPathError::Io => StatusCode::INTERNAL_SERVER_ERROR,
RepoPathError::InvalidPath => ApiError::forbidden(format!("invalid path: {path}")),
RepoPathError::OutsideRoot => {
ApiError::forbidden(format!("path outside scan root: {path}"))
}
RepoPathError::NotFound => ApiError::not_found(format!("file not found: {path}")),
RepoPathError::TooLarge => {
ApiError::bad_request(format!("file too large to display: {path}"))
}
RepoPathError::InvalidText => {
ApiError::bad_request(format!("file is not valid UTF-8 text: {path}"))
}
RepoPathError::NotFile => {
ApiError::bad_request(format!("path is not a regular file: {path}"))
}
RepoPathError::NotDirectory => {
ApiError::bad_request(format!("path is not a directory: {path}"))
}
RepoPathError::Io => ApiError::internal(format!("I/O error reading: {path}")),
}
}

View file

@ -2,13 +2,13 @@
use crate::commands::scan::Diag;
use crate::database::index::Indexer;
use crate::server::app::AppState;
use crate::server::app::{AppState, CachedFindings};
use crate::server::error::{ApiError, ApiResult};
use crate::server::models::{
FilterValues, FindingSummary, FindingView, collect_filter_values, finding_from_diag,
finding_from_diag_with_detail, overlay_triage_states, summarize_findings,
};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
@ -22,16 +22,30 @@ pub fn routes() -> Router<AppState> {
.route("/findings/{index}", get(get_finding))
}
/// Sentinel job id for "we read this from SQLite, not from JobManager."
/// Used as the cache key when no in-memory job exists (e.g. fresh server boot).
const DB_FALLBACK_KEY: &str = "__db_fallback__";
/// Bundle returned by [`load_latest_findings`]: the raw diags plus the cache
/// key under which their derived views should be stored. The cache key is the
/// in-memory job id when available, or [`DB_FALLBACK_KEY`] when we fell back
/// to SQLite.
struct LoadedFindings {
cache_key: String,
findings: Arc<Vec<Diag>>,
}
/// Load findings for the latest completed scan, falling back to DB if no
/// in-memory completed scan exists (e.g. after a server restart).
pub fn load_latest_findings(state: &AppState) -> Arc<Vec<Diag>> {
// In-memory first
fn load_latest_findings_internal(state: &AppState) -> LoadedFindings {
if let Some(job) = state.job_manager.get_latest_completed() {
if let Some(ref findings) = job.findings {
return Arc::clone(findings);
return LoadedFindings {
cache_key: job.id.clone(),
findings: Arc::clone(findings),
};
}
}
// DB fallback — find the most recent completed scan with findings
if let Some(ref pool) = state.db_pool {
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
if let Ok(scans) = idx.list_scans(20) {
@ -39,7 +53,10 @@ pub fn load_latest_findings(state: &AppState) -> Arc<Vec<Diag>> {
if scan.status == "completed" {
if let Some(json) = scan.findings_json.as_deref() {
if let Ok(diags) = serde_json::from_str::<Vec<Diag>>(json) {
return Arc::new(diags);
return LoadedFindings {
cache_key: format!("{DB_FALLBACK_KEY}:{}", scan.id),
findings: Arc::new(diags),
};
}
}
}
@ -47,10 +64,61 @@ pub fn load_latest_findings(state: &AppState) -> Arc<Vec<Diag>> {
}
}
}
Arc::new(Vec::new())
LoadedFindings {
cache_key: DB_FALLBACK_KEY.to_string(),
findings: Arc::new(Vec::new()),
}
}
/// Build (or fetch from cache) the per-scan derived views.
///
/// Returns clones of `Arc`s so callers can drop the lock immediately and work
/// without contention. Triage state is *not* baked into the cached views — it
/// changes on a different cadence and is overlaid per request.
fn cached_for_latest(state: &AppState) -> CachedFindings {
let loaded = load_latest_findings_internal(state);
// Fast path: cache hit for the same job id.
if let Some(cached) = state.findings_cache.read().as_ref() {
if cached.job_id == loaded.cache_key {
return cached.clone();
}
}
// Slow path: rebuild. Guard against concurrent rebuilds of the same key —
// a second writer that finds the cache already populated for our key
// simply returns it.
let mut guard = state.findings_cache.write();
if let Some(existing) = guard.as_ref() {
if existing.job_id == loaded.cache_key {
return existing.clone();
}
}
let views: Vec<FindingView> = loaded
.findings
.iter()
.enumerate()
.map(|(i, d)| finding_from_diag(i, d))
.collect();
let summary = summarize_findings(&loaded.findings);
let filters = collect_filter_values(&loaded.findings);
let entry = CachedFindings {
job_id: loaded.cache_key,
views: Arc::new(views),
summary: Arc::new(summary),
filters: Arc::new(filters),
};
*guard = Some(entry.clone());
entry
}
/// Load triage states and suppression rules from DB, apply to views.
///
/// Triage state is overlaid onto a freshly-cloned `Vec` rather than mutating
/// the cached views so concurrent readers see consistent data and the cache
/// stays valid across triage edits.
fn apply_triage_overlay(state: &AppState, views: &mut [FindingView]) {
if let Some(ref pool) = state.db_pool {
if let Ok(idx) = Indexer::from_pool("_triage", pool) {
@ -80,19 +148,11 @@ struct FindingsQuery {
async fn list_findings(
State(state): State<AppState>,
Query(query): Query<FindingsQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let findings = load_latest_findings(&state);
let mut views: Vec<FindingView> = findings
.iter()
.enumerate()
.map(|(i, d)| finding_from_diag(i, d))
.collect();
// Overlay triage states from DB before filtering
) -> ApiResult<Json<serde_json::Value>> {
let cached = cached_for_latest(&state);
let mut views: Vec<FindingView> = (*cached.views).clone();
apply_triage_overlay(&state, &mut views);
// Apply filters.
if let Some(ref sev) = query.severity {
let sev_upper = sev.to_ascii_uppercase();
views.retain(|f| f.severity.as_db_str() == sev_upper);
@ -138,7 +198,6 @@ async fn list_findings(
});
}
// Sort.
match query.sort_by.as_deref() {
Some("severity") => views.sort_by_key(|a| a.severity),
Some("path") | Some("file") => views.sort_by(|a, b| a.path.cmp(&b.path)),
@ -163,13 +222,12 @@ async fn list_findings(
}),
Some("status") => views.sort_by(|a, b| a.status.cmp(&b.status)),
Some("category") => views.sort_by_key(|a| a.category.to_string()),
_ => {} // default order (by index)
_ => {}
}
if query.sort_dir.as_deref() == Some("desc") {
views.reverse();
}
// Paginate.
let total = views.len();
let page = query.page.unwrap_or(1).max(1);
let per_page = query.per_page.unwrap_or(50).clamp(1, 10000);
@ -185,22 +243,28 @@ async fn list_findings(
}
async fn findings_summary(State(state): State<AppState>) -> Json<FindingSummary> {
let findings = load_latest_findings(&state);
Json(summarize_findings(&findings))
Json((*cached_for_latest(&state).summary).clone())
}
async fn findings_filters(State(state): State<AppState>) -> Json<FilterValues> {
let findings = load_latest_findings(&state);
Json(collect_filter_values(&findings))
Json((*cached_for_latest(&state).filters).clone())
}
async fn get_finding(
State(state): State<AppState>,
Path(index): Path<usize>,
) -> Result<Json<FindingView>, StatusCode> {
let findings = load_latest_findings(&state);
let diag = findings.get(index).ok_or(StatusCode::NOT_FOUND)?;
) -> ApiResult<Json<FindingView>> {
let findings = load_latest_findings_internal(&state).findings;
let diag = findings
.get(index)
.ok_or_else(|| ApiError::not_found(format!("finding {index} not found")))?;
let mut view = finding_from_diag_with_detail(index, diag, &state.scan_root, &findings);
apply_triage_overlay(&state, std::slice::from_mut(&mut view));
Ok(Json(view))
}
/// Public alias for callers (overview, explorer, triage) that just want
/// the raw diag list. Kept as `load_latest_findings` for source-compat.
pub fn load_latest_findings(state: &AppState) -> Arc<Vec<Diag>> {
load_latest_findings_internal(state).findings
}

View file

@ -2,21 +2,31 @@
use crate::commands::scan::Diag;
use crate::database::index::{Indexer, ScanRecord};
use crate::evidence::Confidence;
use crate::evidence::{Confidence, Verdict};
use crate::server::app::AppState;
use crate::server::models::{
Insight, NoisyRule, OverviewResponse, ScanSummary, TrendPoint, by_language_from_findings,
compute_fingerprint, summarize_findings, top_directories_from_findings, top_n_from_map,
BacklogStats, BaselineInfo, ConfidenceDistribution, HotSink, Insight, LanguageHealth,
NoisyRule, OverviewCount, OverviewResponse, PostureSummary, ScanSummary, ScannerQuality,
SuppressionHygiene, TrendPoint, WeightedFile, by_language_from_findings, compute_fingerprint,
lang_for_finding_path, summarize_findings, top_directories_from_findings, top_n_from_map,
};
use axum::extract::State;
use axum::routing::get;
use crate::server::owasp;
use axum::extract::{Path as AxPath, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
const BASELINE_KEY: &str = "baseline_scan_id";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/overview", get(overview))
.route("/overview/trends", get(overview_trends))
.route("/overview/baseline", post(set_baseline))
.route("/overview/baseline", delete(clear_baseline))
.route("/overview/baseline/{scan_id}", post(set_baseline_path))
}
/// GET /api/overview — aggregated dashboard data.
@ -25,7 +35,7 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
let findings = crate::server::routes::findings::load_latest_findings(&state);
// 2. Collect recent scans (in-memory + DB, deduped)
let recent_scans = collect_recent_scans(&state, 10);
let recent_scans = collect_recent_scans(&state, 20);
// 3. Basic summary
let summary = summarize_findings(&findings);
@ -37,8 +47,10 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
let latest_scan_at = latest_completed.and_then(|s| s.started_at.clone());
let latest_scan_duration = latest_completed.and_then(|s| s.duration_secs);
// 5. New/fixed since last scan
let (new_since_last, fixed_since_last) = compute_delta(&state, &findings);
// 5. Walk historical scans once for delta + posture + backlog + drift.
let history = ScanHistory::load(&state, 20);
let (new_since_last, fixed_since_last, reintroduced_count) =
history.compare_to_current(&findings);
// 6. High confidence rate
let high_confidence_rate = if findings.is_empty() {
@ -67,6 +79,7 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
&summary,
new_since_last,
fixed_since_last,
reintroduced_count,
triage_coverage,
&noisy_rules,
);
@ -80,6 +93,51 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
"normal".to_string()
};
// ── New (Tier 1/2/3) ──
let confidence_distribution = Some(compute_confidence_distribution(&findings));
let weighted_top_files = compute_weighted_top_files(&findings, 10);
let cross_file_ratio = Some(compute_cross_file_ratio(&findings));
let hot_sinks = compute_hot_sinks(&findings, 5);
let owasp_buckets = owasp::bucket_findings(&summary.by_rule);
let issue_categories = owasp::issue_categories(&summary.by_rule);
let scanner_quality =
compute_scanner_quality(&state, &findings, latest_completed.map(|s| s.id.as_str()));
let language_health = compute_language_health(&findings);
let suppression_hygiene = Some(compute_suppression_hygiene(&state, &findings));
let backlog = Some(compute_backlog(&state, &findings, &history));
let baseline = compute_baseline_info(&state, &findings);
let posture = Some(build_posture(
new_since_last,
fixed_since_last,
reintroduced_count,
&history,
summary.total,
));
let health = Some(crate::server::health::compute(
&crate::server::health::HealthInputs {
summary: &summary,
findings: &findings,
triage_coverage,
new_since_last,
fixed_since_last,
reintroduced: reintroduced_count,
// Files-scanned proxy for repo size — used for size-aware
// severity dampening in `health::compute`. See
// `docs/health-score-audit.md` for calibration data.
repo_files: scanner_quality
.as_ref()
.map(|q| q.files_scanned)
.filter(|&f| f > 0),
backlog: backlog.as_ref(),
// Trend is meaningless without ≥2 completed scans —
// matches the first-scan check `compare_to_current` uses.
has_history: history.scans.len() >= 2,
// Suppression-hygiene modifier — populated when the
// suppression panel was computable for this scan.
blanket_suppression_rate: suppression_hygiene.as_ref().map(|s| s.blanket_rate),
},
));
Json(OverviewResponse {
state: state_str,
total_findings: summary.total,
@ -90,8 +148,8 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
latest_scan_duration_secs: latest_scan_duration,
latest_scan_id,
latest_scan_at,
by_severity: summary.by_severity,
by_category: summary.by_category,
by_severity: summary.by_severity.clone(),
by_category: summary.by_category.clone(),
by_language,
top_files,
top_directories,
@ -99,6 +157,19 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
noisy_rules,
recent_scans: recent_scans.into_iter().take(10).collect(),
insights,
health,
posture,
backlog,
weighted_top_files,
confidence_distribution,
scanner_quality,
issue_categories,
hot_sinks,
owasp_buckets,
cross_file_ratio,
baseline,
language_health,
suppression_hygiene,
})
}
@ -142,8 +213,198 @@ async fn overview_trends(State(state): State<AppState>) -> Json<Vec<TrendPoint>>
Json(points)
}
#[derive(Debug, Deserialize)]
struct BaselineBody {
scan_id: String,
}
/// POST /api/overview/baseline { scan_id } — pin a scan as the baseline for drift comparison.
async fn set_baseline(
State(state): State<AppState>,
Json(body): Json<BaselineBody>,
) -> Result<StatusCode, StatusCode> {
set_baseline_inner(&state, &body.scan_id)
}
/// POST /api/overview/baseline/:scan_id — convenience path-form for clients without a JSON body.
async fn set_baseline_path(
State(state): State<AppState>,
AxPath(scan_id): AxPath<String>,
) -> Result<StatusCode, StatusCode> {
set_baseline_inner(&state, &scan_id)
}
fn set_baseline_inner(state: &AppState, scan_id: &str) -> Result<StatusCode, StatusCode> {
if scan_id.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let pool = state
.db_pool
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
idx.set_metadata(BASELINE_KEY, scan_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
/// DELETE /api/overview/baseline — clear the pinned baseline.
async fn clear_baseline(State(state): State<AppState>) -> Result<StatusCode, StatusCode> {
let pool = state
.db_pool
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
idx.delete_metadata(BASELINE_KEY)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Cached view of recent completed scans' fingerprints + timestamps. Built once
/// per overview request and reused by delta / posture / backlog / drift.
struct ScanHistory {
/// Completed scans, oldest → newest.
scans: Vec<HistoricScan>,
/// fingerprint → earliest started_at (RFC-3339) seen across history.
first_seen: HashMap<String, String>,
}
struct HistoricScan {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
started_at: Option<String>,
fingerprints: HashSet<String>,
total: usize,
}
impl ScanHistory {
fn load(state: &AppState, limit: usize) -> Self {
let mut scans = Vec::new();
let mut first_seen: HashMap<String, String> = HashMap::new();
let Some(ref pool) = state.db_pool else {
return Self { scans, first_seen };
};
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
return Self { scans, first_seen };
};
let mut records = idx.list_scans(limit as i64).unwrap_or_default();
// Filter to completed and reverse to oldest-first.
records.retain(|r| r.status == "completed");
records.reverse();
let mut bulk_inserts: Vec<(String, String)> = Vec::new();
for r in records {
let fps: HashSet<String> = r
.findings_json
.as_deref()
.and_then(|j| serde_json::from_str::<Vec<Diag>>(j).ok())
.map(|diags| diags.iter().map(compute_fingerprint).collect())
.unwrap_or_default();
let total = fps.len();
let started_at = r.started_at.clone();
// Seed first_seen for new fingerprints.
if let Some(ref ts) = started_at {
for fp in &fps {
first_seen.entry(fp.clone()).or_insert_with(|| {
bulk_inserts.push((fp.clone(), ts.clone()));
ts.clone()
});
}
}
scans.push(HistoricScan {
id: r.id,
started_at,
fingerprints: fps,
total,
});
}
// Persist newly observed first-seen entries (best-effort; ignore errors).
if !bulk_inserts.is_empty() {
let _ = idx.record_finding_first_seen_bulk(&bulk_inserts);
}
Self { scans, first_seen }
}
/// Compare current findings against the most-recent historical scan and
/// against all earlier scans for regression detection.
/// Returns (new_count, fixed_count, reintroduced_count).
fn compare_to_current(&self, current: &[Diag]) -> (usize, usize, usize) {
if self.scans.is_empty() {
return (0, 0, 0);
}
let current_fps: HashSet<String> = current.iter().map(compute_fingerprint).collect();
// For new/fixed delta, compare against the *previous* completed scan
// (i.e. the one before the latest, since the latest is "current" in DB
// most of the time). If only one scan exists, treat all as new.
let (new_count, fixed_count) = if self.scans.len() >= 2 {
let prev = &self.scans[self.scans.len() - 2];
let new_count = current_fps.difference(&prev.fingerprints).count();
let fixed_count = prev.fingerprints.difference(&current_fps).count();
(new_count, fixed_count)
} else {
(0, 0)
};
// Regression: fingerprints that were present in some past scan, were
// absent in the immediately-preceding scan, and are present now.
let reintroduced = if self.scans.len() >= 2 {
let prev_fps = &self.scans[self.scans.len() - 2].fingerprints;
let mut count = 0usize;
for fp in current_fps.iter() {
if prev_fps.contains(fp) {
continue;
}
// Was present in any earlier scan?
let earlier = self
.scans
.iter()
.take(self.scans.len() - 2)
.any(|s| s.fingerprints.contains(fp));
if earlier {
count += 1;
}
}
count
} else {
0
};
(new_count, fixed_count, reintroduced)
}
/// Trend slope across the last N totals — 1.0 means strictly improving,
/// -1.0 strictly regressing, 0.0 unchanged. Returns None with <3 points.
fn trend_slope(&self) -> Option<f64> {
if self.scans.len() < 3 {
return None;
}
let tail: Vec<f64> = self
.scans
.iter()
.rev()
.take(5)
.map(|s| s.total as f64)
.collect();
let first = *tail.last()?;
let last = *tail.first()?;
if first <= 0.0 && last <= 0.0 {
return Some(0.0);
}
// Improving = total decreased → positive score. Normalize by max.
let max = first.max(last).max(1.0);
Some(((first - last) / max).clamp(-1.0, 1.0))
}
}
/// Collect recent scans from in-memory jobs + DB, deduped by ID.
fn collect_recent_scans(state: &AppState, limit: usize) -> Vec<ScanSummary> {
let mut seen = HashSet::new();
@ -181,55 +442,11 @@ fn collect_recent_scans(state: &AppState, limit: usize) -> Vec<ScanSummary> {
}
}
// Sort by started_at descending
scans.sort_by(|a, b| b.started_at.cmp(&a.started_at));
scans.truncate(limit);
scans
}
/// Compute new/fixed finding counts by comparing the two most recent completed scans.
fn compute_delta(state: &AppState, current_findings: &[Diag]) -> (usize, usize) {
if current_findings.is_empty() {
return (0, 0);
}
let current_fps: HashSet<String> = current_findings.iter().map(compute_fingerprint).collect();
// Find previous completed scan's findings
let previous_fps = load_previous_scan_fingerprints(state);
if previous_fps.is_empty() {
return (0, 0);
}
let new_count = current_fps.difference(&previous_fps).count();
let fixed_count = previous_fps.difference(&current_fps).count();
(new_count, fixed_count)
}
/// Load fingerprints from the second-most-recent completed scan.
fn load_previous_scan_fingerprints(state: &AppState) -> HashSet<String> {
if let Some(ref pool) = state.db_pool {
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
if let Ok(scans) = idx.list_scans(10) {
let completed: Vec<&ScanRecord> = scans
.iter()
.filter(|s| s.status == "completed" && s.findings_json.is_some())
.collect();
// Skip the first (latest) completed scan — we want the previous one
if let Some(prev) = completed.get(1) {
if let Some(json) = prev.findings_json.as_deref() {
if let Ok(diags) = serde_json::from_str::<Vec<Diag>>(json) {
return diags.iter().map(compute_fingerprint).collect();
}
}
}
}
}
}
HashSet::new()
}
/// Compute triage coverage: fraction of findings with non-"open" triage state.
fn compute_triage_coverage(state: &AppState, findings: &[Diag]) -> f64 {
if findings.is_empty() {
@ -249,24 +466,19 @@ fn compute_triage_coverage(state: &AppState, findings: &[Diag]) -> f64 {
let mut non_open = 0usize;
for d in findings {
let fp = compute_fingerprint(d);
// Check explicit triage state
if let Some((triage_state, _, _)) = triage_map.get(&fp) {
if triage_state != "open" {
non_open += 1;
continue;
}
}
// Check suppression rules
let path = &d.path;
let rule_id = &d.id;
for rule in &suppression_rules {
let matches = match rule.suppress_by.as_str() {
"fingerprint" => fp == rule.match_value,
"rule" => *rule_id == rule.match_value,
"rule_in_file" => {
let key = format!("{rule_id}:{path}");
key == rule.match_value
}
"rule_in_file" => format!("{rule_id}:{path}") == rule.match_value,
"file" => *path == rule.match_value,
_ => false,
};
@ -296,7 +508,6 @@ fn compute_noisy_rules(
let triage_map = idx.get_all_triage_states().unwrap_or_default();
let suppression_rules = idx.get_suppression_rules().unwrap_or_default();
// Count suppressed findings per rule
let mut suppressed_per_rule: HashMap<String, usize> = HashMap::new();
for d in findings {
let fp = compute_fingerprint(d);
@ -347,12 +558,12 @@ fn generate_insights(
summary: &crate::server::models::FindingSummary,
new_since_last: usize,
fixed_since_last: usize,
reintroduced: usize,
triage_coverage: f64,
noisy_rules: &[NoisyRule],
) -> Vec<Insight> {
let mut insights = Vec::new();
// Untriaged high findings
let high_count = summary.by_severity.get("HIGH").copied().unwrap_or(0);
if high_count > 0 {
insights.push(Insight {
@ -366,7 +577,18 @@ fn generate_insights(
});
}
// New findings since last scan
if reintroduced > 0 {
insights.push(Insight {
kind: "regression".into(),
message: format!(
"{reintroduced} previously-fixed finding{} reintroduced",
if reintroduced == 1 { "" } else { "s" }
),
severity: "danger".into(),
action_url: Some("/findings".into()),
});
}
if new_since_last > 0 {
insights.push(Insight {
kind: "new_findings".into(),
@ -379,7 +601,6 @@ fn generate_insights(
});
}
// Fixed findings since last scan
if fixed_since_last > 0 {
insights.push(Insight {
kind: "fixed_findings".into(),
@ -392,7 +613,6 @@ fn generate_insights(
});
}
// Noisy rules
for rule in noisy_rules.iter().take(3) {
insights.push(Insight {
kind: "noisy_rule".into(),
@ -407,7 +627,6 @@ fn generate_insights(
});
}
// Low triage coverage
if triage_coverage < 0.1 && summary.total > 20 {
insights.push(Insight {
kind: "low_triage".into(),
@ -435,3 +654,481 @@ fn is_fresh_scan(scan: Option<&ScanSummary>) -> bool {
}
false
}
// ── Tier 1/2/3 computations ──────────────────────────────────────────────────
fn compute_confidence_distribution(findings: &[Diag]) -> ConfidenceDistribution {
let mut d = ConfidenceDistribution::default();
for f in findings {
match f.confidence {
Some(Confidence::High) => d.high += 1,
Some(Confidence::Medium) => d.medium += 1,
Some(Confidence::Low) => d.low += 1,
None => d.none += 1,
}
}
d
}
fn compute_weighted_top_files(findings: &[Diag], limit: usize) -> Vec<WeightedFile> {
use crate::patterns::Severity;
let mut per_file: HashMap<String, [usize; 3]> = HashMap::new(); // [high, medium, low]
for f in findings {
let entry = per_file.entry(f.path.clone()).or_insert([0, 0, 0]);
match f.severity {
Severity::High => entry[0] += 1,
Severity::Medium => entry[1] += 1,
Severity::Low => entry[2] += 1,
}
}
let mut rows: Vec<WeightedFile> = per_file
.into_iter()
.map(|(name, [h, m, l])| WeightedFile {
name,
score: (h * 10 + m * 3 + l) as u32,
high: h,
medium: m,
low: l,
total: h + m + l,
})
.collect();
rows.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.total.cmp(&a.total)));
rows.truncate(limit);
rows
}
fn compute_cross_file_ratio(findings: &[Diag]) -> f64 {
if findings.is_empty() {
return 0.0;
}
let mut cross = 0usize;
for f in findings {
if let Some(ev) = f.evidence.as_ref() {
if ev.uses_summary || ev.flow_steps.iter().any(|s| s.is_cross_file) {
cross += 1;
}
}
}
cross as f64 / findings.len() as f64
}
/// Hot sinks are *only* meaningful for taint findings — counting AST rule IDs
/// (e.g. `rs.quality.unwrap`) here just duplicates the Top Rules table. So we
/// deliberately require a real Sink-step callee (or a parsable sink snippet)
/// and skip everything else. Empty result → frontend hides the card.
fn compute_hot_sinks(findings: &[Diag], limit: usize) -> Vec<HotSink> {
let mut counts: HashMap<String, usize> = HashMap::new();
for f in findings {
let Some(ev) = f.evidence.as_ref() else {
continue;
};
let from_flow = ev
.flow_steps
.iter()
.rev()
.find(|s| matches!(s.kind, crate::evidence::FlowStepKind::Sink))
.and_then(|s| s.callee.clone())
.filter(|c| !c.trim().is_empty());
let from_sink_snippet = ev
.sink
.as_ref()
.and_then(|s| s.snippet.as_ref())
.and_then(|s| {
let c = extract_callee_from_snippet(s);
if c.is_empty() { None } else { Some(c) }
});
let Some(callee) = from_flow.or(from_sink_snippet) else {
continue;
};
*counts.entry(callee).or_insert(0) += 1;
}
let mut rows: Vec<HotSink> = counts
.into_iter()
.map(|(callee, count)| HotSink { callee, count })
.collect();
rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.callee.cmp(&b.callee)));
rows.truncate(limit);
rows
}
/// Pull the leading identifier from a sink snippet — a best-effort heuristic
/// for the dashboard's "hot sinks" list.
fn extract_callee_from_snippet(s: &str) -> String {
let trimmed = s.trim();
let end = trimmed
.find('(')
.or_else(|| trimmed.find(char::is_whitespace))
.unwrap_or(trimmed.len());
trimmed[..end].trim().to_string()
}
fn compute_scanner_quality(
state: &AppState,
findings: &[Diag],
latest_scan_id: Option<&str>,
) -> Option<ScannerQuality> {
let pool = state.db_pool.as_ref()?;
let idx = Indexer::from_pool("_scans", pool).ok()?;
let mut files_scanned = 0u64;
let mut files_skipped = 0u64;
if let Some(scan_id) = latest_scan_id {
let scans = idx.list_scans(20).unwrap_or_default();
if let Some(rec) = scans.into_iter().find(|s| s.id == scan_id) {
files_scanned = rec.files_scanned.unwrap_or(0).max(0) as u64;
files_skipped = rec.files_skipped.unwrap_or(0).max(0) as u64;
}
}
let parse_success_rate = if files_scanned + files_skipped > 0 {
files_scanned as f64 / (files_scanned + files_skipped) as f64
} else {
0.0
};
// Engine metrics from scan_metrics table (if available via Indexer).
let (functions_analyzed, call_edges, unresolved_calls) = latest_scan_id
.and_then(|id| idx.get_scan_metrics(id).ok().flatten())
.map(|m| (m.functions_analyzed, m.call_edges, m.unresolved_calls))
.unwrap_or((0, 0, 0));
let call_resolution_rate = if call_edges + unresolved_calls > 0 {
call_edges as f64 / (call_edges + unresolved_calls) as f64
} else {
0.0
};
// Symex coverage from current findings.
let mut breakdown: HashMap<String, usize> = HashMap::new();
let mut taint_total = 0usize;
for f in findings {
let Some(ev) = f.evidence.as_ref() else {
continue;
};
let Some(sv) = ev.symbolic.as_ref() else {
continue;
};
taint_total += 1;
let label = match sv.verdict {
Verdict::Confirmed => "confirmed",
Verdict::Infeasible => "infeasible",
Verdict::Inconclusive => "inconclusive",
Verdict::NotAttempted => "not_attempted",
};
*breakdown.entry(label.to_string()).or_insert(0) += 1;
}
let symex_verified_rate = if taint_total > 0 {
let attempted = breakdown
.iter()
.filter(|(k, _)| k.as_str() != "not_attempted")
.map(|(_, v)| *v)
.sum::<usize>();
attempted as f64 / taint_total as f64
} else {
0.0
};
Some(ScannerQuality {
files_scanned,
files_skipped,
parse_success_rate,
functions_analyzed,
call_edges,
unresolved_calls,
call_resolution_rate,
symex_verified_rate,
symex_breakdown: breakdown,
})
}
fn compute_language_health(findings: &[Diag]) -> Vec<LanguageHealth> {
use crate::patterns::Severity;
let mut per_lang: HashMap<String, [usize; 4]> = HashMap::new(); // [total, h, m, l]
for f in findings {
let Some(lang) = lang_for_finding_path(&f.path) else {
continue;
};
let entry = per_lang.entry(lang).or_insert([0; 4]);
entry[0] += 1;
match f.severity {
Severity::High => entry[1] += 1,
Severity::Medium => entry[2] += 1,
Severity::Low => entry[3] += 1,
}
}
let mut rows: Vec<LanguageHealth> = per_lang
.into_iter()
.map(|(language, [t, h, m, l])| LanguageHealth {
language,
findings: t,
high: h,
medium: m,
low: l,
})
.collect();
rows.sort_by(|a, b| {
b.high
.cmp(&a.high)
.then_with(|| b.findings.cmp(&a.findings))
});
rows
}
fn compute_suppression_hygiene(state: &AppState, findings: &[Diag]) -> SuppressionHygiene {
let mut hygiene = SuppressionHygiene {
fingerprint_level: 0,
rule_level: 0,
file_level: 0,
rule_in_file_level: 0,
blanket_rate: 0.0,
};
if findings.is_empty() {
return hygiene;
}
let Some(ref pool) = state.db_pool else {
return hygiene;
};
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
return hygiene;
};
let triage_map = idx.get_all_triage_states().unwrap_or_default();
let suppression_rules = idx.get_suppression_rules().unwrap_or_default();
let mut total_suppressed = 0usize;
for d in findings {
let fp = compute_fingerprint(d);
if let Some((s, _, _)) = triage_map.get(&fp) {
if s == "suppressed" || s == "false_positive" {
hygiene.fingerprint_level += 1;
total_suppressed += 1;
continue;
}
}
for rule in &suppression_rules {
let matched = match rule.suppress_by.as_str() {
"fingerprint" => fp == rule.match_value,
"rule" => d.id == rule.match_value,
"rule_in_file" => format!("{}:{}", d.id, d.path) == rule.match_value,
"file" => d.path == rule.match_value,
_ => false,
};
if matched {
match rule.suppress_by.as_str() {
"fingerprint" => hygiene.fingerprint_level += 1,
"rule" => hygiene.rule_level += 1,
"file" => hygiene.file_level += 1,
"rule_in_file" => hygiene.rule_in_file_level += 1,
_ => {}
}
total_suppressed += 1;
break;
}
}
}
if total_suppressed > 0 {
let blanket = hygiene.rule_level + hygiene.file_level + hygiene.rule_in_file_level;
hygiene.blanket_rate = blanket as f64 / total_suppressed as f64;
}
hygiene
}
fn compute_backlog(state: &AppState, findings: &[Diag], history: &ScanHistory) -> BacklogStats {
// No useful aging data on the first scan — every fingerprint was first-seen
// today by definition. Avoid the misleading "0d / 0d / 0" display.
if history.scans.len() <= 1 {
return BacklogStats {
oldest_open_days: None,
median_age_days: None,
stale_count: 0,
age_buckets: Vec::new(),
};
}
let now = chrono::Utc::now();
// Pull DB-cached first_seen first; fall back to in-memory history map.
let fingerprints: Vec<String> = findings.iter().map(compute_fingerprint).collect();
let mut cached: HashMap<String, String> = HashMap::new();
if let Some(ref pool) = state.db_pool {
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
cached = idx.get_first_seen_map(&fingerprints).unwrap_or_default();
}
}
// Merge history's view (already persisted as we walked).
for (fp, ts) in &history.first_seen {
cached.entry(fp.clone()).or_insert_with(|| ts.clone());
}
let mut ages_days: Vec<u32> = Vec::with_capacity(fingerprints.len());
for fp in &fingerprints {
let Some(ts) = cached.get(fp) else {
continue;
};
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
let elapsed = now - dt.with_timezone(&chrono::Utc);
let days = elapsed.num_days().max(0) as u32;
ages_days.push(days);
}
}
let oldest_open_days = ages_days.iter().copied().max();
let median_age_days = if ages_days.is_empty() {
None
} else {
let mut sorted = ages_days.clone();
sorted.sort_unstable();
Some(sorted[sorted.len() / 2])
};
let stale_count = ages_days.iter().filter(|d| **d > 30).count();
// Buckets: ≤1d, ≤7d, ≤30d, ≤90d, >90d
let mut b = [0usize; 5];
for d in &ages_days {
let i = match *d {
0..=1 => 0,
2..=7 => 1,
8..=30 => 2,
31..=90 => 3,
_ => 4,
};
b[i] += 1;
}
let labels = ["≤1d", "≤7d", "≤30d", "≤90d", ">90d"];
let age_buckets = labels
.iter()
.zip(b.iter())
.map(|(l, c)| OverviewCount {
name: (*l).to_string(),
count: *c,
})
.collect();
BacklogStats {
oldest_open_days,
median_age_days,
stale_count,
age_buckets,
}
}
fn compute_baseline_info(state: &AppState, findings: &[Diag]) -> Option<BaselineInfo> {
let pool = state.db_pool.as_ref()?;
let idx = Indexer::from_pool("_scans", pool).ok()?;
let scan_id = idx.get_metadata(BASELINE_KEY).ok().flatten()?;
if scan_id.is_empty() {
return None;
}
// Look up baseline scan record (separate from history, since history is capped at 20).
let scans = idx.list_scans(200).ok()?;
let baseline = scans.into_iter().find(|s| s.id == scan_id)?;
let baseline_fps: HashSet<String> = baseline
.findings_json
.as_deref()
.and_then(|j| serde_json::from_str::<Vec<Diag>>(j).ok())
.map(|diags| diags.iter().map(compute_fingerprint).collect())
.unwrap_or_default();
let current_fps: HashSet<String> = findings.iter().map(compute_fingerprint).collect();
let drift_new = current_fps.difference(&baseline_fps).count();
let drift_fixed = baseline_fps.difference(&current_fps).count();
Some(BaselineInfo {
scan_id: baseline.id,
started_at: baseline.started_at,
baseline_total: baseline_fps.len(),
drift_new,
drift_fixed,
})
}
fn build_posture(
new_since_last: usize,
fixed_since_last: usize,
reintroduced: usize,
history: &ScanHistory,
current_total: usize,
) -> PostureSummary {
// First-scan case: no prior data to diff against. Saying "stable / no change"
// is misleading — we genuinely don't know yet.
if history.scans.len() <= 1 {
return PostureSummary {
trend: "unknown".into(),
severity: "info".into(),
message: format!(
"First scan: {current_total} finding{} detected. Re-scan to compare.",
plural(current_total)
),
reintroduced_count: 0,
};
}
let net = fixed_since_last as i64 - new_since_last as i64;
let trend_slope = history.trend_slope();
// Severity selection priorities: regressions are loudest.
let (trend, severity, message) = if reintroduced > 0 {
(
"regressing",
"danger",
format!(
"Regressed: {reintroduced} previously-fixed finding{} returned",
plural(reintroduced)
),
)
} else if net > 0 {
(
"improving",
"success",
format!(
"Improving: net {net:+} since last scan ({fixed_since_last} fixed, {new_since_last} new)"
),
)
} else if net < 0 {
(
"regressing",
"warning",
format!(
"Regressing: net {net:+} since last scan ({new_since_last} new, {fixed_since_last} fixed)"
),
)
} else if let Some(slope) = trend_slope {
if slope > 0.1 {
(
"improving",
"success",
"Improving: gradual decline in finding count over the last 5 scans".to_string(),
)
} else if slope < -0.1 {
(
"regressing",
"warning",
"Regressing: gradual rise in finding count over the last 5 scans".to_string(),
)
} else {
(
"stable",
"info",
"Stable: no net change since last scan".to_string(),
)
}
} else {
(
"stable",
"info",
"Stable: no net change since last scan".to_string(),
)
};
PostureSummary {
trend: trend.to_string(),
severity: severity.to_string(),
message,
reintroduced_count: reintroduced,
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
// `compute_health_score` moved to `crate::server::health::compute`
// after the v2 audit (2026-04-28). See `docs/health-score-audit.md`
// for calibration data and the rationale, and `docs/health-score.md`
// for the customer-facing methodology.