Files
nanny/crates/nanny-cli/src/format.rs
T
grenadeandClaude Opus 5 467c8d35ad 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
2026-09-02 16:21:22 +03:00

319 lines
10 KiB
Rust

//! Rendering findings for a terminal and for scripts.
//!
//! Everything here works from a [`Finding`], which carries no secret value, so
//! there is no output mode — table, JSON or notification — that could disclose
//! one. That is a property of the type, not of the care taken in this module.
use nanny_entities::{Finding, Severity, Status};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// ANSI colour, used only when stdout is a terminal.
pub struct Style {
enabled: bool,
}
impl Style {
/// Colour if stdout is a TTY and `NO_COLOR` is unset.
#[must_use]
pub fn detect() -> Self {
let tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
Self {
enabled: tty && std::env::var_os("NO_COLOR").is_none(),
}
}
/// Disable colour unconditionally. Used by tests, which assert on the text
/// rather than on the escape codes around it.
#[must_use]
#[cfg_attr(not(test), allow(dead_code))]
pub const fn plain() -> Self {
Self { enabled: false }
}
fn paint(&self, code: &str, text: &str) -> String {
if self.enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_owned()
}
}
/// Colour a severity by how much it should worry the reader.
#[must_use]
pub fn severity(&self, severity: Severity) -> String {
let code = match severity {
Severity::Critical => "1;31",
Severity::High => "31",
Severity::Medium => "33",
Severity::Low => "90",
};
self.paint(code, &severity.to_string())
}
/// Colour a status: unresolved stands out, resolved recedes.
#[must_use]
pub fn status(&self, status: Status) -> String {
let code = match status {
Status::Open => "1;33",
Status::Acknowledged => "36",
Status::Rotated => "32",
Status::Ignored => "90",
};
self.paint(code, &status.to_string())
}
/// Dim secondary text.
#[must_use]
pub fn dim(&self, text: &str) -> String {
self.paint("90", text)
}
/// Emphasise a heading.
#[must_use]
pub fn bold(&self, text: &str) -> String {
self.paint("1", text)
}
}
/// Render a time as something a human can act on: a clock time today, a date
/// before that.
#[must_use]
pub fn when(t: OffsetDateTime) -> String {
let local =
t.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC));
let now = OffsetDateTime::now_utc();
if local.date() == now.to_offset(local.offset()).date() {
format!(
"{:02}:{:02}:{:02}",
local.hour(),
local.minute(),
local.second()
)
} else {
format!("{} {:02}", month(local.month()), local.day())
}
}
const fn month(m: time::Month) -> &'static str {
use time::Month::{
April, August, December, February, January, July, June, March, May, November, October,
September,
};
match m {
January => "Jan",
February => "Feb",
March => "Mar",
April => "Apr",
May => "May",
June => "Jun",
July => "Jul",
August => "Aug",
September => "Sep",
October => "Oct",
November => "Nov",
December => "Dec",
}
}
/// Shorten a path for a fixed-width column, keeping the end — the filename and
/// its directory are what identify a finding, the leading `/home/grenade` is
/// the same on every row.
#[must_use]
pub fn short_path(path: &std::path::Path, home: &std::path::Path, width: usize) -> String {
let text = path.strip_prefix(home).map_or_else(
|_| path.display().to_string(),
|rest| format!("~/{}", rest.display()),
);
if text.chars().count() <= width {
return text;
}
let keep: String = text
.chars()
.rev()
.take(width.saturating_sub(1))
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{keep}")
}
/// A finding's location — path and line — rendered to fit exactly `width`
/// characters.
///
/// The line number is budgeted for first and the path shortened around it.
/// Truncating the path to the full width and then appending `:1712` pushes the
/// row over the column, and the terminal takes the line number off the end —
/// leaving a location that names a file but not a place in it.
#[must_use]
pub fn location(finding: &Finding, home: &std::path::Path, width: usize) -> String {
let suffix = finding.line.map_or_else(String::new, |l| format!(":{l}"));
let path = short_path(
finding.origin.path(),
home,
width.saturating_sub(suffix.chars().count()).max(8),
);
format!("{path}{suffix}")
}
/// One finding as a table row.
#[must_use]
pub fn row(finding: &Finding, home: &std::path::Path, style: &Style, width: usize) -> String {
let where_ = location(finding, home, width);
format!(
"{:>5} {:<8} {:<9} {:<9} {:<26} {where_}",
finding.id.0,
when(finding.last_seen),
style.severity(finding.severity),
finding.harness.slug(),
finding.label,
)
}
/// The header for [`row`].
#[must_use]
pub fn header(style: &Style) -> String {
style.bold(&format!(
"{:>5} {:<8} {:<9} {:<9} {:<26} {}",
"ID", "WHEN", "SEVERITY", "HARNESS", "WHAT", "WHERE"
))
}
/// A finding as JSON, for scripting.
///
/// The shape is deliberately flat and stable: this is an interface other things
/// will parse.
#[must_use]
pub fn json(finding: &Finding) -> serde_json::Value {
serde_json::json!({
"id": finding.id.0,
"fingerprint": finding.fingerprint.as_str(),
"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(),
"detail": finding.detail,
"line": finding.line,
"byte_offset": finding.byte_offset,
"preview": finding.preview.as_str(),
"matched_len": finding.matched_len,
"first_seen": finding.first_seen.format(&Rfc3339).unwrap_or_default(),
"last_seen": finding.last_seen.format(&Rfc3339).unwrap_or_default(),
"occurrences": finding.occurrences,
"status": finding.status.to_string(),
"note": finding.note,
})
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use nanny_entities::{FindingId, Fingerprint, Harness, Origin, RuleId};
use time::OffsetDateTime;
use super::*;
fn finding_at(path: &str, line: Option<u32>) -> Finding {
Finding {
id: FindingId(1),
fingerprint: Fingerprint::from_stored("abcdef0123456789".into()),
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 {
path: PathBuf::from(path),
},
detail: None,
line,
byte_offset: Some(0),
preview: nanny_entities::Preview::from_safe("…••••[49]…"),
matched_len: 49,
first_seen: OffsetDateTime::now_utc(),
last_seen: OffsetDateTime::now_utc(),
occurrences: 1,
status: Status::Open,
note: None,
status_changed_at: None,
}
}
#[test]
fn paths_are_shortened_from_the_left_keeping_the_filename() {
let home = Path::new("/home/tester");
let path = Path::new("/home/tester/.claude/projects/-home-tester-git-thing/abcdef.jsonl");
let short = short_path(path, home, 30);
assert!(short.chars().count() <= 30);
assert!(
short.ends_with("abcdef.jsonl"),
"the filename must survive: {short}"
);
}
#[test]
fn a_short_path_is_left_alone_but_still_uses_a_tilde() {
let home = Path::new("/home/tester");
assert_eq!(
short_path(Path::new("/home/tester/.bash_history"), home, 40),
"~/.bash_history"
);
}
#[test]
fn a_path_outside_home_keeps_its_absolute_form() {
let home = Path::new("/home/tester");
assert_eq!(
short_path(Path::new("/etc/nanny/x"), home, 40),
"/etc/nanny/x"
);
}
#[test]
fn a_location_fits_its_column_and_keeps_the_line_number() {
let home = Path::new("/home/tester");
let finding = finding_at(
"/home/tester/git/zeroclaw/crates/zeroclaw-runtime/src/skills/creator.rs",
Some(1712),
);
let rendered = location(&finding, home, 40);
assert!(
rendered.chars().count() <= 40,
"overflows the column: {rendered:?}"
);
assert!(
rendered.ends_with(":1712"),
"lost the line number: {rendered:?}"
);
assert!(
rendered.contains("creator.rs"),
"lost the filename: {rendered:?}"
);
}
#[test]
fn a_location_without_a_line_number_uses_the_whole_column() {
let home = Path::new("/home/tester");
let finding = finding_at("/home/tester/.local/share/opencode/opencode.db", None);
let rendered = location(&finding, home, 40);
assert_eq!(rendered, "~/.local/share/opencode/opencode.db");
}
#[test]
fn plain_style_emits_no_escape_codes() {
let style = Style::plain();
assert_eq!(style.severity(Severity::Critical), "critical");
assert!(!style.status(Status::Open).contains('\x1b'));
}
}