Merge pull request #86 from nyx-sec/triage-works-in-cli

fix(cli): apply repository triage file during scans
This commit is contained in:
Eli Peter 2026-06-05 10:59:40 -05:00 committed by GitHub
commit 25863d222a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 573 additions and 21 deletions

View file

@ -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 <finding_id> --wrong <reason> | --right`** records a correction or confirmation for a finding's verdict in the local telemetry log.

View file

@ -82,6 +82,13 @@ nyx scan [PATH] [OPTIONS]
| `--rollup-examples <N>` | `5` | Number of example locations in rollup findings |
| `--show-instances <RULE>` | *(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

View file

@ -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 |

View file

@ -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(),

View file

@ -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(),

View file

@ -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![],

View file

@ -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<crate::suppress::SuppressionMeta>,
/// 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<RollupData>,
@ -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![],

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -82,6 +82,8 @@ pub fn scan_ejs_file(path: &Path, bytes: &[u8]) -> Vec<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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(

View file

@ -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<String> {
}
/// 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(),

View file

@ -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<PathBuf, String> {
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<TriageApplySummary, String> {
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<TriageApplySummary, String> {
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<String, String> {
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() {

View file

@ -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![],

View file

@ -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![],

View file

@ -79,6 +79,8 @@ fn fixture_findings() -> Vec<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(),

View file

@ -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<Value> = 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();

View file

@ -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![],

View file

@ -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(),

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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(),

View file

@ -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![],

View file

@ -49,6 +49,8 @@ fn diag(severity: Severity, id: &str, conf: Option<Confidence>) -> 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(),

View file

@ -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![],

View file

@ -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![],

View file

@ -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(),

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],

View file

@ -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(),

View file

@ -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![],

View file

@ -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![],

View file

@ -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![],