use anyhow::{Context, Result};
use keyhog_core::VerifiedFinding;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
pub const BASELINE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub 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)]
pub 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(default = "default_status")]
pub status: String,
}
fn default_status() -> String {
"acknowledged".to_string()
}
fn default_created() -> String {
"unknown".to_string()
}
pub 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("version") && map.contains_key("entries"))
}
_ => false,
}
}
impl Baseline {
pub fn empty() -> Self {
Self {
version: BASELINE_VERSION,
created: chrono::Utc::now().to_rfc3339(),
entries: Vec::new(),
cached_index: std::sync::OnceLock::new(),
}
}
pub 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 (e.g. `--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 fn save(&self, path: &Path) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("creating baseline parent dir {}", parent.display()))?;
let serialized = serde_json::to_vec_pretty(self)
.with_context(|| format!("serializing baseline for {}", path.display()))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("creating baseline tmp in {}", parent.display()))?;
std::io::Write::write_all(&mut tmp, &serialized)
.with_context(|| format!("writing baseline tmp for {}", path.display()))?;
tmp.as_file()
.sync_all()
.with_context(|| format!("fsyncing baseline tmp for {}", path.display()))?;
tmp.persist(path)
.map_err(|e| e.error)
.with_context(|| format!("renaming baseline tmp onto {}", path.display()))?;
Ok(())
}
pub 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: format!("sha256:{}", keyhog_core::hex_encode(&f.credential_hash)),
file_path: f.location.file_path.as_ref().map(|p| p.to_string()),
line: f.location.line,
status: "acknowledged".to_string(),
})
.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 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(),
format!(
"sha256:{}",
keyhog_core::hex_encode(&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,
status: "acknowledged".to_string(),
});
}
}
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 fn contains(&self, finding: &VerifiedFinding) -> bool {
let hash = format!(
"sha256:{}",
keyhog_core::hex_encode(&finding.credential_hash)
);
self.entries
.iter()
.any(|e| e.detector_id == finding.detector_id.as_ref() && e.credential_hash == hash)
}
pub 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 fn filter_new(&self, findings: &[VerifiedFinding]) -> Vec<VerifiedFinding> {
let index = self.index_set();
findings
.iter()
.filter(|f| {
let key = (
f.detector_id.to_string(),
format!("sha256:{}", keyhog_core::hex_encode(&f.credential_hash)),
);
!index.contains(&key)
})
.cloned()
.collect()
}
}