1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
//! Baseline scanning support for the KeyHog CLI.
//!
//! Baselines allow teams to suppress known/acknowledged secrets so that
//! scanning an existing repository does not produce overwhelming noise.
//! A finding is suppressed if its `(detector_id, credential_hash)` pair
//! exists in the baseline. File path and line number are stored for
//! reference only - secrets may move between lines.
use anyhow::{Context, Result};
use keyhog_core::VerifiedFinding;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
const BASELINE_VERSION: u32 = 1;
/// Canonical baseline serialization of a credential hash: the `sha256:`-prefixed
/// lowercase-hex form stored in, and matched against, baseline entries. One
/// definition so `from_findings` / `merge` / `contains` / `filter_new` can never
/// drift to different spellings of the same key. (Note: this `sha256:`-prefixed
/// form is baseline-specific; the SARIF `partialFingerprints` and `.keyhogignore`
/// `hash:` surfaces use the bare hex without the prefix.)
fn baseline_hash_key(hash: &keyhog_core::CredentialHash) -> String {
format!("sha256:{}", keyhog_core::hex_encode(hash))
}
/// A baseline file containing acknowledged secrets.
///
/// `entries` is the canonical persisted form. `cached_index` is built lazily
/// on first lookup and reused across subsequent `filter_new` / `contains`
/// calls so we don't re-hash every entry on every call. Constructors that
/// know the entry list will not change can call `build_index()` to amortize.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Baseline {
pub version: u32,
#[serde(default = "default_created")]
pub created: String,
pub entries: Vec<BaselineEntry>,
#[serde(skip)]
cached_index: std::sync::OnceLock<HashSet<(String, String)>>,
}
/// A single entry in a baseline file.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(deny_unknown_fields)]
pub(crate) struct BaselineEntry {
pub detector_id: String,
pub credential_hash: String,
#[serde(default, alias = "path", skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
/// Legacy v1 input accepted for compatibility; never an active policy.
#[serde(rename = "status", default, skip_serializing)]
pub(crate) legacy_status: Option<String>,
}
fn default_created() -> String {
"unknown".to_string()
}
/// Heuristic used only to turn an opaque serde error into an actionable hint:
/// does this JSON look like a `scan` findings report rather than a baseline?
/// A baseline is a JSON object carrying `version` + `entries`; a findings
/// report is a legacy array or a versioned object carrying `findings`.
fn looks_like_findings_report(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(serde_json::Value::Array(_)) => true,
Ok(serde_json::Value::Object(map)) => {
map.contains_key("findings")
|| !(map.contains_key("version") && map.contains_key("entries"))
}
_ => false,
}
}
impl Baseline {
/// Create an empty baseline with the current timestamp.
pub(crate) fn empty() -> Self {
Self {
version: BASELINE_VERSION,
created: chrono::Utc::now().to_rfc3339(),
entries: Vec::new(),
cached_index: std::sync::OnceLock::new(),
}
}
/// Load a baseline from a JSON file.
pub(crate) fn load(path: &Path) -> Result<Self> {
// Baseline load/parse is Preprocess-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading baseline file {}", path.display()))?;
let baseline: Baseline = serde_json::from_str(&content).map_err(|e| {
// The #1 mistake here is feeding a `scan --format json` FINDINGS
// envelope to `diff`, which wants a BASELINE file. The raw serde
// error ("invalid type: map, expected u32") sends people chasing a
// corruption bug that isn't there - detect the shape and point at
// the command that actually produces a baseline.
if looks_like_findings_report(&content) {
anyhow::anyhow!(
"{p} is not a keyhog baseline file - it looks like a `scan` \
findings report (for example `--format json` output).\n \
Create a baseline with: keyhog scan <path> --create-baseline {p}",
p = path.display(),
)
} else {
anyhow::Error::new(e).context(format!("parsing baseline file {}", path.display()))
}
})?;
if baseline.version != BASELINE_VERSION {
anyhow::bail!(
"unsupported baseline version {} (expected {})",
baseline.version,
BASELINE_VERSION
);
}
Ok(baseline)
}
/// Save the baseline to a JSON file (pretty-printed).
///
/// Atomic write: serialise to a `NamedTempFile` in the target
/// directory, fsync, then atomic-rename onto the final path. If
/// keyhog crashes (panic, SIGTERM, OOM-kill) before the rename
/// completes, the user's existing baseline is intact and the
/// tmp file is reaped by `NamedTempFile`'s Drop. Without this
/// pattern a mid-write `--update-baseline` could leave a half-
/// written JSON that the next run can't parse.
pub(crate) fn save(&self, path: &Path) -> Result<()> {
// Baseline persistence is Reporting-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
let serialized = serde_json::to_vec_pretty(self)
.with_context(|| format!("serializing baseline for {}", path.display()))?;
crate::atomic_file::write_bytes(path, &serialized)
.with_context(|| format!("atomically writing baseline {}", path.display()))?;
Ok(())
}
/// Build a new baseline from a slice of findings.
/// Entries are deduplicated by `(detector_id, credential_hash)`.
pub(crate) fn from_findings(findings: &[VerifiedFinding]) -> Self {
// Entry insertion with sort/dedup is ResultMerge-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::ResultMerge);
let mut entries: Vec<BaselineEntry> = findings
.iter()
.map(|f| BaselineEntry {
detector_id: f.detector_id.to_string(),
// `credential_hash` is the raw 32 bytes; the baseline stores the
// hex form prefixed with the algorithm (hex at the serde boundary).
credential_hash: baseline_hash_key(&f.credential_hash),
file_path: f.location.file_path.as_ref().map(|p| p.to_string()),
line: f.location.line,
legacy_status: None,
})
.collect();
entries.sort_by(|a, b| {
a.detector_id
.cmp(&b.detector_id)
.then(a.credential_hash.cmp(&b.credential_hash))
});
entries.dedup_by(|a, b| {
a.detector_id == b.detector_id && a.credential_hash == b.credential_hash
});
Self {
version: BASELINE_VERSION,
created: chrono::Utc::now().to_rfc3339(),
entries,
cached_index: std::sync::OnceLock::new(),
}
}
/// Merge new findings into an existing baseline.
/// New entries are added; existing entries are preserved.
pub(crate) fn merge(&mut self, findings: &[VerifiedFinding]) {
// Merge/update entry insertion is ResultMerge-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::ResultMerge);
let existing: HashSet<(String, String)> = self
.entries
.iter()
.map(|e| (e.detector_id.clone(), e.credential_hash.clone()))
.collect();
for finding in findings {
let key = (
finding.detector_id.to_string(),
baseline_hash_key(&finding.credential_hash),
);
if !existing.contains(&key) {
self.entries.push(BaselineEntry {
detector_id: finding.detector_id.to_string(),
credential_hash: key.1,
file_path: finding.location.file_path.as_ref().map(|p| p.to_string()),
line: finding.location.line,
legacy_status: None,
});
}
}
self.entries.sort_by(|a, b| {
a.detector_id
.cmp(&b.detector_id)
.then(a.credential_hash.cmp(&b.credential_hash))
});
self.entries.dedup_by(|a, b| {
a.detector_id == b.detector_id && a.credential_hash == b.credential_hash
});
}
/// Returns `true` if the given finding matches an entry in the baseline.
/// Matching is based solely on `(detector_id, credential_hash)`.
///
/// O(N) - for hot paths (e.g. filtering a large finding set against a
/// baseline) prefer `contains_set` + `index_set` to amortize lookups.
pub(crate) fn contains(&self, finding: &VerifiedFinding) -> bool {
// Baseline matching is Suppression-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::Suppression);
let hash = baseline_hash_key(&finding.credential_hash);
self.entries
.iter()
.any(|e| e.detector_id == finding.detector_id.as_ref() && e.credential_hash == hash)
}
/// Cached O(1) lookup set keyed by `(detector_id, credential_hash)`.
/// Built once on first access via `OnceLock` and reused; subsequent
/// `filter_new` / `contains` calls are O(N) total instead of O(N·M).
pub(crate) fn index_set(&self) -> &HashSet<(String, String)> {
self.cached_index.get_or_init(|| {
self.entries
.iter()
.map(|e| (e.detector_id.clone(), e.credential_hash.clone()))
.collect()
})
}
/// Compute the in-order keep mask without cloning finding graphs. Baseline
/// update uses this before merging, then applies it after the complete
/// finding set has been persisted.
pub(crate) fn new_finding_mask(&self, findings: &[VerifiedFinding]) -> Vec<bool> {
let index = self.index_set();
findings
.iter()
.map(|finding| {
let key = (
finding.detector_id.to_string(),
baseline_hash_key(&finding.credential_hash),
);
!index.contains(&key)
})
.collect()
}
/// Filter findings in their existing allocation so suppression does not
/// retain old and replacement `VerifiedFinding` graphs at the same time.
pub(crate) fn retain_new(&self, findings: &mut Vec<VerifiedFinding>) {
let keep = self.new_finding_mask(findings);
Self::retain_mask(findings, &keep);
}
/// Apply a previously computed mask without reallocating the finding
/// vector. The mask and findings originate from the same ordered slice.
pub(crate) fn retain_mask(findings: &mut Vec<VerifiedFinding>, mask: &[bool]) {
debug_assert_eq!(findings.len(), mask.len());
let mut remaining = mask;
findings.retain(|_| {
let Some((&keep, tail)) = remaining.split_first() else {
return false;
};
remaining = tail;
keep
});
debug_assert!(remaining.is_empty());
}
/// Filter a slice of findings, returning only those **not** present in
/// the baseline. Uses an O(1) HashSet lookup so total cost is O(N) in
/// the number of findings instead of O(N·M).
pub(crate) fn filter_new(&self, findings: &[VerifiedFinding]) -> Vec<VerifiedFinding> {
// Baseline filtering is Suppression-stage work.
let _span = keyhog_profile::span(keyhog_profile::Stage::Suppression);
let mut filtered = findings.to_vec();
self.retain_new(&mut filtered);
filtered
}
}
#[doc(hidden)]
pub(crate) mod testing {
pub(crate) fn baseline_version() -> u32 {
super::BASELINE_VERSION
}
pub(crate) fn looks_like_findings_report(content: &str) -> bool {
super::looks_like_findings_report(content)
}
}