From 1148e65f3696070c795d50d3972bf7582e7c2ada Mon Sep 17 00:00:00 2001 From: elipeter Date: Fri, 5 Jun 2026 10:50:25 -0500 Subject: [PATCH 1/2] fix(cli): apply repository triage file during scans --- docs/cli.md | 7 + docs/output.md | 19 +++ src/ast.rs | 10 ++ src/auth_analysis/mod.rs | 2 + src/baseline.rs | 2 + src/commands/scan.rs | 75 ++++++++++- src/database.rs | 2 + src/evidence.rs | 2 + src/fmt.rs | 86 +++++++++++- src/output/sarif.rs | 8 ++ src/patterns/ejs.rs | 2 + src/rank.rs | 2 + src/server/health.rs | 2 + src/server/jobs.rs | 13 ++ src/server/models.rs | 22 +-- src/server/triage_sync.rs | 191 ++++++++++++++++++++++++++- tests/calibration_data_exfil.rs | 2 + tests/chain_edges.rs | 2 + tests/chain_emission.rs | 2 + tests/cli_validation_tests.rs | 88 +++++++++++- tests/common/fixture_harness.rs | 2 + tests/console_snapshot.rs | 2 + tests/determinism_audit.rs | 4 + tests/dynamic_parity.rs | 2 + tests/dynamic_verify_e2e.rs | 4 + tests/engine_notes_rank_tests.rs | 2 + tests/go_fixtures.rs | 2 + tests/health_score_calibration.rs | 2 + tests/java_fixtures.rs | 2 + tests/js_fixtures.rs | 2 + tests/json_snapshot.rs | 2 + tests/lang_detect_probes.rs | 2 + tests/php_fixtures.rs | 2 + tests/policy_deny.rs | 2 + tests/python_fixtures.rs | 2 + tests/rust_fixtures.rs | 2 + tests/sandbox_hardening_linux.rs | 4 + tests/sandbox_hardening_macos.rs | 4 + tests/sarif_dynamic_verdict_tests.rs | 2 + tests/spec_callgraph_resolution.rs | 2 + tests/spec_derivation_strategies.rs | 2 + tests/spec_framework_sample.rs | 2 + 42 files changed, 571 insertions(+), 20 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 9cb27738..0ccaa747 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,6 +82,13 @@ nyx scan [PATH] [OPTIONS] | `--rollup-examples ` | `5` | Number of example locations in rollup findings | | `--show-instances ` | *(none)* | Expand all instances of a specific rule (bypass rollup) | +`nyx scan` automatically reads `.nyx/triage.json` from the scan root when the +file exists. Terminal triage states written by `nyx serve` (`false_positive`, +`accepted_risk`, `suppressed`, and `fixed`) are hidden from CLI output and do +not trigger `--fail-on` by default. Use `--show-suppressed` to include them in +console, JSON, or SARIF output with their `triage_state` and optional +`triage_note`. + **Severity expression formats**: ```bash diff --git a/docs/output.md b/docs/output.md index 42335407..852e3e9e 100644 --- a/docs/output.md +++ b/docs/output.md @@ -282,6 +282,25 @@ Without `--fail-on` or `--gate`, Nyx always exits `0` on a successful scan regar --- +## Repository Triage + +`nyx scan` and `nyx serve` share `.nyx/triage.json` in the scan root. The file +uses portable fingerprints so committed triage decisions survive different +checkout paths in local runs and CI. + +When the file exists, CLI scans apply it automatically: + +- `open` and `investigating` findings remain active. +- `false_positive`, `accepted_risk`, `suppressed`, and `fixed` findings are + excluded from output and `--fail-on` checks by default. +- `--show-suppressed` includes terminal triage findings and emits + `triage_state` plus `triage_note` when present. + +`nyx serve` continues to read and write the same file when triage sync is +enabled, so browser triage and CI gating use the same decisions. + +--- + ## Severity Levels | Level | Description | Typical rules | diff --git a/src/ast.rs b/src/ast.rs index a61ba78a..f9fe33a6 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -99,6 +99,8 @@ fn parse_timeout_diag(path: &Path, timeout_ms: u64) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -711,6 +713,8 @@ fn build_taint_diag( rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: finding.finding_id.clone(), alternative_finding_ids: finding.alternative_finding_ids.to_vec(), @@ -1398,6 +1402,8 @@ impl<'a> ParsedSource<'a> { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -2042,6 +2048,8 @@ impl<'a> ParsedFile<'a> { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -2123,6 +2131,8 @@ impl<'a> ParsedFile<'a> { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/auth_analysis/mod.rs b/src/auth_analysis/mod.rs index 6b9937ad..47a243ef 100644 --- a/src/auth_analysis/mod.rs +++ b/src/auth_analysis/mod.rs @@ -1046,6 +1046,8 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: & rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/baseline.rs b/src/baseline.rs index 3661e6f4..6d529a62 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -406,6 +406,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 4fe03394..ec7aa6cd 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -173,6 +173,20 @@ pub struct Diag { /// Metadata about the suppression directive, if suppressed. #[serde(default, skip_serializing_if = "Option::is_none")] pub suppression: Option, + /// Triage state applied from `.nyx/triage.json`. + /// + /// `open` is the default and is omitted from serialized output. Terminal + /// states (`false_positive`, `accepted_risk`, `suppressed`, `fixed`) are + /// hidden from CLI output and `--fail-on` by default, mirroring the web + /// UI's triage attention queue. + #[serde( + default = "default_triage_state", + skip_serializing_if = "is_default_triage_state" + )] + pub triage_state: String, + /// Optional note carried with a triage decision. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub triage_note: String, /// Rollup data when multiple occurrences are grouped into one finding. #[serde(default, skip_serializing_if = "Option::is_none")] pub rollup: Option, @@ -200,6 +214,25 @@ fn is_zero_u64(v: &u64) -> bool { *v == 0 } +pub fn default_triage_state() -> String { + "open".to_string() +} + +pub fn is_default_triage_state(state: &str) -> bool { + state == "open" +} + +pub fn is_terminal_triage_state(state: &str) -> bool { + matches!( + state, + "false_positive" | "accepted_risk" | "suppressed" | "fixed" + ) +} + +pub fn is_inactive_for_cli(diag: &Diag) -> bool { + diag.suppressed || is_terminal_triage_state(&diag.triage_state) +} + #[cfg(test)] impl Default for Diag { fn default() -> Self { @@ -220,6 +253,8 @@ impl Default for Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: default_triage_state(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], @@ -726,7 +761,27 @@ pub fn handle( // ── Apply inline suppressions ─────────────────────────────────── apply_suppressions(&mut diags); if !show_suppressed { - diags.retain(|d| !d.suppressed); + let triage_summary = + crate::server::triage_sync::apply_triage_file_to_diags(&mut diags, &scan_path) + .map_err(|e| crate::errors::NyxError::Msg(format!("triage sync failed: {e}")))?; + if !suppress_status + && triage_summary.decisions_applied + triage_summary.suppression_rules_applied > 0 + { + eprintln!( + "Applied {} triage decision{} from .nyx/triage.json.", + triage_summary.decisions_applied + triage_summary.suppression_rules_applied, + if triage_summary.decisions_applied + triage_summary.suppression_rules_applied == 1 + { + "" + } else { + "s" + } + ); + } + diags.retain(|d| !is_inactive_for_cli(d)); + } else { + crate::server::triage_sync::apply_triage_file_to_diags(&mut diags, &scan_path) + .map_err(|e| crate::errors::NyxError::Msg(format!("triage sync failed: {e}")))?; } // ── Prioritization: category filter, rollup, LOW budgets ───────── @@ -923,7 +978,7 @@ pub fn handle( if let Some(threshold) = fail_on { let breached = diags .iter() - .any(|d| !d.suppressed && d.severity <= threshold); + .any(|d| !is_inactive_for_cli(d) && d.severity <= threshold); if breached { std::process::exit(1); } @@ -3530,6 +3585,8 @@ fn rollup_findings( rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: Some(RollupData { count: total, occurrences: examples, @@ -3762,6 +3819,8 @@ mod dedup_taint_flow_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -3930,6 +3989,8 @@ mod scc_tagging_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -4222,6 +4283,8 @@ fn severity_filter_applied_at_output_stage() { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -4244,6 +4307,8 @@ fn severity_filter_applied_at_output_stage() { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -4293,6 +4358,8 @@ mod prioritize_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -4724,6 +4791,8 @@ mod prioritize_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: Some(RollupData { count: 38, occurrences: vec![Location { line: 10, col: 1 }, Location { line: 20, col: 5 }], @@ -4814,6 +4883,8 @@ mod stable_hash_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/src/database.rs b/src/database.rs index 263ad893..1c52694f 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1093,6 +1093,8 @@ pub mod index { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/evidence.rs b/src/evidence.rs index db7477e4..9297031f 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -1602,6 +1602,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/fmt.rs b/src/fmt.rs index 0b9c9d7d..675cea3c 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -61,17 +61,20 @@ pub fn render_console( )); } - let suppressed_count = diags.iter().filter(|d| d.suppressed).count(); - let active_count = diags.len() - suppressed_count; + let inactive_count = diags + .iter() + .filter(|d| crate::commands::scan::is_inactive_for_cli(d)) + .count(); + let active_count = diags.len() - inactive_count; - if suppressed_count > 0 { + if inactive_count > 0 { out.push_str(&format!( - "{} '{}' generated {} {} ({} suppressed).\n\n", + "{} '{}' generated {} {} ({} suppressed/triaged).\n\n", style("warning").yellow().bold(), style(project_name).white().bold(), style(active_count).bold(), if active_count == 1 { "issue" } else { "issues" }, - suppressed_count, + inactive_count, )); } else { out.push_str(&format!( @@ -328,6 +331,8 @@ fn render_diag(d: &Diag, width: usize) -> String { let loc = format!("{}:{}", d.line, d.col); let sev = if d.suppressed { format!("{} {}", style("○").dim(), style("[SUPPRESSED]").dim(),) + } else if crate::commands::scan::is_terminal_triage_state(&d.triage_state) { + triage_state_tag(&d.triage_state) } else { severity_tag(d.severity) }; @@ -383,14 +388,25 @@ fn render_diag(d: &Diag, width: usize) -> String { } else { String::new() }; + let triage_suffix = if !crate::commands::scan::is_default_triage_state(&d.triage_state) + && !crate::commands::scan::is_terminal_triage_state(&d.triage_state) + { + format!( + " {}", + style(format!("[triage: {}]", d.triage_state.replace('_', " "))).cyan() + ) + } else { + String::new() + }; out.push_str(&format!( - " {} {} {}{}{}{}\n", + " {} {} {}{}{}{}{}\n", style(&loc).dim(), sev, style(&d.id).dim(), meta_suffix, engine_notes_suffix, alt_suffix, + triage_suffix, )); // ── Rollup body ───────────────────────────────────────────────────── @@ -427,6 +443,21 @@ fn render_diag(d: &Diag, width: usize) -> String { out.push_str(&format!("{indent_str}{wrapped}\n")); } + if !crate::commands::scan::is_default_triage_state(&d.triage_state) { + let label = d.triage_state.replace('_', " "); + let note = if d.triage_note.is_empty() { + String::new() + } else { + format!(" — {}", d.triage_note) + }; + let wrapped = wrap_text(&format!("{label}{note}"), width, BODY_INDENT + 8); + out.push_str(&format!( + "{indent_str}{} {}\n", + style("Triage:").dim(), + style(wrapped).dim(), + )); + } + // ── Evidence labels (Source, Sink, Path guard) ─────────────────────── if !d.labels.is_empty() { out.push('\n'); @@ -663,6 +694,21 @@ fn severity_tag(sev: Severity) -> String { } } +fn triage_state_tag(state: &str) -> String { + let label = state.replace('_', " ").to_ascii_uppercase(); + match state { + "false_positive" | "suppressed" | "fixed" => { + format!("{} {}", style("○").dim(), style(format!("[{label}]")).dim()) + } + "accepted_risk" => format!( + "{} {}", + style("●").yellow(), + style(format!("[{label}]")).yellow(), + ), + _ => format!("{} {}", style("○").dim(), style(format!("[{label}]")).dim()), + } +} + // Text utilities /// Collapse spacing artefacts in method chains. @@ -941,6 +987,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -963,6 +1011,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -999,6 +1049,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1035,6 +1087,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1057,6 +1111,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1091,6 +1147,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1122,6 +1180,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1157,6 +1217,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1251,6 +1313,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1298,6 +1362,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1331,6 +1397,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1368,6 +1436,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1401,6 +1471,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), @@ -1448,6 +1520,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/output/sarif.rs b/src/output/sarif.rs index 8c9ce82f..5c612e56 100644 --- a/src/output/sarif.rs +++ b/src/output/sarif.rs @@ -226,6 +226,12 @@ pub fn build_sarif_with_chains(diags: &[Diag], chains: &[ChainFinding], scan_roo if let Some(conf) = d.confidence { props.insert("confidence".into(), json!(conf.to_string())); } + if !crate::commands::scan::is_default_triage_state(&d.triage_state) { + props.insert("triage_state".into(), json!(d.triage_state)); + if !d.triage_note.is_empty() { + props.insert("triage_note".into(), json!(d.triage_note)); + } + } if let Some(field) = d .evidence @@ -391,6 +397,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/patterns/ejs.rs b/src/patterns/ejs.rs index 7baeba3e..de2f6ee7 100644 --- a/src/patterns/ejs.rs +++ b/src/patterns/ejs.rs @@ -82,6 +82,8 @@ pub fn scan_ejs_file(path: &Path, bytes: &[u8]) -> Vec { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/rank.rs b/src/rank.rs index 18003ba0..d3a87c18 100644 --- a/src/rank.rs +++ b/src/rank.rs @@ -423,6 +423,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/server/health.rs b/src/server/health.rs index ad3707bf..8054d569 100644 --- a/src/server/health.rs +++ b/src/server/health.rs @@ -612,6 +612,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/server/jobs.rs b/src/server/jobs.rs index ff3e032a..42e9b404 100644 --- a/src/server/jobs.rs +++ b/src/server/jobs.rs @@ -292,6 +292,19 @@ impl JobManager { for d in &mut diags { d.stable_hash = scan::compute_stable_hash(d); } + if config.server.triage_sync + && let Some(ref pool) = db_pool + { + match crate::server::triage_sync::sync_from_file(pool, &diags, &scan_root) { + Some(applied) if applied > 0 => log_collector.info( + format!( + "Imported {applied} triage decisions from .nyx/triage.json" + ), + None, + ), + _ => {} + } + } let dynamic_summary = scan::DynamicVerificationSummary::from_diags(&diags); if !dynamic_summary.is_empty() { log_collector.info( diff --git a/src/server/models.rs b/src/server/models.rs index b5e143d2..6148f9df 100644 --- a/src/server/models.rs +++ b/src/server/models.rs @@ -233,7 +233,7 @@ pub fn collect_filter_values(findings: &[Diag]) -> FilterValues { languages.insert(lang); } rules.insert(d.id.clone()); - statuses.insert(status_for_diag(d).to_string()); + statuses.insert(status_for_diag(d)); verification_statuses.insert( dynamic_status_for_diag(d) .unwrap_or("Unverified") @@ -279,13 +279,15 @@ pub fn lang_for_finding_path(path: &str) -> Option { } /// Compute the status string for a diagnostic. -fn status_for_diag(d: &Diag) -> &'static str { - if d.suppressed { - "suppressed" +fn status_for_diag(d: &Diag) -> String { + if !crate::commands::scan::is_default_triage_state(&d.triage_state) { + d.triage_state.clone() + } else if d.suppressed { + "suppressed".to_string() } else if d.path_validated { - "validated" + "validated".to_string() } else { - "open" + "open".to_string() } } @@ -332,9 +334,9 @@ pub fn finding_from_diag(index: usize, d: &Diag) -> FindingView { 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(), + status: status_for_diag(d), + triage_state: d.triage_state.clone(), + triage_note: d.triage_note.clone(), code_context: None, evidence: None, dynamic_verdict: d @@ -937,6 +939,8 @@ mod tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/src/server/triage_sync.rs b/src/server/triage_sync.rs index 72903618..8dc54a83 100644 --- a/src/server/triage_sync.rs +++ b/src/server/triage_sync.rs @@ -7,9 +7,9 @@ //! project root, so they match across machines regardless of where the repo is //! checked out. -use crate::commands::scan::Diag; +use crate::commands::scan::{Diag, is_terminal_triage_state}; use crate::database::index::Indexer; -use crate::server::models::compute_portable_fingerprint; +use crate::server::models::{compute_fingerprint, compute_portable_fingerprint}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use serde::{Deserialize, Serialize}; @@ -73,6 +73,14 @@ fn default_suppressed() -> String { "suppressed".to_string() } +/// Summary of a triage file applied to a set of current findings. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct TriageApplySummary { + pub decisions_applied: usize, + pub suppression_rules_applied: usize, + pub inactive_findings: usize, +} + /// Path to the triage sync file for a given scan root. pub fn triage_file_path(scan_root: &Path) -> Result { let root = canonical_scan_root(scan_root)?; @@ -171,6 +179,98 @@ pub fn save_triage_file(scan_root: &Path, file: &TriageFile) -> Result<(), Strin Ok(()) } +fn validate_triage_state(state: &str) -> Result<(), String> { + if crate::server::models::is_valid_triage_state(state) { + Ok(()) + } else { + Err(format!("invalid triage state in .nyx/triage.json: {state}")) + } +} + +fn diag_relative_path(d: &Diag, scan_root: &Path) -> String { + d.path + .strip_prefix(scan_root.to_string_lossy().as_ref()) + .unwrap_or(&d.path) + .trim_start_matches('/') + .to_string() +} + +fn suppression_rule_matches( + rule: &TriageSuppressionRule, + d: &Diag, + scan_root: &Path, + portable_fp: &str, +) -> bool { + let rel_path = diag_relative_path(d, scan_root); + match rule.by.as_str() { + // Prefer portable fingerprints for committed triage files, but accept + // local fingerprints for hand-written files and older exports. + "fingerprint" => rule.value == portable_fp || rule.value == compute_fingerprint(d), + "rule" => rule.value == d.id, + "file" => rule.value == d.path || rule.value == rel_path, + "rule_in_file" => { + rule.value == format!("{}:{}", d.id, d.path) + || rule.value == format!("{}:{rel_path}", d.id) + } + _ => false, + } +} + +/// Apply a loaded triage file directly to diagnostics. +/// +/// This is the CLI-facing equivalent of [`import_triage`]: it uses the same +/// portable fingerprint format as the server sync file, but annotates the +/// in-memory findings instead of first writing through SQLite. +pub fn apply_triage_to_diags( + findings: &mut [Diag], + scan_root: &Path, + file: &TriageFile, +) -> Result { + let mut decisions: HashMap<&str, &TriageDecision> = HashMap::new(); + for decision in &file.decisions { + validate_triage_state(&decision.state)?; + decisions.insert(decision.fingerprint.as_str(), decision); + } + for rule in &file.suppression_rules { + validate_triage_state(&rule.state)?; + } + + let mut summary = TriageApplySummary::default(); + for d in findings { + let portable_fp = compute_portable_fingerprint(d, scan_root); + if let Some(decision) = decisions.get(portable_fp.as_str()) { + d.triage_state = decision.state.clone(); + d.triage_note = decision.note.clone(); + summary.decisions_applied += 1; + } else if let Some(rule) = file + .suppression_rules + .iter() + .find(|rule| suppression_rule_matches(rule, d, scan_root, &portable_fp)) + { + d.triage_state = rule.state.clone(); + d.triage_note = rule.note.clone(); + summary.suppression_rules_applied += 1; + } + + if is_terminal_triage_state(&d.triage_state) { + summary.inactive_findings += 1; + } + } + + Ok(summary) +} + +/// Load `.nyx/triage.json`, if present, and apply it to diagnostics. +pub fn apply_triage_file_to_diags( + findings: &mut [Diag], + scan_root: &Path, +) -> Result { + let Some(file) = load_triage_file_checked(scan_root)? else { + return Ok(TriageApplySummary::default()); + }; + apply_triage_to_diags(findings, scan_root, &file) +} + fn read_bounded_text_file(path: &Path, max_bytes: u64) -> Result { let file = std::fs::File::open(path).map_err(|e| format!("failed to open file: {e}"))?; let metadata = file @@ -271,6 +371,7 @@ pub fn import_triage( // Import decisions for decision in &file.decisions { + validate_triage_state(&decision.state)?; if let Some(local_fp) = portable_to_local.get(&decision.fingerprint) { let _ = idx.set_triage_state(local_fp, &decision.state, &decision.note, "import"); applied += 1; @@ -279,6 +380,7 @@ pub fn import_triage( // Import suppression rules for rule in &file.suppression_rules { + validate_triage_state(&rule.state)?; let _ = idx.add_suppression_rule(&rule.by, &rule.value, &rule.state, &rule.note); } @@ -312,6 +414,16 @@ pub fn sync_to_file( mod tests { use super::*; + fn test_diag(root: &Path, path: &str, rule_id: &str) -> Diag { + Diag { + path: root.join(path).to_string_lossy().to_string(), + id: rule_id.to_string(), + line: 10, + col: 2, + ..Diag::default() + } + } + #[test] fn oversized_triage_files_are_rejected() { let root = tempfile::tempdir().unwrap(); @@ -340,6 +452,81 @@ mod tests { ); } + #[test] + fn apply_triage_to_diags_matches_portable_fingerprints() { + let root = tempfile::tempdir().unwrap(); + let mut findings = vec![test_diag(root.path(), "src/app.js", "js.security.eval")]; + let fingerprint = compute_portable_fingerprint(&findings[0], root.path()); + let file = TriageFile { + version: 1, + decisions: vec![TriageDecision { + fingerprint, + state: "false_positive".to_string(), + note: "framework sanitizer handles this".to_string(), + rule_id: "js.security.eval".to_string(), + path: "src/app.js".to_string(), + }], + suppression_rules: vec![], + }; + + let summary = apply_triage_to_diags(&mut findings, root.path(), &file).unwrap(); + + assert_eq!(summary.decisions_applied, 1); + assert_eq!(summary.inactive_findings, 1); + assert_eq!(findings[0].triage_state, "false_positive"); + assert_eq!(findings[0].triage_note, "framework sanitizer handles this"); + assert!(crate::commands::scan::is_inactive_for_cli(&findings[0])); + } + + #[test] + fn apply_triage_to_diags_matches_suppression_rules_by_portable_path() { + let root = tempfile::tempdir().unwrap(); + let mut findings = vec![ + test_diag(root.path(), "src/app.js", "js.security.eval"), + test_diag(root.path(), "src/other.js", "js.security.eval"), + ]; + let file = TriageFile { + version: 1, + decisions: vec![], + suppression_rules: vec![TriageSuppressionRule { + by: "rule_in_file".to_string(), + value: "js.security.eval:src/app.js".to_string(), + state: "suppressed".to_string(), + note: "test-only shim".to_string(), + }], + }; + + let summary = apply_triage_to_diags(&mut findings, root.path(), &file).unwrap(); + + assert_eq!(summary.suppression_rules_applied, 1); + assert_eq!(summary.inactive_findings, 1); + assert_eq!(findings[0].triage_state, "suppressed"); + assert_eq!(findings[0].triage_note, "test-only shim"); + assert_eq!(findings[1].triage_state, "open"); + } + + #[test] + fn apply_triage_to_diags_rejects_invalid_states() { + let root = tempfile::tempdir().unwrap(); + let mut findings = vec![test_diag(root.path(), "src/app.js", "js.security.eval")]; + let fingerprint = compute_portable_fingerprint(&findings[0], root.path()); + let file = TriageFile { + version: 1, + decisions: vec![TriageDecision { + fingerprint, + state: "maybe_later".to_string(), + note: String::new(), + rule_id: String::new(), + path: String::new(), + }], + suppression_rules: vec![], + }; + + let err = apply_triage_to_diags(&mut findings, root.path(), &file).unwrap_err(); + + assert!(err.contains("invalid triage state")); + } + #[cfg(unix)] #[test] fn load_triage_file_rejects_symlink_escape() { diff --git a/tests/calibration_data_exfil.rs b/tests/calibration_data_exfil.rs index 628da3e7..2cfc67b9 100644 --- a/tests/calibration_data_exfil.rs +++ b/tests/calibration_data_exfil.rs @@ -99,6 +99,8 @@ fn make_diag( rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/chain_edges.rs b/tests/chain_edges.rs index bbfe1918..4b2ece0f 100644 --- a/tests/chain_edges.rs +++ b/tests/chain_edges.rs @@ -52,6 +52,8 @@ fn diag_with_caps(path: &str, line: usize, caps: Cap) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/chain_emission.rs b/tests/chain_emission.rs index 9501c2ce..c3ed8469 100644 --- a/tests/chain_emission.rs +++ b/tests/chain_emission.rs @@ -79,6 +79,8 @@ fn fixture_findings() -> Vec { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/tests/cli_validation_tests.rs b/tests/cli_validation_tests.rs index af281a04..7b3ed074 100644 --- a/tests/cli_validation_tests.rs +++ b/tests/cli_validation_tests.rs @@ -14,8 +14,9 @@ //! reproducible. use assert_cmd::Command; +use nyx_scanner::commands::scan::Diag; use predicates::prelude::*; -use serde_json::Value; +use serde_json::{Value, json}; use std::path::PathBuf; /// Build a scan command with a fresh config dir and a writable tempdir as @@ -197,6 +198,91 @@ fn scan_json_stdout_is_machine_clean_when_tracing_warns() { ); } +#[test] +fn scan_respects_committed_triage_file_for_cli_output_and_fail_on() { + let home = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + std::fs::write( + target.path().join("app.js"), + b"const q = req.query.x;\neval(q);\n", + ) + .unwrap(); + let canonical_target = target.path().canonicalize().unwrap(); + + let scan_args = [ + "--format", + "json", + "--quiet", + "--index", + "off", + "--no-verify", + "--all", + "--include-quality", + "--parse-timeout-ms", + "0", + ]; + let (mut first_cmd, _) = scan_cmd(home.path(), target.path()); + first_cmd.args(scan_args); + let first = first_cmd.assert().success(); + let first_json = assert_stdout_is_json_from_byte_zero( + &first.get_output().stdout, + "initial nyx scan --format json", + ); + let findings = first_json["findings"] + .as_array() + .expect("scan JSON must include findings"); + assert!( + !findings.is_empty(), + "fixture should emit at least one finding" + ); + + let decisions: Vec = findings + .iter() + .map(|finding| { + let diag: Diag = serde_json::from_value(finding.clone()).unwrap(); + json!({ + "fingerprint": nyx_scanner::server::models::compute_portable_fingerprint( + &diag, + &canonical_target, + ), + "state": "false_positive", + "note": "fixture triaged by committed file", + "rule_id": diag.id, + "path": diag.path.strip_prefix(canonical_target.to_string_lossy().as_ref()) + .unwrap_or(&diag.path) + .trim_start_matches('/') + }) + }) + .collect(); + + let nyx_dir = target.path().join(".nyx"); + std::fs::create_dir(&nyx_dir).unwrap(); + std::fs::write( + nyx_dir.join("triage.json"), + serde_json::to_vec_pretty(&json!({ + "version": 1, + "decisions": decisions, + "suppression_rules": [] + })) + .unwrap(), + ) + .unwrap(); + + let (mut second_cmd, _) = scan_cmd(home.path(), target.path()); + second_cmd.args(scan_args).args(["--fail-on", "HIGH"]); + let second = second_cmd.assert().success(); + let second_json = assert_stdout_is_json_from_byte_zero( + &second.get_output().stdout, + "triaged nyx scan --format json", + ); + + assert_eq!( + second_json["findings"].as_array().unwrap().len(), + 0, + "terminal triage decisions from .nyx/triage.json should be hidden by default" + ); +} + #[test] fn scan_sarif_stdout_is_machine_clean_when_tracing_warns() { let home = tempfile::tempdir().unwrap(); diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index 9fa89715..8b934ca6 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -970,6 +970,8 @@ fn make_diag(path: &Path, func: &str, cap: Cap, sink_line: u32) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/console_snapshot.rs b/tests/console_snapshot.rs index fecd0484..160fca8d 100644 --- a/tests/console_snapshot.rs +++ b/tests/console_snapshot.rs @@ -47,6 +47,8 @@ fn base_diag() -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/tests/determinism_audit.rs b/tests/determinism_audit.rs index 880bc825..ea5c714c 100644 --- a/tests/determinism_audit.rs +++ b/tests/determinism_audit.rs @@ -61,6 +61,8 @@ fn deny_diag(stable_hash: u64) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], @@ -312,6 +314,8 @@ fn confirmed_run_is_byte_identical_across_runs() { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/dynamic_parity.rs b/tests/dynamic_parity.rs index a7ed8c46..141cb238 100644 --- a/tests/dynamic_parity.rs +++ b/tests/dynamic_parity.rs @@ -88,6 +88,8 @@ mod parity_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/dynamic_verify_e2e.rs b/tests/dynamic_verify_e2e.rs index a61127a1..6a99b7fd 100644 --- a/tests/dynamic_verify_e2e.rs +++ b/tests/dynamic_verify_e2e.rs @@ -80,6 +80,8 @@ mod verify_e2e { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], @@ -111,6 +113,8 @@ mod verify_e2e { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/engine_notes_rank_tests.rs b/tests/engine_notes_rank_tests.rs index 232519b4..d84ab6a2 100644 --- a/tests/engine_notes_rank_tests.rs +++ b/tests/engine_notes_rank_tests.rs @@ -66,6 +66,8 @@ fn high_confidence_taint_diag(path: &str, line: u32) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/tests/go_fixtures.rs b/tests/go_fixtures.rs index c9fed4e0..0a18143c 100644 --- a/tests/go_fixtures.rs +++ b/tests/go_fixtures.rs @@ -454,6 +454,8 @@ mod go_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/health_score_calibration.rs b/tests/health_score_calibration.rs index 4e212416..f22dcc2b 100644 --- a/tests/health_score_calibration.rs +++ b/tests/health_score_calibration.rs @@ -49,6 +49,8 @@ fn diag(severity: Severity, id: &str, conf: Option) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/tests/java_fixtures.rs b/tests/java_fixtures.rs index 0f8d9115..6788a29d 100644 --- a/tests/java_fixtures.rs +++ b/tests/java_fixtures.rs @@ -452,6 +452,8 @@ mod java_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/js_fixtures.rs b/tests/js_fixtures.rs index 2ce0e3cb..caa2e418 100644 --- a/tests/js_fixtures.rs +++ b/tests/js_fixtures.rs @@ -447,6 +447,8 @@ mod js_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/json_snapshot.rs b/tests/json_snapshot.rs index 83774012..9450e47a 100644 --- a/tests/json_snapshot.rs +++ b/tests/json_snapshot.rs @@ -27,6 +27,8 @@ fn base_diag() -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: Vec::new(), diff --git a/tests/lang_detect_probes.rs b/tests/lang_detect_probes.rs index 133feafa..36314723 100644 --- a/tests/lang_detect_probes.rs +++ b/tests/lang_detect_probes.rs @@ -57,6 +57,8 @@ mod lang_detect { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/php_fixtures.rs b/tests/php_fixtures.rs index d2b3c9d1..ad2bc1a3 100644 --- a/tests/php_fixtures.rs +++ b/tests/php_fixtures.rs @@ -442,6 +442,8 @@ mod php_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/policy_deny.rs b/tests/policy_deny.rs index 4c21173a..71dcf45b 100644 --- a/tests/policy_deny.rs +++ b/tests/policy_deny.rs @@ -36,6 +36,8 @@ fn empty_diag() -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/python_fixtures.rs b/tests/python_fixtures.rs index 8a94f5bb..66c72797 100644 --- a/tests/python_fixtures.rs +++ b/tests/python_fixtures.rs @@ -930,6 +930,8 @@ mod python_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/rust_fixtures.rs b/tests/rust_fixtures.rs index 1637a3c4..14cfa3b0 100644 --- a/tests/rust_fixtures.rs +++ b/tests/rust_fixtures.rs @@ -281,6 +281,8 @@ mod rust_fixture_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/sandbox_hardening_linux.rs b/tests/sandbox_hardening_linux.rs index adaa4b52..99c878f5 100644 --- a/tests/sandbox_hardening_linux.rs +++ b/tests/sandbox_hardening_linux.rs @@ -754,6 +754,8 @@ mod hardening_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], @@ -947,6 +949,8 @@ mod hardening_tests { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index 30849115..187b8e03 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -649,6 +649,8 @@ finally: rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], @@ -787,6 +789,8 @@ finally: rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/sarif_dynamic_verdict_tests.rs b/tests/sarif_dynamic_verdict_tests.rs index dcbac33f..764cc776 100644 --- a/tests/sarif_dynamic_verdict_tests.rs +++ b/tests/sarif_dynamic_verdict_tests.rs @@ -31,6 +31,8 @@ fn base_diag() -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: "deadbeef01234567".into(), alternative_finding_ids: Vec::new(), diff --git a/tests/spec_callgraph_resolution.rs b/tests/spec_callgraph_resolution.rs index a9a9ae9e..148547cf 100644 --- a/tests/spec_callgraph_resolution.rs +++ b/tests/spec_callgraph_resolution.rs @@ -80,6 +80,8 @@ fn make_diag(id: &str, path: &str, line: usize) -> Diag { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/spec_derivation_strategies.rs b/tests/spec_derivation_strategies.rs index b6041f33..f4167b9f 100644 --- a/tests/spec_derivation_strategies.rs +++ b/tests/spec_derivation_strategies.rs @@ -50,6 +50,8 @@ mod spec_strategies { rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], diff --git a/tests/spec_framework_sample.rs b/tests/spec_framework_sample.rs index adbea41f..e602179b 100644 --- a/tests/spec_framework_sample.rs +++ b/tests/spec_framework_sample.rs @@ -75,6 +75,8 @@ fn make_diag(path: &str, handler: &str, line: usize, cap: Cap, rule_id: &str) -> rank_reason: None, suppressed: false, suppression: None, + triage_state: "open".to_string(), + triage_note: String::new(), rollup: None, finding_id: String::new(), alternative_finding_ids: vec![], From d09a97008ee6a91243d9ce58b5f3056bde76c0ec Mon Sep 17 00:00:00 2001 From: elipeter Date: Fri, 5 Jun 2026 10:53:09 -0500 Subject: [PATCH 2/2] updated CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898e12ec..b637bcc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to Nyx are documented here. The format is based on [Keep a C ## [Unreleased] -## [0.8.0] - 2026-06-01 +## [0.8.0] - 2026-06-06 The dynamic-verification release. An attack-surface map, a sandboxed dynamic verifier, a framework adapter registry that grounds both, the per-language build infrastructure that makes per-finding verification affordable at corpus scale, and the first real-corpus acceptance gates. @@ -75,6 +75,7 @@ The attack-surface map and chain composer turn the flat finding list into a rout - **`nyx scan --verify`** (enabled by default in standard builds) and `--backend {auto,process,docker}` select the dynamic-verification harness. `--no-verify` skips verification for a single run without changing config. - **`nyx scan --harden {standard,strict}`** picks the process-backend hardening profile. `standard` is no-new-privs plus a memory rlimit on Linux. `strict` layers namespace unshare, chroot to the workdir, and a default-deny seccomp filter on Linux, or wraps the harness with `sandbox-exec` on macOS. - **Patch-validation CI mode.** `--baseline FILE` reads a previous scan's JSON (or a stripped `.nyx/baseline.json` written by `--baseline-write`) and diffs it against the current scan on `stable_hash`, emitting `New` / `Resolved` / `FlippedConfirmed` / `FlippedNotConfirmed` transitions. `--gate {no-new-confirmed,resolve-all-confirmed}` exits non-zero when the diff violates the policy so CI fails the build instead of merging an unreviewed regression. The stripped baseline carries only `stable_hash`, `dynamic_verdict`, `severity`, `path`, and `rule_id`, so persisting it between scans does not leak source. +- **Repository triage in CI.** `nyx scan` now reads the same `.nyx/triage.json` file written by `nyx serve`. Terminal triage states (`false_positive`, `accepted_risk`, `suppressed`, `fixed`) are hidden from CLI output and excluded from `--fail-on` by default, while `--show-suppressed` includes them with `triage_state` / `triage_note` metadata for JSON, SARIF, and console output. - **`nyx scan --verify-all-confidence`** drops the Medium cutoff and re-verifies everything. - **`nyx scan --unsafe-sandbox`** disables hardening (development only, never for CI). - **`nyx verify-feedback --wrong | --right`** records a correction or confirmation for a finding's verdict in the local telemetry log.