feat: severity depends on where a finding is, and judgements are remembered
Two mechanisms for the same complaint: nanny's own test fixtures were being reported as critical, and dismissing them was per-row work that came back on the next sweep. Severity was purely a property of the rule — an Anthropic key is critical because Anthropic keys are critical. That answers "what kind of credential" and not the question an operator asks, which is what a leak at this location costs them. A finding now carries the rule's severity, the effective one, and the context that separates them; filters, sorts and alerts use the effective one, and every demotion can state what it was and why. Contexts are tests, benchmarks, fixtures, examples, documentation and .example configs. Demote, never drop. crates/nanny-core/src/scan.rs:271 should read "medium - test fixture", not vanish: a tool that hides things is one you cannot reason about, and nanny's silence is only worth something if it means something. Demotion moves a finding below alert.min_severity, which stops the interruption without losing the record. Only in working trees. A .md in a repo is documentation; a .md in ~/.claude/projects/*/memory/ is an agent memory, which is exactly where a real credential ends up. In a harness store there is no way to distinguish a transcript quoting a test file from one that printed a live key, so nothing there is demoted. Rust needs more than a path rule, because its convention puts a #[cfg(test)] mod tests inside the production source file — which is the literal case that prompted this. Matches after that attribute in a .rs file are classified as fixtures. Deliberately Rust-only: every other language here keeps tests in their own files, which the path rules already catch. The second mechanism is suppression. Marking a finding ignored settles one row, but "that is an invented token" is true of the value wherever it turns up next, transcripts included. Suppressions are keyed by fingerprint and optionally scoped to a path prefix, so the table holds the same one-way salted hashes as the findings table and no values — remembering a judgement risks nothing the database did not already. Suppressed findings are still recorded, as ignored, so the counts do not lie about what is on disk, and each suppression tracks what it caught so one that stopped earning its place is visible. nanny ignore 849 --everywhere nanny ignore --under ~/git/upstream --everywhere --note "their fixtures" nanny suppressions / nanny unsuppress <id> I in the triage screen, next to i Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAqHWfdMAsYu1o36tgeima
This commit is contained in:
@@ -60,6 +60,29 @@ leak is incident response rather than a rotation.
|
||||
Rule ids are persisted with every finding. Renaming one orphans the status
|
||||
history and ignore entries that reference it.
|
||||
|
||||
## Severity has two inputs
|
||||
|
||||
`severity` on a finding is the *effective* one; `rule_severity` is what the rule
|
||||
alone says; `context` is why they may differ. Filters, sorts and alerts all use
|
||||
the effective one — using the rule's would mean judging a finding by a number
|
||||
that is never displayed.
|
||||
|
||||
A demotion must always be able to explain itself (`Finding::demotion()`). Never
|
||||
add a context that suppresses rather than demotes, and never demote outside a
|
||||
working tree: in a harness store there is no way to distinguish a quoted fixture
|
||||
from a printed credential, and guessing there is how a real leak gets buried.
|
||||
|
||||
## Suppressions vs. ignoring
|
||||
|
||||
`Status::Ignored` is a judgement about a *row*. A `Suppression` is a judgement
|
||||
about a *value*, keyed by fingerprint and optionally scoped to a path prefix.
|
||||
The keying is not incidental — it is what lets nanny remember a judgement
|
||||
without storing anything it does not already store. A suppression that held the
|
||||
value, or a plaintext hint of it, would defeat the whole design.
|
||||
|
||||
Suppressed findings are still recorded, as `ignored`. Skipping the write would
|
||||
make the counts lie about what is on disk.
|
||||
|
||||
## Exclusions
|
||||
|
||||
`crates/nanny-core/src/exclude.rs` holds two different things that share one
|
||||
|
||||
@@ -193,6 +193,9 @@ pub fn json(finding: &Finding) -> serde_json::Value {
|
||||
"rule": finding.rule.as_str(),
|
||||
"label": finding.label,
|
||||
"severity": finding.severity.to_string(),
|
||||
"rule_severity": finding.rule_severity.to_string(),
|
||||
"context": finding.context.to_string(),
|
||||
"demoted_because": finding.demotion().map(|(_, reason)| reason),
|
||||
"confidence": finding.confidence.to_string(),
|
||||
"harness": finding.harness.slug(),
|
||||
"path": finding.origin.path().display().to_string(),
|
||||
@@ -225,6 +228,8 @@ mod tests {
|
||||
rule: RuleId::new("anthropic-api-key"),
|
||||
label: "Anthropic API key".into(),
|
||||
severity: Severity::Critical,
|
||||
rule_severity: Severity::Critical,
|
||||
context: nanny_entities::Context::Live,
|
||||
confidence: nanny_entities::Confidence::Certain,
|
||||
harness: Harness::WorkingTree,
|
||||
origin: Origin::File {
|
||||
|
||||
@@ -17,9 +17,11 @@ use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use nanny_core::port::{FindingStore, Query, Scope};
|
||||
use nanny_core::port::{FindingStore, Query, Scope, SuppressionStore};
|
||||
use nanny_data::Runtime;
|
||||
use nanny_entities::{FindingId, Harness, Severity, Status, StatusChange};
|
||||
use nanny_entities::{
|
||||
FindingId, Harness, NewSuppression, Severity, Status, StatusChange, SuppressionId,
|
||||
};
|
||||
|
||||
use format::Style;
|
||||
|
||||
@@ -75,6 +77,14 @@ struct Triage {
|
||||
#[arg(long)]
|
||||
note: Option<String>,
|
||||
|
||||
/// Remember the judgement, so future occurrences of the same value are
|
||||
/// suppressed automatically instead of being triaged again.
|
||||
///
|
||||
/// Scoped to `--under` when that is given ("not here"), unscoped otherwise
|
||||
/// ("this value is not a credential, anywhere").
|
||||
#[arg(long)]
|
||||
everywhere: bool,
|
||||
|
||||
/// Show what would change, without changing it.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
@@ -148,6 +158,17 @@ enum Command {
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// List the remembered judgements — values nanny has been told are not
|
||||
/// credentials.
|
||||
Suppressions {
|
||||
/// Print as JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Forget a suppression, so its value is reported again.
|
||||
Unsuppress { id: i64 },
|
||||
|
||||
/// Check that nanny is configured safely and can see what it should.
|
||||
Doctor,
|
||||
}
|
||||
@@ -196,6 +217,15 @@ async fn main() -> Result<()> {
|
||||
Some(Command::Ignore(t)) => triage(&runtime, &t, Status::Ignored, &style).await?,
|
||||
Some(Command::Reopen(t)) => triage(&runtime, &t, Status::Open, &style).await?,
|
||||
Some(Command::Rules { json }) => rules(&runtime, json, &style),
|
||||
Some(Command::Suppressions { json }) => suppressions(&runtime, json, &style).await?,
|
||||
Some(Command::Unsuppress { id }) => {
|
||||
runtime
|
||||
.store
|
||||
.unsuppress(SuppressionId(id))
|
||||
.await
|
||||
.with_context(|| format!("cannot remove suppression {id}"))?;
|
||||
println!("suppression {id} removed; its value will be reported again");
|
||||
}
|
||||
Some(Command::Doctor) => doctor(&runtime, &style).await?,
|
||||
}
|
||||
|
||||
@@ -548,6 +578,113 @@ async fn triage(runtime: &Runtime, selector: &Triage, status: Status, style: &St
|
||||
}
|
||||
|
||||
println!("marked {} finding(s) {status}", targets.len());
|
||||
|
||||
if selector.everywhere {
|
||||
let scope = selector
|
||||
.under
|
||||
.as_ref()
|
||||
.map(|p| nanny_core::config::expand_tilde(p, &runtime.home));
|
||||
let remembered = remember(runtime, &targets, scope, selector.note.clone()).await?;
|
||||
println!(
|
||||
"remembered {remembered} value(s); future occurrences are suppressed \
|
||||
automatically (`nanny suppressions` to review)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a suppression for each distinct value among `targets`.
|
||||
///
|
||||
/// By value, not by finding: the same invented token in forty places is one
|
||||
/// judgement, and forty suppressions would be forty things to remove later.
|
||||
async fn remember(
|
||||
runtime: &Runtime,
|
||||
targets: &[FindingId],
|
||||
scope: Option<PathBuf>,
|
||||
reason: Option<String>,
|
||||
) -> Result<usize> {
|
||||
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
let mut count = 0usize;
|
||||
|
||||
for id in targets {
|
||||
let Some(finding) = runtime.store.get(*id).await? else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(finding.fingerprint.as_str().to_owned()) {
|
||||
continue;
|
||||
}
|
||||
runtime
|
||||
.store
|
||||
.suppress(&NewSuppression {
|
||||
fingerprint: finding.fingerprint.clone(),
|
||||
path_prefix: scope.clone(),
|
||||
reason: reason.clone(),
|
||||
label: finding.label.clone(),
|
||||
})
|
||||
.await
|
||||
.with_context(|| format!("cannot remember the judgement on finding {id}"))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn suppressions(runtime: &Runtime, json: bool, style: &Style) -> Result<()> {
|
||||
let list = runtime.store.suppressions().await?;
|
||||
|
||||
if json {
|
||||
let value: Vec<_> = list
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
serde_json::json!({
|
||||
"id": s.id.0,
|
||||
"fingerprint": s.fingerprint.as_str(),
|
||||
"label": s.label,
|
||||
"scope": s.path_prefix.as_ref().map(|p| p.display().to_string()),
|
||||
"reason": s.reason,
|
||||
"hits": s.hits,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if list.is_empty() {
|
||||
println!("{}", style.dim("nothing suppressed"));
|
||||
println!(
|
||||
" {}",
|
||||
style.dim("`nanny ignore <id> --everywhere` remembers that a value is not a secret")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style.bold(&format!(
|
||||
"{:>4} {:<18} {:<26} {:>6} {}",
|
||||
"ID", "FINGERPRINT", "WHAT", "CAUGHT", "SCOPE"
|
||||
))
|
||||
);
|
||||
for s in list.entries() {
|
||||
let scope = s.path_prefix.as_ref().map_or_else(
|
||||
|| style.dim("everywhere"),
|
||||
|p| format::short_path(p, &runtime.home, 48),
|
||||
);
|
||||
println!(
|
||||
"{:>4} {:<18} {:<26} {:>6} {scope}",
|
||||
s.id.0, s.fingerprint, s.label, s.hits
|
||||
);
|
||||
if let Some(reason) = &s.reason {
|
||||
println!(" {}", style.dim(reason));
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
style.dim("`nanny unsuppress <id>` forgets one, and its value is reported again")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use nanny_core::RuleEngine;
|
||||
use nanny_core::port::{FindingStore, Query};
|
||||
use nanny_entities::{Finding, Severity, Status, StatusChange};
|
||||
use nanny_core::port::{FindingStore, Query, SuppressionStore};
|
||||
use nanny_entities::{Finding, NewSuppression, Severity, Status, StatusChange};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
@@ -104,7 +104,11 @@ impl App {
|
||||
/// cannot be read. The terminal is restored on every exit path, including a
|
||||
/// failure part-way through, because leaving an operator in a broken shell to
|
||||
/// report an error is not a trade worth making.
|
||||
pub async fn run(store: &dyn FindingStore, engine: &RuleEngine, home: PathBuf) -> Result<()> {
|
||||
pub async fn run(
|
||||
store: &(impl FindingStore + SuppressionStore),
|
||||
engine: &RuleEngine,
|
||||
home: PathBuf,
|
||||
) -> Result<()> {
|
||||
let mut terminal = enter().context("cannot start the terminal interface")?;
|
||||
let result = event_loop(&mut terminal, store, engine, home).await;
|
||||
leave(&mut terminal)?;
|
||||
@@ -125,7 +129,7 @@ fn leave(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(store: &dyn FindingStore, filter: Filter) -> Result<Vec<Finding>> {
|
||||
async fn load(store: &impl FindingStore, filter: Filter) -> Result<Vec<Finding>> {
|
||||
let query = Query {
|
||||
statuses: filter.query().map(|s| s.to_vec()).unwrap_or_default(),
|
||||
..Query::default()
|
||||
@@ -135,7 +139,7 @@ async fn load(store: &dyn FindingStore, filter: Filter) -> Result<Vec<Finding>>
|
||||
|
||||
async fn event_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
store: &dyn FindingStore,
|
||||
store: &(impl FindingStore + SuppressionStore),
|
||||
engine: &RuleEngine,
|
||||
home: PathBuf,
|
||||
) -> Result<()> {
|
||||
@@ -172,7 +176,11 @@ async fn event_loop(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_key(key: KeyEvent, app: &mut App, store: &dyn FindingStore) -> Result<()> {
|
||||
async fn handle_key(
|
||||
key: KeyEvent,
|
||||
app: &mut App,
|
||||
store: &(impl FindingStore + SuppressionStore),
|
||||
) -> Result<()> {
|
||||
app.message = None;
|
||||
|
||||
match key.code {
|
||||
@@ -204,6 +212,25 @@ async fn handle_key(key: KeyEvent, app: &mut App, store: &dyn FindingStore) -> R
|
||||
KeyCode::Char('a') => set_status(app, store, Status::Acknowledged).await?,
|
||||
KeyCode::Char('R') => set_status(app, store, Status::Rotated).await?,
|
||||
KeyCode::Char('i') => set_status(app, store, Status::Ignored).await?,
|
||||
KeyCode::Char('I') => {
|
||||
// Ignore *and* remember: this value is not a credential, anywhere.
|
||||
// The capital is deliberate — it is the more consequential action,
|
||||
// and it should not be one keystroke away from the reversible one.
|
||||
if let Some(f) = app.selected() {
|
||||
let suppression = NewSuppression {
|
||||
fingerprint: f.fingerprint.clone(),
|
||||
path_prefix: None,
|
||||
reason: Some("judged not a credential".into()),
|
||||
label: f.label.clone(),
|
||||
};
|
||||
let what = f.label.clone();
|
||||
store.suppress(&suppression).await?;
|
||||
set_status(app, store, Status::Ignored).await?;
|
||||
app.message = Some(format!(
|
||||
"{what} value suppressed everywhere; `nanny suppressions` to review"
|
||||
));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('o') => set_status(app, store, Status::Open).await?,
|
||||
|
||||
KeyCode::Char('y') => {
|
||||
@@ -219,7 +246,7 @@ async fn handle_key(key: KeyEvent, app: &mut App, store: &dyn FindingStore) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_status(app: &mut App, store: &dyn FindingStore, status: Status) -> Result<()> {
|
||||
async fn set_status(app: &mut App, store: &impl FindingStore, status: Status) -> Result<()> {
|
||||
let Some(finding) = app.selected() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -404,6 +431,25 @@ fn draw_detail(frame: &mut ratatui::Frame<'_>, area: Rect, app: &App, engine: &R
|
||||
f.matched_len, f.confidence, f.fingerprint
|
||||
),
|
||||
));
|
||||
|
||||
// A silently downgraded finding is one nobody can trust, so a demotion
|
||||
// always says what it was and why.
|
||||
if let Some((rule_severity, reason)) = f.demotion() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<10}", "severity"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(
|
||||
f.severity.to_string(),
|
||||
Style::default().fg(severity_colour(f.severity)),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" — {rule_severity} for this rule, lowered because this is {reason}"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<10}", "context"),
|
||||
@@ -459,7 +505,8 @@ fn draw_detail(frame: &mut ratatui::Frame<'_>, area: Rect, app: &App, engine: &R
|
||||
|
||||
fn draw_footer(frame: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
|
||||
let text = app.message.clone().unwrap_or_else(|| {
|
||||
"j/k move · a ack · R rotated · i ignore · o reopen · f filter · y show path · q quit"
|
||||
"j/k move · a ack · R rotated · i ignore · I ignore everywhere · o reopen · \
|
||||
f filter · y path · q quit"
|
||||
.to_owned()
|
||||
});
|
||||
frame.render_widget(
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nanny_entities::{
|
||||
Cursor, Excerpt, Finding, FindingId, Harness, NewFinding, Severity, SourceId, Status,
|
||||
StatusChange,
|
||||
Cursor, Excerpt, Finding, FindingId, Harness, NewFinding, NewSuppression, Severity, SourceId,
|
||||
Status, StatusChange, Suppression, SuppressionId,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -289,6 +289,51 @@ pub trait FindingStore: Send + Sync {
|
||||
async fn record_sweep(&self, at: OffsetDateTime, stats: CollectStats) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence for judgements about values rather than about rows.
|
||||
///
|
||||
/// Kept as its own port rather than folded into [`FindingStore`] because the
|
||||
/// two answer different questions — "what has been found" and "what has already
|
||||
/// been judged harmless" — and because a consumer that only reads findings
|
||||
/// should not be handed the ability to silence them.
|
||||
#[async_trait]
|
||||
pub trait SuppressionStore: Send + Sync {
|
||||
/// Every suppression, loaded once per sweep rather than queried per
|
||||
/// finding.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the read fails.
|
||||
async fn suppressions(&self) -> crate::Result<nanny_entities::Suppressions>;
|
||||
|
||||
/// Record a new suppression, or return the id of an identical existing one.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the write fails.
|
||||
async fn suppress(&self, suppression: &NewSuppression) -> crate::Result<SuppressionId>;
|
||||
|
||||
/// Remove a suppression, so the value it covered is reported again.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if no such suppression exists, or the write fails.
|
||||
async fn unsuppress(&self, id: SuppressionId) -> crate::Result<()>;
|
||||
|
||||
/// Credit a suppression with the findings it caught this sweep, for the
|
||||
/// "is this still earning its place" column in `nanny suppressions`.
|
||||
///
|
||||
/// Takes a count rather than being called once per finding: a suppression
|
||||
/// covering a fixture in a vendored dependency catches hundreds in a single
|
||||
/// sweep, and that is one write, not hundreds.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the write fails.
|
||||
async fn record_suppression_hits(&self, id: SuppressionId, count: u64) -> crate::Result<()>;
|
||||
|
||||
/// Fetch one suppression.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the read fails.
|
||||
async fn suppression(&self, id: SuppressionId) -> crate::Result<Option<Suppression>>;
|
||||
}
|
||||
|
||||
/// Telling the operator, right now, that something leaked.
|
||||
#[async_trait]
|
||||
pub trait Alerter: Send + Sync {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
//! the excerpt. Nothing downstream is *able* to leak it, because nothing
|
||||
//! downstream is given it.
|
||||
|
||||
use nanny_entities::{Excerpt, Mask, NewFinding, Preview, Secret, Severity, rule::Confidence};
|
||||
use nanny_entities::{
|
||||
Context, Excerpt, Harness, Mask, NewFinding, Preview, Secret, Severity, Status,
|
||||
rule::Confidence,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::rules::RuleEngine;
|
||||
@@ -71,6 +74,8 @@ impl Scanner {
|
||||
pub fn scan(&self, excerpt: &Excerpt) -> Vec<NewFinding> {
|
||||
let observed_at = OffsetDateTime::now_utc();
|
||||
let text = &excerpt.text;
|
||||
let harness = excerpt_harness(excerpt);
|
||||
let in_working_tree = matches!(harness, Harness::WorkingTree);
|
||||
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
let mut storm: Option<&str> = None;
|
||||
@@ -140,7 +145,16 @@ impl Scanner {
|
||||
.filter_map(|hit| {
|
||||
let compiled = &self.engine.rules()[hit.rule_index];
|
||||
let rule = &compiled.rule;
|
||||
if rule.severity.rank() > self.min_severity.rank()
|
||||
// Context is per-match, not per-excerpt: a Rust source file
|
||||
// holds production code and a `#[cfg(test)]` module, and which
|
||||
// one a finding is in depends on where it sits.
|
||||
let context = classify(in_working_tree, excerpt, hit.start);
|
||||
|
||||
// Filter on the *effective* severity. Using the rule's would
|
||||
// mean a demoted finding is judged by a number that is never
|
||||
// displayed anywhere.
|
||||
let effective = rule.severity.demoted(context.demotion_steps());
|
||||
if effective.rank() > self.min_severity.rank()
|
||||
|| rule.confidence.rank() > self.min_confidence.rank()
|
||||
{
|
||||
return None;
|
||||
@@ -150,9 +164,11 @@ impl Scanner {
|
||||
fingerprint: secret.fingerprint(&self.salt),
|
||||
rule: rule.id.clone(),
|
||||
label: rule.name.clone(),
|
||||
severity: rule.severity,
|
||||
severity: rule.severity.demoted(context.demotion_steps()),
|
||||
rule_severity: rule.severity,
|
||||
context,
|
||||
confidence: rule.confidence,
|
||||
harness: excerpt_harness(excerpt),
|
||||
harness: harness.clone(),
|
||||
origin: excerpt.origin.clone(),
|
||||
detail: excerpt.detail.clone(),
|
||||
line: Some(excerpt.line_at(hit.start)),
|
||||
@@ -160,6 +176,7 @@ impl Scanner {
|
||||
preview: Preview::build(text, (hit.start, hit.end), &mask),
|
||||
matched_len: secret.len(),
|
||||
observed_at,
|
||||
initial_status: Status::Open,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -198,6 +215,20 @@ impl Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify where this excerpt came from, for severity demotion.
|
||||
///
|
||||
/// Only working trees are classified. In a harness store there is no way to
|
||||
/// distinguish "the transcript quotes a test file" from "the transcript printed
|
||||
/// a live key", so nothing there is demoted — fixtures that reach a transcript
|
||||
/// are handled by suppressing the fingerprint instead.
|
||||
fn classify(in_working_tree: bool, excerpt: &Excerpt, offset: usize) -> Context {
|
||||
if in_working_tree {
|
||||
Context::classify_in_source(excerpt.origin.path(), &excerpt.text, offset)
|
||||
} else {
|
||||
Context::Live
|
||||
}
|
||||
}
|
||||
|
||||
/// A collector always sets the origin, and the harness follows from it. Kept as
|
||||
/// a function so the mapping is in one place if a collector ever produces
|
||||
/// excerpts for more than one harness.
|
||||
@@ -413,6 +444,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn excerpt_in(path: &str, text: &str) -> Excerpt {
|
||||
Excerpt {
|
||||
source: SourceId::new("test", "t"),
|
||||
origin: Origin::File {
|
||||
path: PathBuf::from(path),
|
||||
},
|
||||
base_offset: 0,
|
||||
base_line: 1,
|
||||
detail: None,
|
||||
text: text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixture_in_a_working_tree_is_demoted_and_says_why() {
|
||||
let s = scanner();
|
||||
let text =
|
||||
r#"let key = "sk-ant-api03-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v2W3x4Y5z6";"#;
|
||||
|
||||
let live = s.scan(&excerpt_in("/home/x/git/p/src/main.rs", text));
|
||||
assert_eq!(live[0].severity, Severity::Critical);
|
||||
assert_eq!(live[0].context, Context::Live);
|
||||
|
||||
let fixture = s.scan(&excerpt_in("/home/x/git/p/crates/c/src/scan.rs", text));
|
||||
assert_eq!(
|
||||
fixture[0].severity,
|
||||
Severity::Critical,
|
||||
"ordinary source is not a fixture"
|
||||
);
|
||||
|
||||
let test = s.scan(&excerpt_in("/home/x/git/p/tests/end_to_end.rs", text));
|
||||
assert_eq!(test[0].context, Context::TestFixture);
|
||||
assert_eq!(
|
||||
test[0].rule_severity,
|
||||
Severity::Critical,
|
||||
"the rule still says critical"
|
||||
);
|
||||
assert_eq!(
|
||||
test[0].severity,
|
||||
Severity::Medium,
|
||||
"but it is demoted two steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transcript_is_never_demoted_whatever_it_quotes() {
|
||||
// A transcript quoting a test file looks exactly like a transcript that
|
||||
// printed a live key. Only the fingerprint suppression can tell them
|
||||
// apart, and that is the operator's judgement to make.
|
||||
let s = scanner();
|
||||
let text =
|
||||
r#"reading tests/end_to_end.rs: let key = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";"#;
|
||||
let found = s.scan(&excerpt_in(
|
||||
"/home/x/.claude/projects/p/session.jsonl",
|
||||
text,
|
||||
));
|
||||
assert_eq!(found[0].context, Context::Live);
|
||||
assert_eq!(found[0].severity, found[0].rule_severity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_severity_threshold_applies_to_the_demoted_severity() {
|
||||
// A `high` rule in a fixture demotes to `low`. With a `medium` floor it
|
||||
// must be filtered out — judging it by the rule's `high` would let
|
||||
// through a finding that displays as `low`.
|
||||
let strict = Scanner::new(
|
||||
RuleEngine::builtin().unwrap(),
|
||||
Salt::from_bytes([3u8; 32]),
|
||||
Severity::Medium,
|
||||
Confidence::Moderate,
|
||||
);
|
||||
let text = r#"let t = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";"#;
|
||||
assert!(
|
||||
!strict
|
||||
.scan(&excerpt_in("/home/x/git/p/src/main.rs", text))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
strict
|
||||
.scan(&excerpt_in("/home/x/git/p/tests/main.rs", text))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locations_point_at_the_right_line() {
|
||||
let s = scanner();
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
//! continuous watch cannot drift apart in what they detect.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nanny_entities::{Excerpt, Finding, FindingId, Severity};
|
||||
use nanny_entities::{Excerpt, Finding, FindingId, Severity, Status, SuppressionId, Suppressions};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::port::{
|
||||
Alerter, CollectStats, Collector, CursorStore, ExcerptSink, FindingStore, Scope,
|
||||
SuppressionStore,
|
||||
};
|
||||
use crate::scan::Scanner;
|
||||
|
||||
@@ -21,6 +22,8 @@ pub struct SweepReport {
|
||||
pub new_findings: Vec<FindingId>,
|
||||
/// Findings re-confirmed at a location already known.
|
||||
pub reconfirmed: usize,
|
||||
/// Findings a suppression covered. Recorded, never alerted on.
|
||||
pub suppressed: usize,
|
||||
/// Collectors that failed, by name. A sweep reports rather than aborts:
|
||||
/// one unreadable store must not hide what the others found.
|
||||
pub failures: Vec<(String, String)>,
|
||||
@@ -37,17 +40,33 @@ impl SweepReport {
|
||||
}
|
||||
|
||||
/// A sink that scans each excerpt and records what it finds.
|
||||
struct RecordingSink<'a> {
|
||||
struct RecordingSink<'a, S: FindingStore + ?Sized> {
|
||||
scanner: &'a Scanner,
|
||||
store: &'a dyn FindingStore,
|
||||
store: &'a S,
|
||||
suppressions: &'a Suppressions,
|
||||
new_findings: Vec<FindingId>,
|
||||
reconfirmed: usize,
|
||||
suppressed: usize,
|
||||
/// Suppressions that caught something this sweep, to be credited once at
|
||||
/// the end rather than written per finding.
|
||||
hits: Vec<SuppressionId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExcerptSink for RecordingSink<'_> {
|
||||
impl<S: FindingStore + ?Sized> ExcerptSink for RecordingSink<'_, S> {
|
||||
async fn accept(&mut self, excerpt: Excerpt) -> crate::Result<()> {
|
||||
for finding in self.scanner.scan(&excerpt) {
|
||||
for mut finding in self.scanner.scan(&excerpt) {
|
||||
// A value the operator has already judged harmless is still
|
||||
// recorded — the inventory stays honest — but it lands ignored, so
|
||||
// it never re-enters triage and never alerts.
|
||||
if let Some(suppression) = self.suppressions.covering(&finding) {
|
||||
finding.initial_status = Status::Ignored;
|
||||
self.suppressed += 1;
|
||||
self.hits.push(suppression.id);
|
||||
self.store.record(&finding).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let recorded = self.store.record(&finding).await?;
|
||||
if recorded.is_new {
|
||||
tracing::warn!(
|
||||
@@ -81,7 +100,7 @@ pub async fn sweep(
|
||||
scanner: &Scanner,
|
||||
collectors: &[Box<dyn Collector>],
|
||||
cursors: &dyn CursorStore,
|
||||
store: &dyn FindingStore,
|
||||
store: &(impl FindingStore + SuppressionStore + ?Sized),
|
||||
scope: Scope,
|
||||
) -> crate::Result<SweepReport> {
|
||||
let started_at = OffsetDateTime::now_utc();
|
||||
@@ -90,6 +109,11 @@ pub async fn sweep(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Loaded once per sweep. Querying per finding would mean a database round
|
||||
// trip for every match across a 286 MB transcript directory.
|
||||
let suppressions = store.suppressions().await?;
|
||||
let mut hits: Vec<SuppressionId> = Vec::new();
|
||||
|
||||
for collector in collectors {
|
||||
if !scope.includes(collector.cadence()) {
|
||||
continue;
|
||||
@@ -105,8 +129,11 @@ pub async fn sweep(
|
||||
let mut sink = RecordingSink {
|
||||
scanner,
|
||||
store,
|
||||
suppressions: &suppressions,
|
||||
new_findings: Vec::new(),
|
||||
reconfirmed: 0,
|
||||
suppressed: 0,
|
||||
hits: Vec::new(),
|
||||
};
|
||||
|
||||
match collector.collect(cursors, &mut sink).await {
|
||||
@@ -130,6 +157,13 @@ pub async fn sweep(
|
||||
|
||||
report.new_findings.extend(sink.new_findings);
|
||||
report.reconfirmed += sink.reconfirmed;
|
||||
report.suppressed += sink.suppressed;
|
||||
hits.extend(sink.hits);
|
||||
}
|
||||
|
||||
hits.sort_unstable();
|
||||
for (id, count) in tally(hits) {
|
||||
store.record_suppression_hits(id, count).await?;
|
||||
}
|
||||
|
||||
let finished_at = OffsetDateTime::now_utc();
|
||||
@@ -143,6 +177,18 @@ pub async fn sweep(
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Collapse a sorted list of ids into `(id, count)` pairs.
|
||||
fn tally(sorted: Vec<SuppressionId>) -> Vec<(SuppressionId, u64)> {
|
||||
let mut out: Vec<(SuppressionId, u64)> = Vec::new();
|
||||
for id in sorted {
|
||||
match out.last_mut() {
|
||||
Some((last, count)) if *last == id => *count += 1,
|
||||
_ => out.push((id, 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Alert on the new findings from a sweep, filtered to those worth
|
||||
/// interrupting for.
|
||||
///
|
||||
@@ -183,3 +229,29 @@ pub async fn alert_on(
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tally_counts_each_suppression_once() {
|
||||
let ids = vec![
|
||||
SuppressionId(1),
|
||||
SuppressionId(1),
|
||||
SuppressionId(2),
|
||||
SuppressionId(3),
|
||||
SuppressionId(3),
|
||||
SuppressionId(3),
|
||||
];
|
||||
assert_eq!(
|
||||
tally(ids),
|
||||
vec![
|
||||
(SuppressionId(1), 2),
|
||||
(SuppressionId(2), 1),
|
||||
(SuppressionId(3), 3)
|
||||
]
|
||||
);
|
||||
assert!(tally(Vec::new()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Severity gains a second dimension, and judgements gain somewhere to live.
|
||||
--
|
||||
-- Until now severity came only from the rule: an Anthropic key was critical
|
||||
-- because Anthropic keys are critical. That answers "what kind of credential is
|
||||
-- this" and not the question an operator is actually asking, which is what a
|
||||
-- leak *at this location* costs them. A fake key in a `#[cfg(test)]` block
|
||||
-- scored identically to a live one in a transcript, and a list where those look
|
||||
-- the same is a list nobody reads.
|
||||
|
||||
-- What the rule alone says. Kept alongside the effective severity so a demoted
|
||||
-- finding can account for itself instead of silently reading as unimportant.
|
||||
ALTER TABLE finding ADD COLUMN rule_severity TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Where it was found: live, test-fixture, documentation, example-config.
|
||||
ALTER TABLE finding ADD COLUMN context TEXT NOT NULL DEFAULT 'live';
|
||||
|
||||
-- Rows written before this migration have no recorded rule severity. Their
|
||||
-- stored severity *is* the rule's, because nothing was being demoted yet.
|
||||
UPDATE finding SET rule_severity = severity WHERE rule_severity = '';
|
||||
|
||||
-- Judgements about values rather than about rows.
|
||||
--
|
||||
-- Marking a finding ignored settles one location. "That is a test fixture, not
|
||||
-- a credential" is true of the value wherever it turns up next, and without
|
||||
-- somewhere to record it the operator dismisses the same invented token every
|
||||
-- time an agent quotes the file it lives in.
|
||||
--
|
||||
-- Keyed by fingerprint, which is what makes this safe to have at all: the table
|
||||
-- holds the same one-way salted hashes as `finding` and no values, so
|
||||
-- remembering a judgement risks nothing the database did not already.
|
||||
CREATE TABLE suppression (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
fingerprint TEXT NOT NULL,
|
||||
|
||||
-- Empty means "this value is not a credential, anywhere" — right for an
|
||||
-- invented token, which is equally harmless quoted into a transcript.
|
||||
-- A prefix means "do not report it here" — right for a third-party
|
||||
-- checkout whose committed keys are real, but somebody else's to rotate.
|
||||
--
|
||||
-- Empty string rather than NULL so the UNIQUE constraint below actually
|
||||
-- binds: SQLite treats NULLs as distinct, so two unscoped suppressions for
|
||||
-- the same value would both be allowed.
|
||||
path_prefix TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Why. A suppression nobody can account for later is one nobody will
|
||||
-- dare remove.
|
||||
reason TEXT,
|
||||
|
||||
-- The rule's name, carried from the finding that prompted this. Never the
|
||||
-- value.
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
-- How many findings this has caught, so one that has stopped earning its
|
||||
-- place is visible rather than accumulating silently.
|
||||
hits INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE (fingerprint, path_prefix)
|
||||
);
|
||||
|
||||
CREATE INDEX suppression_fingerprint_idx ON suppression (fingerprint);
|
||||
@@ -12,11 +12,14 @@
|
||||
//! both simpler and a smaller target.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nanny_core::port::{CollectStats, CursorStore, FindingStore, Query, Recorded, Summary};
|
||||
use nanny_core::port::{
|
||||
CollectStats, CursorStore, FindingStore, Query, Recorded, Summary, SuppressionStore,
|
||||
};
|
||||
use nanny_core::{Error, Result};
|
||||
use nanny_entities::{
|
||||
Confidence, Cursor, Finding, FindingId, Fingerprint, Harness, NewFinding, Origin, Preview,
|
||||
Severity, SourceId, Status, StatusChange,
|
||||
Confidence, Context, Cursor, Finding, FindingId, Fingerprint, Harness, NewFinding,
|
||||
NewSuppression, Origin, Preview, Severity, SourceId, Status, StatusChange, Suppression,
|
||||
SuppressionId, Suppressions,
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
@@ -138,14 +141,22 @@ impl FindingStore for SqliteStore {
|
||||
INSERT INTO finding (
|
||||
fingerprint, rule_id, label, severity, confidence, harness,
|
||||
origin_kind, origin_path, origin_detail, line, byte_offset,
|
||||
preview, matched_len, first_seen, last_seen, occurrences, status
|
||||
preview, matched_len, first_seen, last_seen, occurrences, status,
|
||||
rule_severity, context
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14, 1, 'open')
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14, 1, ?15,
|
||||
?16, ?17)
|
||||
ON CONFLICT (fingerprint, origin_path, origin_detail, byte_offset) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
occurrences = finding.occurrences + 1,
|
||||
line = excluded.line,
|
||||
preview = excluded.preview
|
||||
last_seen = excluded.last_seen,
|
||||
occurrences = finding.occurrences + 1,
|
||||
line = excluded.line,
|
||||
preview = excluded.preview,
|
||||
-- Reclassification (a rule\'s severity changed, a path moved
|
||||
-- into a tests directory) should take effect; the operator\'s
|
||||
-- status and note must not be touched.
|
||||
severity = excluded.severity,
|
||||
rule_severity = excluded.rule_severity,
|
||||
context = excluded.context
|
||||
RETURNING id, occurrences
|
||||
",
|
||||
)
|
||||
@@ -163,6 +174,9 @@ impl FindingStore for SqliteStore {
|
||||
.bind(finding.preview.as_str())
|
||||
.bind(i64::try_from(finding.matched_len).unwrap_or(i64::MAX))
|
||||
.bind(&now)
|
||||
.bind(finding.initial_status.to_string())
|
||||
.bind(finding.rule_severity.to_string())
|
||||
.bind(finding.context.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("cannot record finding: {e}")))?;
|
||||
@@ -375,6 +389,8 @@ fn row_to_finding(row: &sqlx::sqlite::SqliteRow) -> Result<Finding> {
|
||||
rule: nanny_entities::RuleId::new(row.get::<String, _>("rule_id")),
|
||||
label: row.get("label"),
|
||||
severity: Severity::from_str(row.get::<String, _>("severity").as_str())?,
|
||||
rule_severity: Severity::from_str(row.get::<String, _>("rule_severity").as_str())?,
|
||||
context: Context::from_str(row.get::<String, _>("context").as_str())?,
|
||||
confidence: Confidence::from_str(row.get::<String, _>("confidence").as_str())?,
|
||||
harness: Harness::from_str(row.get::<String, _>("harness").as_str())?,
|
||||
origin,
|
||||
@@ -454,3 +470,93 @@ impl CursorStore for SqliteStore {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SuppressionStore for SqliteStore {
|
||||
async fn suppressions(&self) -> Result<Suppressions> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, fingerprint, path_prefix, reason, label, created_at, hits \
|
||||
FROM suppression ORDER BY id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("cannot read suppressions: {e}")))?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let prefix: String = row.get("path_prefix");
|
||||
out.push(Suppression {
|
||||
id: SuppressionId(row.get("id")),
|
||||
fingerprint: Fingerprint::from_stored(row.get("fingerprint")),
|
||||
path_prefix: (!prefix.is_empty()).then(|| PathBuf::from(prefix)),
|
||||
reason: row.get("reason"),
|
||||
label: row.get("label"),
|
||||
created_at: parse_stamp(row.get::<String, _>("created_at").as_str())?,
|
||||
hits: row.get::<i64, _>("hits").max(0).unsigned_abs(),
|
||||
});
|
||||
}
|
||||
Ok(Suppressions::new(out))
|
||||
}
|
||||
|
||||
async fn suppress(&self, suppression: &NewSuppression) -> Result<SuppressionId> {
|
||||
let prefix = suppression
|
||||
.path_prefix
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Suppressing the same thing twice is a no-op that returns the original,
|
||||
// so a repeated `--everywhere` does not accumulate duplicates that all
|
||||
// have to be removed separately later.
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO suppression (fingerprint, path_prefix, reason, label, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT (fingerprint, path_prefix) DO UPDATE SET \
|
||||
reason = COALESCE(excluded.reason, suppression.reason) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(suppression.fingerprint.as_str())
|
||||
.bind(&prefix)
|
||||
.bind(suppression.reason.as_deref())
|
||||
.bind(&suppression.label)
|
||||
.bind(stamp(OffsetDateTime::now_utc()))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("cannot record suppression: {e}")))?;
|
||||
|
||||
Ok(SuppressionId(row.get("id")))
|
||||
}
|
||||
|
||||
async fn unsuppress(&self, id: SuppressionId) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM suppression WHERE id = ?1")
|
||||
.bind(id.0)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("cannot remove suppression {id}: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::Storage(format!("no suppression with id {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_suppression_hits(&self, id: SuppressionId, count: u64) -> Result<()> {
|
||||
sqlx::query("UPDATE suppression SET hits = hits + ?1 WHERE id = ?2")
|
||||
.bind(i64::try_from(count).unwrap_or(i64::MAX))
|
||||
.bind(id.0)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("cannot credit suppression {id}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn suppression(&self, id: SuppressionId) -> Result<Option<Suppression>> {
|
||||
Ok(self
|
||||
.suppressions()
|
||||
.await?
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,33 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nanny_core::port::{Collector, CursorStore, FindingStore, Query, Scope};
|
||||
use nanny_core::port::{Collector, CursorStore, FindingStore, Query, Scope, SuppressionStore};
|
||||
use nanny_core::{Config, RuleEngine, Scanner};
|
||||
use nanny_data::collect;
|
||||
use nanny_data::store::SqliteStore;
|
||||
use nanny_entities::{Confidence, Salt, Severity, Status, StatusChange};
|
||||
use nanny_entities::{Confidence, Context, NewSuppression, Salt, Severity, Status, StatusChange};
|
||||
|
||||
/// Build a syntactically valid GitHub token from a recognisable seed.
|
||||
///
|
||||
/// A real `ghp_` token has exactly 36 characters after the prefix, and a
|
||||
/// hand-written fixture that is one short silently matches a *different*,
|
||||
/// lower-severity rule — which then fails an assertion somewhere unrelated and
|
||||
/// looks like a bug in the code rather than in the fixture. Padding here means
|
||||
/// a test can say what it means without counting characters.
|
||||
fn github_token(seed: &str) -> String {
|
||||
const BODY: usize = 36;
|
||||
let mut body: String = seed.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
let filler = "Zq7Wm4Nt8bYcJ3fH5gD1sA6eXk2pQ9vR0uL5";
|
||||
body.push_str(&filler[..BODY.saturating_sub(body.len()).min(filler.len())]);
|
||||
body.truncate(BODY);
|
||||
format!("ghp_{body}")
|
||||
}
|
||||
|
||||
/// A distinctive value that no rule could match by accident, planted in the
|
||||
/// fixtures so the "is it anywhere in the database" assertions are unambiguous.
|
||||
const CANARY: &str = "ghp_nannycanaryZZ9Q7wLm4nZt8bYcJ3fH5gD1e";
|
||||
fn canary() -> String {
|
||||
github_token("nannycanary")
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
home: PathBuf,
|
||||
@@ -40,7 +58,8 @@ impl Fixture {
|
||||
&home.join(".claude/projects/-home-x-proj/session.jsonl"),
|
||||
&format!(
|
||||
"{{\"type\":\"user\",\"text\":\"hello\"}}\n\
|
||||
{{\"type\":\"assistant\",\"text\":\"the token is {CANARY} ok\"}}\n"
|
||||
{{\"type\":\"assistant\",\"text\":\"the token is {} ok\"}}\n",
|
||||
canary()
|
||||
),
|
||||
);
|
||||
// claude-code: an agent memory with a different secret.
|
||||
@@ -51,9 +70,9 @@ impl Fixture {
|
||||
// A sanctioned store. This is where a credential is *meant* to be.
|
||||
write(
|
||||
&home.join(".claude/.credentials.json"),
|
||||
&format!("{{\"accessToken\":\"{CANARY}\"}}\n"),
|
||||
&format!("{{\"accessToken\":\"{}\"}}\n", canary()),
|
||||
);
|
||||
write(&home.join(".agents/password-store/forge.gpg"), CANARY);
|
||||
write(&home.join(".agents/password-store/forge.gpg"), &canary());
|
||||
write(
|
||||
&home.join(".ssh/id_ed25519"),
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n",
|
||||
@@ -68,6 +87,16 @@ impl Fixture {
|
||||
&home.join("git/proj/target/debug/build/generated.rs"),
|
||||
"-----BEGIN RSA PRIVATE KEY-----\n",
|
||||
);
|
||||
// A working-tree test fixture: secret-shaped, and not a credential.
|
||||
write(
|
||||
&home.join("git/proj/tests/scanner.rs"),
|
||||
&format!(" let key = \"{}\";\n", canary()),
|
||||
);
|
||||
// The same shape in ordinary source, which is not a fixture.
|
||||
write(
|
||||
&home.join("git/proj/src/deploy.rs"),
|
||||
&format!("let token = \"{}\";\n", github_token("liveone")),
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
state_dir: home.join(".local/state/nanny"),
|
||||
@@ -187,7 +216,7 @@ async fn no_column_of_any_row_contains_the_secret() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
!dump.contains(CANARY),
|
||||
!dump.contains(&canary()),
|
||||
"the database holds the leaked value"
|
||||
);
|
||||
assert!(
|
||||
@@ -403,6 +432,269 @@ fn nothing_asks_for_a_recursive_watch_on_a_directory_it_cannot_bound() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_working_tree_fixture_is_demoted_and_explains_itself() {
|
||||
let fixture = Fixture::new();
|
||||
let store = store().await;
|
||||
|
||||
nanny_core::sweep(
|
||||
&fixture.scanner(),
|
||||
&fixture.collectors(),
|
||||
&store,
|
||||
&store,
|
||||
Scope::Full,
|
||||
)
|
||||
.await
|
||||
.expect("sweep");
|
||||
|
||||
let found = store.list(&Query::default()).await.expect("list");
|
||||
|
||||
let in_test = found
|
||||
.iter()
|
||||
.find(|f| f.origin.path().ends_with("tests/scanner.rs"))
|
||||
.expect("the fixture finding should exist, not be filtered away");
|
||||
assert_eq!(in_test.context, Context::TestFixture);
|
||||
assert_eq!(
|
||||
in_test.rule_severity,
|
||||
Severity::High,
|
||||
"the rule still says high"
|
||||
);
|
||||
assert_eq!(
|
||||
in_test.severity,
|
||||
Severity::Low,
|
||||
"but the location lowers it"
|
||||
);
|
||||
let (was, reason) = in_test
|
||||
.demotion()
|
||||
.expect("a demotion must account for itself");
|
||||
assert_eq!(was, Severity::High);
|
||||
assert_eq!(reason, "test fixture");
|
||||
|
||||
let in_source = found
|
||||
.iter()
|
||||
.find(|f| f.origin.path().ends_with("src/deploy.rs"))
|
||||
.expect("ordinary source finding");
|
||||
assert_eq!(in_source.context, Context::Live);
|
||||
assert_eq!(
|
||||
in_source.severity,
|
||||
Severity::High,
|
||||
"ordinary source is not demoted"
|
||||
);
|
||||
assert!(in_source.demotion().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transcript_keeps_full_severity_even_when_quoting_a_fixture() {
|
||||
// The canary is in both a test file and a transcript. Only the test file
|
||||
// is demoted: in a transcript there is no way to tell a quoted fixture from
|
||||
// a printed credential, so the fingerprint suppression is the right tool.
|
||||
let fixture = Fixture::new();
|
||||
let store = store().await;
|
||||
|
||||
nanny_core::sweep(
|
||||
&fixture.scanner(),
|
||||
&fixture.collectors(),
|
||||
&store,
|
||||
&store,
|
||||
Scope::Full,
|
||||
)
|
||||
.await
|
||||
.expect("sweep");
|
||||
|
||||
let found = store.list(&Query::default()).await.expect("list");
|
||||
let transcript = found
|
||||
.iter()
|
||||
.find(|f| f.origin.path().ends_with("session.jsonl"))
|
||||
.expect("transcript finding");
|
||||
assert_eq!(transcript.context, Context::Live);
|
||||
assert_eq!(transcript.severity, transcript.rule_severity);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_suppressed_value_stops_being_reported_everywhere_including_transcripts() {
|
||||
let fixture = Fixture::new();
|
||||
let store = store().await;
|
||||
let scanner = fixture.scanner();
|
||||
let collectors = fixture.collectors();
|
||||
|
||||
let first = nanny_core::sweep(&scanner, &collectors, &store, &store, Scope::Full)
|
||||
.await
|
||||
.expect("first sweep");
|
||||
assert!(!first.new_findings.is_empty());
|
||||
|
||||
// The operator looks at the fixture and judges the value harmless.
|
||||
let fixture_finding = store
|
||||
.list(&Query::default())
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.find(|f| f.origin.path().ends_with("tests/scanner.rs"))
|
||||
.expect("fixture finding");
|
||||
|
||||
store
|
||||
.suppress(&NewSuppression {
|
||||
fingerprint: fixture_finding.fingerprint.clone(),
|
||||
path_prefix: None,
|
||||
reason: Some("invented token in a test".into()),
|
||||
label: fixture_finding.label.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("suppress");
|
||||
|
||||
// A later sweep re-reads everything and must not raise it again — in the
|
||||
// fixture or in the transcript that carries the same value.
|
||||
store.clear().await.expect("clear cursors");
|
||||
let second = nanny_core::sweep(&scanner, &collectors, &store, &store, Scope::Full)
|
||||
.await
|
||||
.expect("second sweep");
|
||||
|
||||
assert!(
|
||||
second.suppressed >= 2,
|
||||
"both copies should have been suppressed"
|
||||
);
|
||||
assert!(
|
||||
second.new_findings.is_empty(),
|
||||
"a suppressed value must not produce new findings: {:?}",
|
||||
second.new_findings
|
||||
);
|
||||
|
||||
// Still recorded, so the inventory stays honest — just not in triage.
|
||||
let suppressed = store
|
||||
.list(&Query::default())
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.filter(|f| f.fingerprint == fixture_finding.fingerprint)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(suppressed.len() >= 2);
|
||||
|
||||
let list = store.suppressions().await.expect("suppressions");
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(
|
||||
list.entries()[0].hits >= 2,
|
||||
"the suppression should be credited with its catches"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_scoped_suppression_does_not_silence_the_value_elsewhere() {
|
||||
let fixture = Fixture::new();
|
||||
let store = store().await;
|
||||
let scanner = fixture.scanner();
|
||||
let collectors = fixture.collectors();
|
||||
|
||||
nanny_core::sweep(&scanner, &collectors, &store, &store, Scope::Full)
|
||||
.await
|
||||
.expect("first");
|
||||
|
||||
let target = store
|
||||
.list(&Query::default())
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.find(|f| f.origin.path().ends_with("tests/scanner.rs"))
|
||||
.expect("fixture finding");
|
||||
|
||||
store
|
||||
.suppress(&NewSuppression {
|
||||
fingerprint: target.fingerprint.clone(),
|
||||
path_prefix: Some(fixture.home.join("git")),
|
||||
reason: Some("someone else's checkout".into()),
|
||||
label: target.label.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("suppress");
|
||||
|
||||
store.clear().await.expect("clear cursors");
|
||||
let second = nanny_core::sweep(&scanner, &collectors, &store, &store, Scope::Full)
|
||||
.await
|
||||
.expect("second");
|
||||
|
||||
assert_eq!(
|
||||
second.suppressed, 1,
|
||||
"only the copy inside the scope is suppressed"
|
||||
);
|
||||
assert!(
|
||||
second.new_findings.is_empty(),
|
||||
"the transcript copy was already known, so it re-confirms rather than being new"
|
||||
);
|
||||
|
||||
let transcript = store
|
||||
.list(&Query::default())
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.find(|f| f.origin.path().ends_with("session.jsonl"))
|
||||
.expect("transcript finding");
|
||||
assert_eq!(
|
||||
transcript.status,
|
||||
Status::Open,
|
||||
"a scoped suppression must not silence the value in a session log"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_a_suppression_makes_the_value_reportable_again() {
|
||||
let fixture = Fixture::new();
|
||||
let store = store().await;
|
||||
|
||||
nanny_core::sweep(
|
||||
&fixture.scanner(),
|
||||
&fixture.collectors(),
|
||||
&store,
|
||||
&store,
|
||||
Scope::Full,
|
||||
)
|
||||
.await
|
||||
.expect("sweep");
|
||||
|
||||
let target = store
|
||||
.list(&Query::default())
|
||||
.await
|
||||
.expect("list")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("some finding");
|
||||
|
||||
let id = store
|
||||
.suppress(&NewSuppression {
|
||||
fingerprint: target.fingerprint.clone(),
|
||||
path_prefix: None,
|
||||
reason: None,
|
||||
label: target.label.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("suppress");
|
||||
|
||||
assert_eq!(store.suppressions().await.expect("list").len(), 1);
|
||||
store.unsuppress(id).await.expect("unsuppress");
|
||||
assert!(store.suppressions().await.expect("list").is_empty());
|
||||
|
||||
assert!(
|
||||
store.unsuppress(id).await.is_err(),
|
||||
"removing a suppression that is gone should be an error, not a silent no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suppressing_the_same_judgement_twice_is_one_suppression() {
|
||||
let store = store().await;
|
||||
let s = NewSuppression {
|
||||
fingerprint: nanny_entities::Fingerprint::from_stored("abcdef0123456789".into()),
|
||||
path_prefix: None,
|
||||
reason: Some("first time".into()),
|
||||
label: "GitHub token".into(),
|
||||
};
|
||||
|
||||
let first = store.suppress(&s).await.expect("first");
|
||||
let second = store.suppress(&s).await.expect("second");
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"a repeated judgement must not accumulate duplicates"
|
||||
);
|
||||
assert_eq!(store.suppressions().await.expect("list").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_cursors_makes_the_next_sweep_read_everything_again() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Where a finding was found, as a factor in how much it should worry anyone.
|
||||
//!
|
||||
//! Severity as a rule defines it answers "what kind of credential is this" — an
|
||||
//! Anthropic key is critical because Anthropic keys are critical. That is only
|
||||
//! half the question an operator is actually asking, which is what a leak *at
|
||||
//! this location* costs them. A live key pasted into a session transcript and a
|
||||
//! fake one in a `#[cfg(test)]` block score identically under the rule alone,
|
||||
//! and the second one is why nobody reads the list.
|
||||
//!
|
||||
//! So severity is the rule's severity adjusted by context. Two rules govern
|
||||
//! that adjustment, and both matter:
|
||||
//!
|
||||
//! **Demote, never drop.** A context never suppresses a finding, it lowers it.
|
||||
//! `crates/nanny-core/src/scan.rs:271` should read `low — test fixture`, not
|
||||
//! vanish. A tool that hides things is one you cannot reason about, and the
|
||||
//! whole value of nanny is that its silence means something.
|
||||
//!
|
||||
//! **Only in working trees.** A `.md` file in a repository is documentation; a
|
||||
//! `.md` file in `~/.claude/projects/*/memory/` is an agent memory, which is
|
||||
//! exactly where a real credential ends up. In a harness store there is no way
|
||||
//! to tell "the transcript quotes some test code" from "the transcript printed
|
||||
//! a live key", so nothing there is demoted. Fixtures that reach a transcript
|
||||
//! are dealt with by suppressing the fingerprint instead — which is the other
|
||||
//! half of this design, and composes with it: once a fake token is judged not a
|
||||
//! secret, every copy of it goes quiet, transcripts included.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// What kind of place a finding was found in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Context {
|
||||
/// Somewhere a credential would only be if it leaked. The default, and the
|
||||
/// only context that carries a rule's severity unchanged.
|
||||
Live,
|
||||
/// A test, benchmark, example or fixture. Secret-shaped strings are
|
||||
/// expected here and are usually invented.
|
||||
TestFixture,
|
||||
/// Documentation. Credentials in prose are nearly always illustrative.
|
||||
Documentation,
|
||||
/// A template, sample or `.example` config — a file whose whole purpose is
|
||||
/// to show the shape of a value without being one.
|
||||
ExampleConfig,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// How many severity steps to demote by.
|
||||
///
|
||||
/// Two, not one: the point is to move a fixture out of the range that
|
||||
/// interrupts anybody (`alert.min_severity` defaults to `high`), and a
|
||||
/// single step leaves a critical at high, which still shouts.
|
||||
#[must_use]
|
||||
pub const fn demotion_steps(self) -> u8 {
|
||||
match self {
|
||||
Self::Live => 0,
|
||||
Self::TestFixture | Self::Documentation | Self::ExampleConfig => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short reason, shown wherever a demoted severity is displayed. A silently
|
||||
/// downgraded finding is one nobody can trust, so this is never optional.
|
||||
#[must_use]
|
||||
pub const fn reason(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "",
|
||||
Self::TestFixture => "test fixture",
|
||||
Self::Documentation => "documentation",
|
||||
Self::ExampleConfig => "example config",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this context changes anything.
|
||||
#[must_use]
|
||||
pub const fn is_live(self) -> bool {
|
||||
matches!(self, Self::Live)
|
||||
}
|
||||
|
||||
/// Classify a location within a repository working tree, using the file's
|
||||
/// own text as well as its path.
|
||||
///
|
||||
/// `offset` is where in `text` the finding sits. It matters because of
|
||||
/// Rust: the language's convention is a `#[cfg(test)] mod tests` at the
|
||||
/// bottom of the *production* source file, so `crates/x/src/scan.rs` holds
|
||||
/// both real code and fixtures and no path rule can tell them apart. Any
|
||||
/// match after that attribute is test code.
|
||||
///
|
||||
/// One caveat, and it is why this is a demotion rather than a filter: a file
|
||||
/// larger than one scan chunk may put the match and the attribute in
|
||||
/// different chunks, and the marker is then invisible. The failure is a
|
||||
/// finding that stays at full severity — noisy, not silent, which is the
|
||||
/// right way round.
|
||||
#[must_use]
|
||||
pub fn classify_in_source(path: &Path, text: &str, offset: usize) -> Self {
|
||||
let by_path = Self::classify_working_tree(path);
|
||||
if !by_path.is_live() {
|
||||
return by_path;
|
||||
}
|
||||
if is_after_inline_test_marker(path, text, offset) {
|
||||
return Self::TestFixture;
|
||||
}
|
||||
Self::Live
|
||||
}
|
||||
|
||||
/// Classify a path within a repository working tree.
|
||||
///
|
||||
/// Callers must only apply this to working trees — see the module docs. The
|
||||
/// function itself has no way to check, because the harness is not a
|
||||
/// property of the path.
|
||||
#[must_use]
|
||||
pub fn classify_working_tree(path: &Path) -> Self {
|
||||
let text = path.to_string_lossy();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
if is_test_path(&lower, &name) {
|
||||
return Self::TestFixture;
|
||||
}
|
||||
if is_example_config(&name) {
|
||||
return Self::ExampleConfig;
|
||||
}
|
||||
if is_documentation(&lower, &name) {
|
||||
return Self::Documentation;
|
||||
}
|
||||
Self::Live
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory segments that mean "the code here is not run in production".
|
||||
const TEST_DIRS: &[&str] = &[
|
||||
"/tests/",
|
||||
"/test/",
|
||||
"/__tests__/",
|
||||
"/spec/",
|
||||
"/specs/",
|
||||
"/benches/",
|
||||
"/benchmark/",
|
||||
"/benchmarks/",
|
||||
"/examples/",
|
||||
"/example/",
|
||||
"/fixtures/",
|
||||
"/fixture/",
|
||||
"/testdata/",
|
||||
"/test-data/",
|
||||
"/mocks/",
|
||||
"/mock/",
|
||||
"/e2e/",
|
||||
];
|
||||
|
||||
fn is_test_path(lower: &str, name: &str) -> bool {
|
||||
if TEST_DIRS.iter().any(|d| lower.contains(d)) {
|
||||
return true;
|
||||
}
|
||||
// Conventional test-file naming across the languages in use here.
|
||||
let stem = name.split('.').next().unwrap_or_default();
|
||||
stem.starts_with("test_")
|
||||
|| stem.ends_with("_test")
|
||||
|| stem.ends_with("_tests")
|
||||
|| stem.ends_with("_spec")
|
||||
|| name.contains(".test.")
|
||||
|| name.contains(".spec.")
|
||||
|| name == "conftest.py"
|
||||
}
|
||||
|
||||
/// Whether `offset` falls after a marker that puts the rest of the file in a
|
||||
/// test module.
|
||||
///
|
||||
/// Rust only, deliberately. It is the language in this workspace whose
|
||||
/// convention co-locates tests with production code in one file; Go puts them in
|
||||
/// `_test.go`, Python and TypeScript in test directories, and all of those the
|
||||
/// path rules already catch. Generalising this to markers like `describe(` would
|
||||
/// buy nothing and start demoting prose.
|
||||
fn is_after_inline_test_marker(path: &Path, text: &str, offset: usize) -> bool {
|
||||
const MARKERS: &[&str] = &["#[cfg(test)]", "#![cfg(test)]"];
|
||||
|
||||
if path.extension().is_none_or(|e| e != "rs") {
|
||||
return false;
|
||||
}
|
||||
let before = &text[..offset.min(text.len())];
|
||||
MARKERS.iter().any(|m| before.contains(m))
|
||||
}
|
||||
|
||||
fn is_example_config(name: &str) -> bool {
|
||||
const SUFFIXES: &[&str] = &[
|
||||
".example",
|
||||
".sample",
|
||||
".tmpl",
|
||||
".template",
|
||||
".dist",
|
||||
".defaults",
|
||||
];
|
||||
SUFFIXES.iter().any(|s| name.ends_with(s))
|
||||
|| name.starts_with("example.")
|
||||
|| name.starts_with("sample.")
|
||||
}
|
||||
|
||||
fn is_documentation(lower: &str, name: &str) -> bool {
|
||||
const DOC_DIRS: &[&str] = &["/doc/", "/docs/", "/documentation/", "/book/", "/site/"];
|
||||
const DOC_EXTS: &[&str] = &[".md", ".markdown", ".rst", ".adoc", ".asciidoc", ".txt"];
|
||||
DOC_DIRS.iter().any(|d| lower.contains(d)) || DOC_EXTS.iter().any(|e| name.ends_with(e))
|
||||
}
|
||||
|
||||
impl fmt::Display for Context {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Live => "live",
|
||||
Self::TestFixture => "test-fixture",
|
||||
Self::Documentation => "documentation",
|
||||
Self::ExampleConfig => "example-config",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Context {
|
||||
type Err = crate::Error;
|
||||
|
||||
fn from_str(s: &str) -> crate::Result<Self> {
|
||||
Ok(match s {
|
||||
"live" => Self::Live,
|
||||
"test-fixture" => Self::TestFixture,
|
||||
"documentation" => Self::Documentation,
|
||||
"example-config" => Self::ExampleConfig,
|
||||
_ => {
|
||||
return Err(crate::Error::UnknownVariant {
|
||||
field: "context",
|
||||
value: s.to_owned(),
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn classify(path: &str) -> Context {
|
||||
Context::classify_working_tree(Path::new(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_test_module_is_a_fixture() {
|
||||
// The finding that prompted all this: nanny detecting its own test
|
||||
// fixtures and calling them critical.
|
||||
assert_eq!(
|
||||
classify("/home/x/git/nanny/crates/nanny-data/tests/end_to_end.rs"),
|
||||
Context::TestFixture
|
||||
);
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/src/parser_test.rs"),
|
||||
Context::TestFixture
|
||||
);
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/benches/auth_mode.rs"),
|
||||
Context::TestFixture
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conventions_from_other_languages_are_recognised() {
|
||||
for path in [
|
||||
"/home/x/git/p/packages/ai/test/total-tokens.test.ts",
|
||||
"/home/x/git/p/src/__tests__/login.js",
|
||||
"/home/x/git/p/spec/models/user_spec.rb",
|
||||
"/home/x/git/p/tests/conftest.py",
|
||||
"/home/x/git/p/internal/testdata/keys.json",
|
||||
] {
|
||||
assert_eq!(classify(path), Context::TestFixture, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documentation_and_templates_are_recognised() {
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/docs/exchanges.md"),
|
||||
Context::Documentation
|
||||
);
|
||||
assert_eq!(classify("/home/x/git/p/readme.md"), Context::Documentation);
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/config.toml.example"),
|
||||
Context::ExampleConfig
|
||||
);
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/asset/config/config.toml.tmpl"),
|
||||
Context::ExampleConfig
|
||||
);
|
||||
assert_eq!(
|
||||
classify("/home/x/git/p/.env.sample"),
|
||||
Context::ExampleConfig
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_source_and_real_config_stay_live() {
|
||||
for path in [
|
||||
"/home/x/git/p/src/main.rs",
|
||||
"/home/x/git/p/.env",
|
||||
"/home/x/git/p/config.toml",
|
||||
"/home/x/git/p/.secrets/stripe.yml",
|
||||
"/home/x/git/p/deploy/production.yaml",
|
||||
] {
|
||||
assert_eq!(classify(path), Context::Live, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_inline_test_module_is_a_fixture() {
|
||||
// The case that prompted this: nanny's own scanner tests live in a
|
||||
// `#[cfg(test)] mod tests` at the bottom of a production source file,
|
||||
// so the path says `src/scan.rs` and says nothing useful.
|
||||
let text = "fn real() {}\n\n#[cfg(test)]\nmod tests {\n let k = \"SECRET\";\n}\n";
|
||||
let marker = text.find("#[cfg(test)]").unwrap();
|
||||
let secret = text.find("SECRET").unwrap();
|
||||
let path = Path::new("/home/x/git/nanny/crates/nanny-core/src/scan.rs");
|
||||
|
||||
assert_eq!(
|
||||
Context::classify_in_source(path, text, secret),
|
||||
Context::TestFixture,
|
||||
"a match after #[cfg(test)] is test code"
|
||||
);
|
||||
assert_eq!(
|
||||
Context::classify_in_source(path, text, marker.saturating_sub(1)),
|
||||
Context::Live,
|
||||
"a match before it is production code in the same file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inline_marker_is_rust_only() {
|
||||
// Other languages keep tests in their own files, which the path rules
|
||||
// already catch. Looking for Rust attributes elsewhere would only find
|
||||
// them quoted in prose.
|
||||
let text = "before\n#[cfg(test)]\nafter SECRET\n";
|
||||
let secret = text.find("SECRET").unwrap();
|
||||
assert_eq!(
|
||||
Context::classify_in_source(Path::new("/home/x/git/p/notes.py"), text, secret),
|
||||
Context::Live
|
||||
);
|
||||
assert_eq!(
|
||||
Context::classify_in_source(Path::new("/home/x/git/p/src/main.rs"), text, secret),
|
||||
Context::TestFixture
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_classification_still_wins_when_it_applies() {
|
||||
let text = "no marker here, SECRET";
|
||||
let secret = text.find("SECRET").unwrap();
|
||||
assert_eq!(
|
||||
Context::classify_in_source(Path::new("/home/x/git/p/tests/a.rs"), text, secret),
|
||||
Context::TestFixture
|
||||
);
|
||||
assert_eq!(
|
||||
Context::classify_in_source(Path::new("/home/x/git/p/docs/a.md"), text, secret),
|
||||
Context::Documentation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contest_is_not_a_test() {
|
||||
// `contest`, `latest`, `manifest` all contain "test"; matching on a bare
|
||||
// substring would classify half a repository as fixtures.
|
||||
assert_eq!(classify("/home/x/git/p/src/contest.rs"), Context::Live);
|
||||
assert_eq!(classify("/home/x/git/p/src/manifest.rs"), Context::Live);
|
||||
assert_eq!(classify("/home/x/git/p/src/latest.rs"), Context::Live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_context_states_a_reason_it_can_show() {
|
||||
for context in [
|
||||
Context::TestFixture,
|
||||
Context::Documentation,
|
||||
Context::ExampleConfig,
|
||||
] {
|
||||
assert!(
|
||||
!context.reason().is_empty(),
|
||||
"{context} must explain itself"
|
||||
);
|
||||
assert_eq!(context.demotion_steps(), 2);
|
||||
assert!(!context.is_live());
|
||||
}
|
||||
assert_eq!(Context::Live.demotion_steps(), 0);
|
||||
assert!(Context::Live.is_live());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts_round_trip() {
|
||||
for context in [
|
||||
Context::Live,
|
||||
Context::TestFixture,
|
||||
Context::Documentation,
|
||||
Context::ExampleConfig,
|
||||
] {
|
||||
assert_eq!(Context::from_str(&context.to_string()).unwrap(), context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use std::str::FromStr;
|
||||
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{Fingerprint, Harness, Origin, Preview, RuleId, Severity, rule::Confidence};
|
||||
use crate::{Context, Fingerprint, Harness, Origin, Preview, RuleId, Severity, rule::Confidence};
|
||||
|
||||
/// Database identity of a finding.
|
||||
#[derive(
|
||||
@@ -100,7 +100,15 @@ pub struct NewFinding {
|
||||
pub rule: RuleId,
|
||||
/// Display label from the rule.
|
||||
pub label: String,
|
||||
/// Effective severity: [`Self::rule_severity`] demoted by [`Self::context`].
|
||||
/// This is what filters, sorts and alerts on.
|
||||
pub severity: Severity,
|
||||
/// What the rule alone says the credential is worth. Kept so that a demoted
|
||||
/// finding can explain itself — "critical, demoted to medium because test
|
||||
/// fixture" — rather than silently reading as unimportant.
|
||||
pub rule_severity: Severity,
|
||||
/// Where it was found, as a factor in severity.
|
||||
pub context: Context,
|
||||
pub confidence: Confidence,
|
||||
/// Whose store the copy is in.
|
||||
pub harness: Harness,
|
||||
@@ -119,6 +127,10 @@ pub struct NewFinding {
|
||||
pub matched_len: usize,
|
||||
/// When the scan that found it ran.
|
||||
pub observed_at: OffsetDateTime,
|
||||
/// Status to record if this is a location nanny has not seen before.
|
||||
/// [`Status::Ignored`] when a suppression covers it, so a judgement the
|
||||
/// operator has already made is not put back in front of them.
|
||||
pub initial_status: Status,
|
||||
}
|
||||
|
||||
impl NewFinding {
|
||||
@@ -144,7 +156,12 @@ pub struct Finding {
|
||||
pub fingerprint: Fingerprint,
|
||||
pub rule: RuleId,
|
||||
pub label: String,
|
||||
/// Effective severity, after any demotion by [`Self::context`].
|
||||
pub severity: Severity,
|
||||
/// What the rule alone says.
|
||||
pub rule_severity: Severity,
|
||||
/// Where it was found, and therefore why the two may differ.
|
||||
pub context: Context,
|
||||
pub confidence: Confidence,
|
||||
pub harness: Harness,
|
||||
pub origin: Origin,
|
||||
@@ -166,6 +183,17 @@ pub struct Finding {
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
/// Whether this finding's severity was lowered by where it was found, and
|
||||
/// the reason if so.
|
||||
///
|
||||
/// Returned rather than stored as prose so that the explanation cannot
|
||||
/// drift from the numbers it explains.
|
||||
#[must_use]
|
||||
pub fn demotion(&self) -> Option<(Severity, &'static str)> {
|
||||
(self.severity != self.rule_severity && !self.context.is_live())
|
||||
.then_some((self.rule_severity, self.context.reason()))
|
||||
}
|
||||
|
||||
/// Sort key for the UI: unresolved first, then most serious, then most
|
||||
/// recent.
|
||||
#[must_use]
|
||||
|
||||
@@ -20,17 +20,21 @@
|
||||
//! See `agent-credentials.md` §6 in the architecture repo: "treat stdout as
|
||||
//! publication". nanny treats its own database the same way.
|
||||
|
||||
pub mod context;
|
||||
pub mod finding;
|
||||
pub mod harness;
|
||||
pub mod location;
|
||||
pub mod redact;
|
||||
pub mod rule;
|
||||
pub mod suppress;
|
||||
|
||||
pub use context::Context;
|
||||
pub use finding::{Finding, FindingId, NewFinding, Status, StatusChange};
|
||||
pub use harness::Harness;
|
||||
pub use location::{Cursor, Excerpt, Origin, SourceId};
|
||||
pub use redact::{Fingerprint, Mask, Preview, Salt, Secret};
|
||||
pub use rule::{Confidence, RotationHint, Rule, RuleId, RuleKind, Severity};
|
||||
pub use suppress::{NewSuppression, Suppression, SuppressionId, Suppressions};
|
||||
|
||||
/// Errors produced by the domain layer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -142,6 +142,21 @@ impl Severity {
|
||||
Self::Low => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The severity `steps` below this one, floored at [`Self::Low`].
|
||||
///
|
||||
/// Used to adjust a rule's severity by where the finding was found. It
|
||||
/// never reaches "no severity", because a demoted finding is still a
|
||||
/// finding — see [`crate::context`].
|
||||
#[must_use]
|
||||
pub const fn demoted(self, steps: u8) -> Self {
|
||||
match self.rank().saturating_add(steps) {
|
||||
0 => Self::Critical,
|
||||
1 => Self::High,
|
||||
2 => Self::Medium,
|
||||
_ => Self::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Severity {
|
||||
@@ -279,6 +294,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demotion_lowers_severity_and_floors_at_low() {
|
||||
assert_eq!(Severity::Critical.demoted(2), Severity::Medium);
|
||||
assert_eq!(Severity::High.demoted(2), Severity::Low);
|
||||
assert_eq!(Severity::Medium.demoted(2), Severity::Low);
|
||||
assert_eq!(Severity::Low.demoted(2), Severity::Low);
|
||||
assert_eq!(
|
||||
Severity::Critical.demoted(0),
|
||||
Severity::Critical,
|
||||
"no steps, no change"
|
||||
);
|
||||
assert_eq!(
|
||||
Severity::Critical.demoted(200),
|
||||
Severity::Low,
|
||||
"never wraps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_ranks_critical_first() {
|
||||
let mut v = [Severity::Low, Severity::Critical, Severity::Medium];
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Suppressions: remembering that a value is not a secret.
|
||||
//!
|
||||
//! Marking a finding `ignored` is a judgement about a *row* — this location,
|
||||
//! this scan. But "that is a test fixture, not a credential" is a judgement
|
||||
//! about the **value**, and it stays true wherever the value turns up next.
|
||||
//! Without somewhere to record that, an operator dismisses the same invented
|
||||
//! token every time an agent quotes the file it lives in, which is the failure
|
||||
//! mode that gets a monitor switched off.
|
||||
//!
|
||||
//! A suppression is therefore keyed by [`Fingerprint`] — the salted hash nanny
|
||||
//! already stores — and optionally scoped to a path prefix. That keying is what
|
||||
//! makes the feature safe to have at all: the suppression list holds no secret
|
||||
//! values, only the same one-way hashes as the findings table, so remembering a
|
||||
//! judgement costs nothing that the database did not already risk.
|
||||
//!
|
||||
//! Two shapes, and the difference is the operator's actual intent:
|
||||
//!
|
||||
//! * **Unscoped** — "this value is not a credential, anywhere." Right for an
|
||||
//! invented token in a test fixture, which is equally harmless when an agent
|
||||
//! quotes it into a transcript.
|
||||
//! * **Scoped to a path** — "do not report this here." Right for a third-party
|
||||
//! checkout whose committed test keys are real credentials belonging to
|
||||
//! somebody else: not yours to rotate, but still worth knowing about if the
|
||||
//! same value ever showed up in your own session log.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{Fingerprint, NewFinding};
|
||||
|
||||
/// Database identity of a suppression.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct SuppressionId(pub i64);
|
||||
|
||||
impl fmt::Display for SuppressionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A suppression as requested, before storage assigns identity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSuppression {
|
||||
/// The value being judged, by its salted hash.
|
||||
pub fingerprint: Fingerprint,
|
||||
/// Limit the judgement to findings under this path. `None` means anywhere.
|
||||
pub path_prefix: Option<PathBuf>,
|
||||
/// Why, in the operator's words. Not optional in spirit: a suppression
|
||||
/// nobody can account for later is one nobody will dare remove.
|
||||
pub reason: Option<String>,
|
||||
/// A label for the value, carried over from the finding that prompted this
|
||||
/// — the rule's name, never the value.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// A stored suppression.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Suppression {
|
||||
pub id: SuppressionId,
|
||||
pub fingerprint: Fingerprint,
|
||||
pub path_prefix: Option<PathBuf>,
|
||||
pub reason: Option<String>,
|
||||
pub label: String,
|
||||
pub created_at: OffsetDateTime,
|
||||
/// How many findings this suppression has caught since it was created.
|
||||
/// Shown by `nanny suppressions` so a rule that stopped earning its place
|
||||
/// is visible rather than accumulating silently.
|
||||
pub hits: u64,
|
||||
}
|
||||
|
||||
impl Suppression {
|
||||
/// Whether this suppression covers a finding at `path` with `fingerprint`.
|
||||
#[must_use]
|
||||
pub fn covers(&self, fingerprint: &Fingerprint, path: &Path) -> bool {
|
||||
if &self.fingerprint != fingerprint {
|
||||
return false;
|
||||
}
|
||||
self.path_prefix
|
||||
.as_ref()
|
||||
.is_none_or(|prefix| path.starts_with(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaded suppression list, consulted once per sweep rather than per
|
||||
/// finding.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Suppressions {
|
||||
entries: Vec<Suppression>,
|
||||
}
|
||||
|
||||
impl Suppressions {
|
||||
/// Wrap the stored suppressions.
|
||||
#[must_use]
|
||||
pub fn new(entries: Vec<Suppression>) -> Self {
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// The suppression covering this finding, if any.
|
||||
#[must_use]
|
||||
pub fn covering(&self, finding: &NewFinding) -> Option<&Suppression> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|s| s.covers(&finding.fingerprint, finding.origin.path()))
|
||||
}
|
||||
|
||||
/// Whether anything covers this finding.
|
||||
#[must_use]
|
||||
pub fn covers(&self, finding: &NewFinding) -> bool {
|
||||
self.covering(finding).is_some()
|
||||
}
|
||||
|
||||
/// How many suppressions are loaded.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether there are none.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The suppressions, for listing.
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[Suppression] {
|
||||
&self.entries
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
Confidence, Context, Harness, Origin, Preview, RuleId, Severity, finding::NewFinding,
|
||||
};
|
||||
|
||||
fn fingerprint(hex: &str) -> Fingerprint {
|
||||
Fingerprint::from_stored(hex.to_owned())
|
||||
}
|
||||
|
||||
fn suppression(hex: &str, prefix: Option<&str>) -> Suppression {
|
||||
Suppression {
|
||||
id: SuppressionId(1),
|
||||
fingerprint: fingerprint(hex),
|
||||
path_prefix: prefix.map(PathBuf::from),
|
||||
reason: Some("test fixture".into()),
|
||||
label: "GitHub token".into(),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
hits: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn finding_at(hex: &str, path: &str) -> NewFinding {
|
||||
NewFinding {
|
||||
fingerprint: fingerprint(hex),
|
||||
rule: RuleId::new("github-token"),
|
||||
label: "GitHub token".into(),
|
||||
severity: Severity::High,
|
||||
rule_severity: Severity::High,
|
||||
context: Context::Live,
|
||||
confidence: Confidence::Certain,
|
||||
harness: Harness::WorkingTree,
|
||||
origin: Origin::File {
|
||||
path: PathBuf::from(path),
|
||||
},
|
||||
detail: None,
|
||||
line: Some(1),
|
||||
byte_offset: Some(0),
|
||||
preview: Preview::from_safe("…••••[40]…"),
|
||||
matched_len: 40,
|
||||
observed_at: OffsetDateTime::now_utc(),
|
||||
initial_status: crate::Status::Open,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unscoped_suppression_covers_the_value_everywhere() {
|
||||
let s = suppression("aaaa1111", None);
|
||||
assert!(s.covers(
|
||||
&fingerprint("aaaa1111"),
|
||||
Path::new("/home/x/git/p/tests/a.rs")
|
||||
));
|
||||
assert!(
|
||||
s.covers(
|
||||
&fingerprint("aaaa1111"),
|
||||
Path::new("/home/x/.claude/history.jsonl")
|
||||
),
|
||||
"an invented token is invented in a transcript too"
|
||||
);
|
||||
assert!(!s.covers(
|
||||
&fingerprint("bbbb2222"),
|
||||
Path::new("/home/x/git/p/tests/a.rs")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_suppression_stops_at_its_prefix() {
|
||||
let s = suppression("aaaa1111", Some("/home/x/git/upstream"));
|
||||
assert!(s.covers(
|
||||
&fingerprint("aaaa1111"),
|
||||
Path::new("/home/x/git/upstream/tests/a.rs")
|
||||
));
|
||||
assert!(
|
||||
!s.covers(
|
||||
&fingerprint("aaaa1111"),
|
||||
Path::new("/home/x/.claude/history.jsonl")
|
||||
),
|
||||
"somebody else's committed key is still news in my own session log"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_matches_whole_path_components_only() {
|
||||
let s = suppression("aaaa1111", Some("/home/x/git/up"));
|
||||
assert!(
|
||||
!s.covers(
|
||||
&fingerprint("aaaa1111"),
|
||||
Path::new("/home/x/git/upstream/a.rs")
|
||||
),
|
||||
"`up` must not silence `upstream`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_finds_the_covering_entry() {
|
||||
let list = Suppressions::new(vec![
|
||||
suppression("aaaa1111", Some("/home/x/git/upstream")),
|
||||
suppression("bbbb2222", None),
|
||||
]);
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(list.covers(&finding_at("bbbb2222", "/anywhere/at/all.rs")));
|
||||
assert!(!list.covers(&finding_at("aaaa1111", "/home/x/git/mine/a.rs")));
|
||||
|
||||
let covering = list
|
||||
.covering(&finding_at("aaaa1111", "/home/x/git/upstream/a.rs"))
|
||||
.expect("should be covered");
|
||||
assert_eq!(covering.reason.as_deref(), Some("test fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_covers_nothing() {
|
||||
let list = Suppressions::default();
|
||||
assert!(list.is_empty());
|
||||
assert!(!list.covers(&finding_at("aaaa1111", "/home/x/a.rs")));
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,61 @@ nanny ignore --under ~/git/some-upstream-checkout --note "not my credentials"
|
||||
nanny rotated --fingerprint c7c0c9894025e096 # every copy of one secret, at once
|
||||
```
|
||||
|
||||
### Severity depends on where, not only on what
|
||||
|
||||
A rule's severity answers "what kind of credential is this" — an Anthropic key is
|
||||
critical because Anthropic keys are critical. That is half the question. The
|
||||
other half is what a leak *at this location* costs you, and without it a fake key
|
||||
in a `#[cfg(test)]` block scores the same as a live one in a transcript.
|
||||
|
||||
So in a working tree, severity is the rule's severity **demoted** by context —
|
||||
tests, benchmarks, fixtures, examples, documentation, `.example`/`.tmpl` configs
|
||||
— and the finding says so:
|
||||
|
||||
```
|
||||
severity medium (certain) — critical for this rule, lowered because this is test fixture
|
||||
```
|
||||
|
||||
Demoted, not dropped. `crates/nanny-core/src/scan.rs:271` should read
|
||||
`medium — test fixture`, not vanish; a tool that hides things is one you cannot
|
||||
reason about, and nanny's silence is only worth something if it means something.
|
||||
Demotion drops a finding below `alert.min_severity`, so it stops interrupting
|
||||
without disappearing.
|
||||
|
||||
**Only in working trees.** A `.md` in a repo is documentation; a `.md` in
|
||||
`~/.claude/projects/*/memory/` is an agent memory, which is exactly where a real
|
||||
credential ends up. In a harness store there is no way to tell "the transcript
|
||||
quotes a test file" from "the transcript printed a live key", so nothing there is
|
||||
demoted.
|
||||
|
||||
### Remembering that a value is not a credential
|
||||
|
||||
Marking a finding `ignored` settles one row. But "that is an invented token"
|
||||
is true of the **value**, wherever it turns up next — including in the transcript
|
||||
of the session where an agent read the file it lives in. So a judgement can be
|
||||
remembered:
|
||||
|
||||
```sh
|
||||
nanny ignore 849 --everywhere # this value is not a credential, anywhere
|
||||
nanny ignore --under ~/git/upstream --everywhere --note "their fixtures, not my keys" # ...or just not here
|
||||
nanny suppressions # what has been remembered, and what it caught
|
||||
nanny unsuppress 3 # forget it; the value is reported again
|
||||
```
|
||||
|
||||
In the triage screen, `i` ignores the row and `I` ignores the value everywhere.
|
||||
|
||||
Suppressions are keyed by fingerprint, which is what makes the feature safe to
|
||||
have at all: the table holds the same one-way salted hashes as the findings
|
||||
table and no values, so remembering a judgement risks nothing the database did
|
||||
not already. Suppressed findings are still **recorded** — the inventory stays
|
||||
honest — they simply land `ignored` and never alert. `nanny suppressions` shows
|
||||
how many each has caught, so one that has stopped earning its place is visible
|
||||
rather than accumulating silently.
|
||||
|
||||
The two mechanisms compose. Demotion handles the fixture where it lives;
|
||||
suppression handles every copy of that same fake token wherever an agent quoted
|
||||
it, transcripts included.
|
||||
|
||||
### Tuning the noise
|
||||
|
||||
`~/git` is watched by default because a spilled value there is a spill whether
|
||||
@@ -102,8 +157,8 @@ or not it was ever committed. On a machine with third-party checkouts it is also
|
||||
where most of the false positives are: someone else's test keys, committed on
|
||||
purpose. Build output, vendored dependencies, tool caches and minified assets
|
||||
are excluded already; beyond that, `exclude` in the config takes globs, and
|
||||
`nanny ignore --under` handles a checkout you would rather keep but not hear
|
||||
about.
|
||||
`nanny ignore --under ... --everywhere` handles a checkout you would rather keep
|
||||
but not hear about.
|
||||
|
||||
The triage screen is the default because the usual question is "do I have to
|
||||
rotate something right now", and that is a list to walk rather than a query to
|
||||
|
||||
Reference in New Issue
Block a user