use indexmap::{Equivalent, IndexMap, IndexSet};
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use crate::{sha256_hash, CredentialHash, MatchLocation, RawMatch, SensitiveString, Severity};
pub(crate) static DEDUP_LOST_SINGLETON: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DedupScope {
None,
File,
Credential,
}
#[derive(Clone, Serialize)]
pub struct DedupedMatch {
#[serde(with = "crate::finding::serde_arc_str")]
pub detector_id: Arc<str>,
#[serde(with = "crate::finding::serde_arc_str")]
pub detector_name: Arc<str>,
#[serde(with = "crate::finding::serde_arc_str")]
pub service: Arc<str>,
pub severity: Severity,
pub credential: SensitiveString,
pub credential_hash: CredentialHash,
#[serde(serialize_with = "serialize_companions_sorted")]
pub companions: HashMap<String, String>,
pub primary_location: MatchLocation,
pub additional_locations: Vec<MatchLocation>,
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entropy: Option<f64>,
}
impl std::fmt::Debug for DedupedMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DedupedMatch")
.field("detector_id", &self.detector_id)
.field("detector_name", &self.detector_name)
.field("service", &self.service)
.field("severity", &self.severity)
.field(
"credential",
&format_args!("<redacted {} bytes>", self.credential.len()),
)
.field(
"credential_hash",
&crate::finding::hex_encode(self.credential_hash),
)
.field(
"companions",
&format_args!("<{} redacted companions>", self.companions.len()),
)
.field("primary_location", &self.primary_location)
.field("additional_locations", &self.additional_locations)
.field("confidence", &self.confidence)
.field("entropy", &self.entropy)
.finish()
}
}
pub fn dedup_matches(matches: Vec<RawMatch>, scope: &DedupScope) -> Vec<DedupedMatch> {
if *scope == DedupScope::None {
return matches
.into_iter()
.map(|m| {
let credential_hash =
effective_credential_hash(m.credential.as_ref(), m.credential_hash);
DedupedMatch {
detector_id: m.detector_id,
detector_name: m.detector_name,
service: m.service,
severity: m.severity,
credential: m.credential,
credential_hash,
companions: m.companions,
primary_location: m.location,
additional_locations: Vec::new(),
confidence: m.confidence,
entropy: m.entropy,
}
})
.collect();
}
type DedupKey = (Arc<str>, SensitiveString, Option<FileScopeIdentity>);
let mut matches = matches;
let match_count = matches.len();
let mut groups: IndexMap<DedupKey, DedupedMatch> = IndexMap::with_capacity(match_count);
let mut seen_locations: Vec<IndexSet<LocationIdentity>> = Vec::with_capacity(match_count);
matches.sort_by(|a, b| {
a.location
.file_path
.cmp(&b.location.file_path)
.then_with(|| a.location.offset.cmp(&b.location.offset))
.then_with(|| a.location.line.cmp(&b.location.line))
.then_with(|| a.location.source.cmp(&b.location.source))
.then_with(|| a.location.commit.cmp(&b.location.commit))
.then_with(|| a.detector_id.cmp(&b.detector_id))
.then_with(|| a.credential.cmp(&b.credential))
});
for matched in matches {
let key_ref = DedupKeyRef {
detector_id: matched.detector_id.as_ref(),
credential: matched.credential.as_str(),
file_scope: match scope {
DedupScope::Credential => None,
DedupScope::File => Some(FileScopeIdentityRef {
source: matched.location.source.as_ref(),
file_path: matched.location.file_path.as_deref(),
commit: matched.location.commit.as_deref(),
}),
DedupScope::None => continue,
},
};
match groups.get_full_mut(&key_ref) {
Some((idx, _, existing)) => {
if is_decoder_alias_pair(&existing.primary_location, &matched.location) {
if is_decoder_location(&existing.primary_location)
&& !is_decoder_location(&matched.location)
{
let seen = &mut seen_locations[idx];
seen.shift_remove(&location_identity_ref(&existing.primary_location));
seen.insert(location_identity(&matched.location));
existing.primary_location = matched.location;
}
merge_companions(&mut existing.companions, matched.companions);
existing.confidence = max_confidence(existing.confidence, matched.confidence);
existing.entropy = max_entropy(existing.entropy, matched.entropy);
continue;
}
if insert_new_location_identity(&mut seen_locations[idx], &matched.location) {
existing.additional_locations.push(matched.location);
}
merge_companions(&mut existing.companions, matched.companions);
existing.confidence = max_confidence(existing.confidence, matched.confidence);
existing.entropy = max_entropy(existing.entropy, matched.entropy);
}
None => {
let mut seen = IndexSet::with_capacity(1);
seen.insert(location_identity(&matched.location));
let credential_hash =
effective_credential_hash(matched.credential.as_ref(), matched.credential_hash);
let file_scope = match scope {
DedupScope::File => Some(file_scope_identity(&matched.location)),
DedupScope::Credential | DedupScope::None => None,
};
let key = (
Arc::clone(&matched.detector_id),
matched.credential.clone(),
file_scope,
);
groups.insert(
key,
DedupedMatch {
detector_id: matched.detector_id,
detector_name: matched.detector_name,
service: matched.service,
severity: matched.severity,
credential: matched.credential,
credential_hash,
companions: matched.companions,
primary_location: matched.location,
additional_locations: Vec::new(),
confidence: matched.confidence,
entropy: matched.entropy,
},
);
debug_assert_eq!(seen_locations.len(), groups.len() - 1);
seen_locations.push(seen);
}
}
}
let mut deduped: Vec<(DedupKey, DedupedMatch)> = groups.into_iter().collect();
deduped.sort_by(|a, b| a.0.cmp(&b.0));
deduped.into_iter().map(|(_, v)| v).collect()
}
const DECODER_ALIAS_MAX_LINE_DELTA: usize = 1;
const DECODER_ALIAS_MAX_OFFSET_DELTA: usize = 16;
fn is_decoder_alias_pair(a: &MatchLocation, b: &MatchLocation) -> bool {
if a.file_path != b.file_path || a.commit != b.commit {
return false;
}
if is_decoder_location(a) == is_decoder_location(b) {
return false;
}
match (a.line, b.line) {
(Some(left), Some(right)) if left.abs_diff(right) <= DECODER_ALIAS_MAX_LINE_DELTA => {
return true
}
(Some(_), Some(_)) => return false,
_ => {}
}
a.offset.abs_diff(b.offset) <= DECODER_ALIAS_MAX_OFFSET_DELTA
}
fn serialize_companions_sorted<S>(
companions: &HashMap<String, String>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut entries: Vec<_> = companions.iter().collect();
entries.sort_by(|left, right| left.0.cmp(right.0));
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (key, value) in entries {
map.serialize_entry(key, value)?;
}
map.end()
}
fn is_decoder_location(location: &MatchLocation) -> bool {
crate::embedded::DECODER_SOURCE_SUFFIXES
.iter()
.any(|suffix| location.source.ends_with(*suffix))
}
fn effective_credential_hash(credential: &str, credential_hash: CredentialHash) -> CredentialHash {
if credential_hash.is_zero() {
sha256_hash(credential)
} else {
credential_hash
}
}
pub fn dedup_cross_detector(deduped: Vec<DedupedMatch>) -> Vec<DedupedMatch> {
if deduped.len() < 2 {
return deduped;
}
type GroupKey = (CredentialHash, Option<Arc<str>>);
let mut groups: IndexMap<GroupKey, Vec<DedupedMatch>> = IndexMap::with_capacity(deduped.len());
for m in deduped {
let key_ref = CrossDetectorGroupKeyRef {
credential_hash: m.credential_hash,
file_path: m.primary_location.file_path.as_deref(),
};
match groups.get_full_mut(&key_ref) {
Some((_, _, group)) => group.push(m),
None => {
let key = (m.credential_hash, m.primary_location.file_path.clone());
groups.insert(key, vec![m]);
}
}
}
let mut out: Vec<DedupedMatch> = Vec::with_capacity(groups.len());
for (_, mut group) in groups {
if group.len() == 1 {
match group.pop() {
Some(only) => out.push(only),
None => {
DEDUP_LOST_SINGLETON.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
eprintln!(
"keyhog: BUG, dedup_cross_detector hit an empty group under \
a len()==1 guard; a finding may have been dropped. Please \
report this with the scanned input shape."
);
}
}
continue;
}
group.sort_by(|a, b| {
let ac = a.confidence.unwrap_or(0.0); let bc = b.confidence.unwrap_or(0.0); bc.total_cmp(&ac)
.then_with(|| b.severity.cmp(&a.severity))
.then_with(|| a.detector_id.cmp(&b.detector_id))
.then_with(|| a.credential.cmp(&b.credential))
.then_with(|| a.credential_hash.cmp(&b.credential_hash))
.then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
});
let mut winner = group.remove(0);
let mut seen_locations = IndexSet::new();
insert_new_location_identity(&mut seen_locations, &winner.primary_location);
for loc in &winner.additional_locations {
insert_new_location_identity(&mut seen_locations, loc);
}
for (idx, loser) in group.into_iter().enumerate() {
let key = format!("cross_detector.{idx}");
let value = format!(
"{} ({}) [{}]",
loser.service,
loser.detector_name,
loser
.confidence
.map(|c| format!("{c:.2}"))
.unwrap_or_else(|| "n/a".to_string()) );
winner.companions.entry(key).or_insert(value);
winner.entropy = max_entropy(winner.entropy, loser.entropy);
merge_cross_detector_locations(&mut winner, &mut seen_locations, loser);
}
out.push(winner);
}
out.sort_by(|a, b| {
a.detector_id
.cmp(&b.detector_id)
.then_with(|| a.credential_hash.cmp(&b.credential_hash))
.then_with(|| {
a.primary_location
.file_path
.cmp(&b.primary_location.file_path)
})
.then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
});
out
}
fn merge_cross_detector_locations(
winner: &mut DedupedMatch,
seen_locations: &mut IndexSet<LocationIdentity>,
loser: DedupedMatch,
) {
if insert_new_location_identity(seen_locations, &loser.primary_location) {
winner.additional_locations.push(loser.primary_location);
}
for loc in loser.additional_locations {
if insert_new_location_identity(seen_locations, &loc) {
winner.additional_locations.push(loc);
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct FileScopeIdentity {
source: Arc<str>,
file_path: Option<Arc<str>>,
commit: Option<Arc<str>>,
}
struct FileScopeIdentityRef<'a> {
source: &'a str,
file_path: Option<&'a str>,
commit: Option<&'a str>,
}
impl Hash for FileScopeIdentityRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.source.hash(state);
self.file_path.hash(state);
self.commit.hash(state);
}
}
impl Equivalent<FileScopeIdentity> for FileScopeIdentityRef<'_> {
fn equivalent(&self, key: &FileScopeIdentity) -> bool {
self.source == key.source.as_ref()
&& self.file_path == key.file_path.as_deref()
&& self.commit == key.commit.as_deref()
}
}
struct DedupKeyRef<'a> {
detector_id: &'a str,
credential: &'a str,
file_scope: Option<FileScopeIdentityRef<'a>>,
}
impl Hash for DedupKeyRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.detector_id.hash(state);
self.credential.hash(state);
self.file_scope.hash(state);
}
}
impl Equivalent<(Arc<str>, SensitiveString, Option<FileScopeIdentity>)> for DedupKeyRef<'_> {
fn equivalent(&self, key: &(Arc<str>, SensitiveString, Option<FileScopeIdentity>)) -> bool {
self.detector_id == key.0.as_ref()
&& self.credential == key.1.as_str()
&& match (&self.file_scope, key.2.as_ref()) {
(None, None) => true,
(Some(scope_ref), Some(scope)) => scope_ref.equivalent(scope),
_ => false,
}
}
}
struct CrossDetectorGroupKeyRef<'a> {
credential_hash: CredentialHash,
file_path: Option<&'a str>,
}
impl Hash for CrossDetectorGroupKeyRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.credential_hash.hash(state);
self.file_path.hash(state);
}
}
impl Equivalent<(CredentialHash, Option<Arc<str>>)> for CrossDetectorGroupKeyRef<'_> {
fn equivalent(&self, key: &(CredentialHash, Option<Arc<str>>)) -> bool {
self.credential_hash == key.0 && self.file_path == key.1.as_deref()
}
}
fn file_scope_identity(location: &MatchLocation) -> FileScopeIdentity {
FileScopeIdentity {
source: Arc::clone(&location.source),
file_path: location.file_path.clone(),
commit: location.commit.clone(),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct LocationIdentity {
source: Arc<str>,
file_path: Option<Arc<str>>,
line: Option<usize>,
commit: Option<Arc<str>>,
}
struct LocationIdentityRef<'a> {
source: &'a str,
file_path: Option<&'a str>,
line: Option<usize>,
commit: Option<&'a str>,
}
impl Hash for LocationIdentityRef<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.source.hash(state);
self.file_path.hash(state);
self.line.hash(state);
self.commit.hash(state);
}
}
impl Equivalent<LocationIdentity> for LocationIdentityRef<'_> {
fn equivalent(&self, key: &LocationIdentity) -> bool {
self.source == key.source.as_ref()
&& self.file_path == key.file_path.as_deref()
&& self.line == key.line
&& self.commit == key.commit.as_deref()
}
}
fn location_identity(loc: &MatchLocation) -> LocationIdentity {
LocationIdentity {
source: Arc::clone(&loc.source),
file_path: loc.file_path.clone(),
line: loc.line,
commit: loc.commit.clone(),
}
}
fn location_identity_ref(loc: &MatchLocation) -> LocationIdentityRef<'_> {
LocationIdentityRef {
source: loc.source.as_ref(),
file_path: loc.file_path.as_deref(),
line: loc.line,
commit: loc.commit.as_deref(),
}
}
fn insert_new_location_identity(
seen: &mut IndexSet<LocationIdentity>,
location: &MatchLocation,
) -> bool {
let identity = location_identity_ref(location);
if seen.contains(&identity) {
return false;
}
seen.insert(location_identity(location));
true
}
fn merge_companions(existing: &mut HashMap<String, String>, incoming: HashMap<String, String>) {
if incoming.is_empty() {
return;
}
let mut sorted: Vec<(String, String)> = incoming.into_iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (name, value) in sorted {
match existing.get_mut(&name) {
Some(current) if current != &value => {
let already_present = current
.split(" | ")
.any(|candidate| candidate == value.as_str());
if !already_present {
current.push_str(" | ");
current.push_str(&value);
}
}
Some(_) => {}
None => {
existing.insert(name, value);
}
}
}
}
fn max_confidence(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
match (lhs, rhs) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn max_entropy(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
match (lhs, rhs) {
(Some(a), Some(b)) => Some(if a.total_cmp(&b).is_ge() { a } else { b }),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}