//! U2: documents hold no secret in a built-in format. `spec export` refuses the same hits, so
//! the commit hook catches them before an export or a push does.
use super::support::skipped;
use crate::check::context::Ctx;
use crate::check::finding::{Finding, Severity};
use crate::check::rule::{Rule, RuleInfo};
use crate::guards;
use crate::model::Tier;
pub struct NoSecrets;
impl Rule for NoSecrets {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "U2",
title: "documents hold no secrets",
explain: "Every document file (the main file and its chapters) and every text attachment kept in the repository (up to [files] scan_max_bytes, 1 MiB by default; binary files are skipped and named in one warning; raise [files] scan_max_bytes in spec.toml to read larger ones) is scanned with the built-in secret formats that `spec export` always applies: private keys, cloud and token formats, and URLs with a password. No guard needs to be configured. Hits in committed documents are errors; in local (git-ignored) documents they are warnings, since those never leave the machine. The value is never printed. When a configured guard with secrets = true already runs as U1 in the same check, U2 stays quiet so a hit is reported once. Move the value to the environment or a secret store and write a placeholder instead; `spec guard secrets` runs the same check over any range.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
let u1_runs = !(ctx.scope.is_all() && !ctx.opts.guards);
if u1_runs && ctx.corpus.cfg.raw.guards.iter().any(|g| g.secrets) {
return;
}
let mut unread: Vec<String> = vec![];
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers(i) || skipped(d, "U2") {
continue;
}
let (attachments, over) =
guards::secrets::attachment_scan(&ctx.corpus.cfg.root, d, ctx.corpus.cfg.raw.files.scan_max_bytes);
unread.extend(over);
let blobs: Vec<(String, Vec<u8>)> = std::iter::once(&d.path)
.chain(&d.chapters)
.chain(&attachments)
.filter_map(|p| Some((p.clone(), ctx.current(p)?.into_bytes())))
.collect();
// A local document is git-ignored and never leaves the machine: a warning, not a block.
let local = d.tier == Tier::Local;
for (_, mut f) in guards::builtin_secrets(&blobs) {
f.rule = "U2".into();
if local {
f.severity = Severity::Warn;
}
out.push(f);
}
}
// One line for every attachment left unread, so a skipped file is never silent.
unread.sort();
unread.dedup();
if let Some(note) =
guards::secrets::skipped_note(&ctx.corpus.cfg.root, &unread, ctx.corpus.cfg.raw.files.scan_max_bytes)
{
out.push(Finding::warn("U2", &unread[0], 1, note).fix_cmd("spec explain U2"));
}
}
}