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;
fn baseline_hash_key(hash: &keyhog_core::CredentialHash) -> String {
format!("sha256:{}", keyhog_core::hex_encode(hash))
}
#[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)>>,
}
#[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>,
#[serde(rename = "status", default, skip_serializing)]
pub(crate) legacy_status: Option<String>,
}
fn default_created() -> String {
"unknown".to_string()
}
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 {
pub(crate) fn empty() -> Self {
Self {
version: BASELINE_VERSION,
created: chrono::Utc::now().to_rfc3339(),
entries: Vec::new(),
cached_index: std::sync::OnceLock::new(),
}
}
pub(crate) fn load(path: &Path) -> Result<Self> {
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| {
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)
}
pub(crate) fn save(&self, path: &Path) -> Result<()> {
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(())
}
pub(crate) fn from_findings(findings: &[VerifiedFinding]) -> Self {
let mut entries: Vec<BaselineEntry> = findings
.iter()
.map(|f| BaselineEntry {
detector_id: f.detector_id.to_string(),
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(),
}
}
pub(crate) fn merge(&mut self, findings: &[VerifiedFinding]) {
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
});
}
pub(crate) fn contains(&self, finding: &VerifiedFinding) -> bool {
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)
}
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()
})
}
pub(crate) fn filter_new(&self, findings: &[VerifiedFinding]) -> Vec<VerifiedFinding> {
let index = self.index_set();
findings
.iter()
.filter(|f| {
let key = (
f.detector_id.to_string(),
baseline_hash_key(&f.credential_hash),
);
!index.contains(&key)
})
.cloned()
.collect()
}
}
#[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)
}
}