use keyhog_core::{CredentialHash, RawMatch, Severity};
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct CanonicalMatch<'a> {
chunk_idx: usize,
detector_id: &'a str,
detector_name: &'a str,
service: &'a str,
severity: Severity,
credential_value_hash: CredentialHash,
credential_hash: CredentialHash,
companions: Vec<(CredentialHash, CredentialHash)>,
source: &'a str,
file_path: Option<&'a str>,
line: Option<usize>,
offset: usize,
commit: Option<&'a str>,
author: Option<&'a str>,
date: Option<&'a str>,
entropy_bits: Option<u64>,
confidence_bits: Option<u64>,
}
pub(crate) fn canonical_matches(matches: &[Vec<RawMatch>]) -> Vec<CanonicalMatch<'_>> {
let mut out = Vec::with_capacity(canonical_match_count(matches));
for (chunk_idx, chunk_matches) in matches.iter().enumerate() {
for m in chunk_matches {
out.push(canonical_match(chunk_idx, m));
}
}
out.sort_unstable();
out
}
pub(crate) fn canonical_matches_equal_reference(
matches: &[Vec<RawMatch>],
reference: &[CanonicalMatch<'_>],
) -> bool {
let match_count = canonical_match_count(matches);
if match_count != reference.len() {
return false;
}
if match_count == 0 {
return true;
}
if match_count > 256 {
return canonical_matches(matches) == reference;
}
let mut matched = [false; 256];
for (chunk_idx, chunk_matches) in matches.iter().enumerate() {
for m in chunk_matches {
let canonical = canonical_match(chunk_idx, m);
let Ok(mut idx) = reference.binary_search(&canonical) else {
return false;
};
while idx > 0 && reference[idx - 1] == canonical {
idx -= 1;
}
while idx < reference.len() && reference[idx] == canonical {
if !matched[idx] {
matched[idx] = true;
break;
}
idx += 1;
}
if idx == reference.len() || reference[idx] != canonical {
return false;
}
}
}
true
}
fn canonical_match_count(matches: &[Vec<RawMatch>]) -> usize {
matches.iter().map(Vec::len).sum()
}
fn canonical_match(chunk_idx: usize, m: &RawMatch) -> CanonicalMatch<'_> {
let mut companions: Vec<_> = m
.companions
.iter()
.map(|(name, value)| {
(
keyhog_core::sha256_hash(name),
keyhog_core::sha256_hash(value),
)
})
.collect();
companions.sort_unstable();
CanonicalMatch {
chunk_idx,
detector_id: m.detector_id.as_ref(),
detector_name: m.detector_name.as_ref(),
service: m.service.as_ref(),
severity: m.severity,
credential_value_hash: keyhog_core::sha256_hash(m.credential.as_ref()),
credential_hash: m.credential_hash,
companions,
source: m.location.source.as_ref(),
file_path: m.location.file_path.as_deref(),
line: m.location.line,
offset: m.location.offset,
commit: m.location.commit.as_deref(),
author: m.location.author.as_deref(),
date: m.location.date.as_deref(),
entropy_bits: m.entropy.map(f64::to_bits),
confidence_bits: m.confidence.map(f64::to_bits),
}
}
pub(crate) fn differing_canonical_match_fields(
reference: &[CanonicalMatch<'_>],
trial: &[CanonicalMatch<'_>],
) -> Vec<&'static str> {
let mut fields = std::collections::BTreeSet::new();
if reference.len() != trial.len() {
fields.insert("match_count");
}
for (reference, trial) in reference.iter().zip(trial) {
if reference.chunk_idx != trial.chunk_idx {
fields.insert("chunk_idx");
}
if reference.detector_id != trial.detector_id {
fields.insert("detector_id");
}
if reference.detector_name != trial.detector_name {
fields.insert("detector_name");
}
if reference.service != trial.service {
fields.insert("service");
}
if reference.severity != trial.severity {
fields.insert("severity");
}
if reference.credential_value_hash != trial.credential_value_hash {
fields.insert("credential_value");
}
if reference.credential_hash != trial.credential_hash {
fields.insert("credential_hash");
}
if reference.companions != trial.companions {
fields.insert("companions");
}
if reference.source != trial.source {
fields.insert("source");
}
if reference.file_path != trial.file_path {
fields.insert("file_path");
}
if reference.line != trial.line {
fields.insert("line");
}
if reference.offset != trial.offset {
fields.insert("offset");
}
if reference.commit != trial.commit {
fields.insert("commit");
}
if reference.author != trial.author {
fields.insert("author");
}
if reference.date != trial.date {
fields.insert("date");
}
if reference.entropy_bits != trial.entropy_bits {
fields.insert("entropy");
}
if reference.confidence_bits != trial.confidence_bits {
fields.insert("confidence");
}
}
fields.into_iter().collect()
}
pub(crate) fn render_canonical_match(record: &CanonicalMatch<'_>) -> String {
let digest = keyhog_core::hex_encode(record.credential_value_hash);
format!(
"chunk {} {} @ {}:{} offset {} credential {}",
record.chunk_idx,
record.detector_id,
record.file_path.unwrap_or("<no path>"), record
.line
.map_or_else(|| "?".to_string(), |line| line.to_string()),
record.offset,
&digest[..8.min(digest.len())],
)
}
pub(crate) fn canonical_match_differences<'a>(
left: &[CanonicalMatch<'a>],
right: &[CanonicalMatch<'a>],
limit: usize,
) -> Vec<String> {
let mut rendered = Vec::new();
let mut left_index = 0usize;
let mut right_index = 0usize;
while left_index < left.len() && rendered.len() < limit {
let record = &left[left_index];
while right_index < right.len() && right[right_index] < *record {
right_index += 1;
}
let left_end = run_end(left, left_index);
let right_count = if right.get(right_index) == Some(record) {
run_end(right, right_index) - right_index
} else {
0
};
let missing = (left_end - left_index).saturating_sub(right_count);
if missing > 0 {
rendered.push(if missing == 1 {
render_canonical_match(record)
} else {
format!("{} (x{missing})", render_canonical_match(record))
});
}
left_index = left_end;
}
rendered
}
fn run_end(records: &[CanonicalMatch<'_>], start: usize) -> usize {
let mut end = start + 1;
while end < records.len() && records[end] == records[start] {
end += 1;
}
end
}
pub(crate) fn canonical_match_digest(matches: &[CanonicalMatch<'_>]) -> u64 {
let mut h = crate::stable_hash::StableHasher::new("autoroute-correctness-digest");
h.field_usize("matches.len", matches.len());
for m in matches {
h.field_usize("match.chunk_idx", m.chunk_idx);
h.field_str("match.detector_id", m.detector_id);
h.field_str("match.detector_name", m.detector_name);
h.field_str("match.service", m.service);
h.field_str("match.severity", m.severity.as_str());
h.field_bytes(
"match.credential_value_hash",
m.credential_value_hash.as_bytes(),
);
h.field_bytes("match.credential_hash", m.credential_hash.as_bytes());
h.field_usize("match.companions.len", m.companions.len());
for (name_hash, value_hash) in &m.companions {
h.field_bytes("match.companion.name_hash", name_hash.as_bytes());
h.field_bytes("match.companion.value_hash", value_hash.as_bytes());
}
h.field_str("match.source", m.source);
h.field_option_str("match.file_path", m.file_path);
h.field_option_usize("match.line", m.line);
h.field_usize("match.offset", m.offset);
h.field_option_str("match.commit", m.commit);
h.field_option_str("match.author", m.author);
h.field_option_str("match.date", m.date);
h.field_option_u64("match.entropy_bits", m.entropy_bits);
h.field_option_u64("match.confidence_bits", m.confidence_bits);
}
h.finish_u64()
}
#[cfg(test)]
#[path = "../../../../../tests/unit/backend_match_identity.rs"]
mod tests;