1use indexmap::{Equivalent, IndexMap, IndexSet};
11use serde::ser::SerializeMap;
12use serde::{Deserialize, Serialize};
13use std::hash::{Hash, Hasher};
14use std::sync::atomic::AtomicU64;
15use std::sync::Arc;
16
17use crate::{
18 sha256_hash, CompanionMap, CredentialHash, MatchLocation, RawMatch, SensitiveString, Severity,
19};
20
21pub(crate) static DEDUP_LOST_SINGLETON: AtomicU64 = AtomicU64::new(0);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum DedupScope {
30 None,
32 File,
34 Credential,
36}
37
38#[derive(Clone, Serialize)]
43pub struct DedupedMatch {
44 #[serde(with = "crate::finding::serde_arc_str")]
46 pub detector_id: Arc<str>,
47 #[serde(with = "crate::finding::serde_arc_str")]
49 pub detector_name: Arc<str>,
50 #[serde(with = "crate::finding::serde_arc_str")]
52 pub service: Arc<str>,
53 pub severity: Severity,
55 pub credential: SensitiveString,
57 pub credential_hash: CredentialHash,
60 #[serde(serialize_with = "serialize_companions_sorted")]
62 pub companions: CompanionMap,
63 pub primary_location: MatchLocation,
65 pub additional_locations: Vec<MatchLocation>,
67 pub confidence: Option<f64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 pub entropy: Option<f64>,
73}
74
75impl std::fmt::Debug for DedupedMatch {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("DedupedMatch")
78 .field("detector_id", &self.detector_id)
79 .field("detector_name", &self.detector_name)
80 .field("service", &self.service)
81 .field("severity", &self.severity)
82 .field(
83 "credential",
84 &format_args!("<redacted {} bytes>", self.credential.len()),
85 )
86 .field(
87 "credential_hash",
88 &crate::finding::hex_encode(self.credential_hash),
89 )
90 .field(
91 "companions",
92 &format_args!("<{} redacted companions>", self.companions.len()),
93 )
94 .field("primary_location", &self.primary_location)
95 .field("additional_locations", &self.additional_locations)
96 .field("confidence", &self.confidence)
97 .field("entropy", &self.entropy)
98 .finish()
99 }
100}
101
102pub fn dedup_matches(matches: Vec<RawMatch>, scope: &DedupScope) -> Vec<DedupedMatch> {
104 if *scope == DedupScope::None {
105 return matches
106 .into_iter()
107 .map(|m| {
108 let credential_hash =
109 effective_credential_hash(m.credential.as_ref(), m.credential_hash);
110 DedupedMatch {
111 detector_id: m.detector_id,
112 detector_name: m.detector_name,
113 service: m.service,
114 severity: m.severity,
115 credential: m.credential,
116 credential_hash,
117 companions: m.companions,
118 primary_location: m.location,
119 additional_locations: Vec::new(),
120 confidence: m.confidence,
121 entropy: m.entropy,
122 }
123 })
124 .collect();
125 }
126
127 type DedupKey = (Arc<str>, SensitiveString, Option<FileScopeIdentity>);
133
134 let mut matches = matches;
162 let match_count = matches.len();
163 let mut groups: IndexMap<DedupKey, DedupedMatch> = IndexMap::with_capacity(match_count);
164 let mut seen_locations: Vec<IndexSet<LocationIdentity>> = Vec::with_capacity(match_count);
165 matches.sort_by(|a, b| {
166 a.location
167 .file_path
168 .cmp(&b.location.file_path)
169 .then_with(|| a.location.offset.cmp(&b.location.offset))
170 .then_with(|| a.location.line.cmp(&b.location.line))
171 .then_with(|| a.location.source.cmp(&b.location.source))
172 .then_with(|| a.location.commit.cmp(&b.location.commit))
173 .then_with(|| a.detector_id.cmp(&b.detector_id))
174 .then_with(|| a.credential.cmp(&b.credential))
175 });
176
177 for matched in matches {
178 let key_ref = DedupKeyRef {
179 detector_id: matched.detector_id.as_ref(),
180 credential: matched.credential.as_str(),
181 file_scope: match scope {
182 DedupScope::Credential => None,
183 DedupScope::File => Some(FileScopeIdentityRef {
184 source: matched.location.source.as_ref(),
185 file_path: matched.location.file_path.as_deref(),
186 commit: matched.location.commit.as_deref(),
187 }),
188 DedupScope::None => continue,
189 },
190 };
191
192 match groups.get_full_mut(&key_ref) {
193 Some((idx, _, existing)) => {
194 if is_decoder_alias_pair(&existing.primary_location, &matched.location) {
195 if is_decoder_location(&existing.primary_location)
196 && !is_decoder_location(&matched.location)
197 {
198 let seen = &mut seen_locations[idx];
204 seen.shift_remove(&location_identity_ref(&existing.primary_location));
205 seen.insert(location_identity(&matched.location));
206 existing.primary_location = matched.location;
207 }
208 merge_companions(&mut existing.companions, matched.companions);
209 existing.confidence = max_confidence(existing.confidence, matched.confidence);
210 existing.entropy = max_entropy(existing.entropy, matched.entropy);
211 continue;
212 }
213 if insert_new_location_identity(&mut seen_locations[idx], &matched.location) {
233 existing.additional_locations.push(matched.location);
234 }
235 merge_companions(&mut existing.companions, matched.companions);
236 existing.confidence = max_confidence(existing.confidence, matched.confidence);
237 existing.entropy = max_entropy(existing.entropy, matched.entropy);
238 }
239 None => {
240 let mut seen = IndexSet::with_capacity(1);
241 seen.insert(location_identity(&matched.location));
242 let credential_hash =
243 effective_credential_hash(matched.credential.as_ref(), matched.credential_hash);
244 let file_scope = match scope {
245 DedupScope::File => Some(file_scope_identity(&matched.location)),
246 DedupScope::Credential | DedupScope::None => None,
247 };
248 let key = (
249 Arc::clone(&matched.detector_id),
250 matched.credential.clone(),
251 file_scope,
252 );
253 groups.insert(
254 key,
255 DedupedMatch {
256 detector_id: matched.detector_id,
257 detector_name: matched.detector_name,
258 service: matched.service,
259 severity: matched.severity,
260 credential: matched.credential,
261 credential_hash,
262 companions: matched.companions,
263 primary_location: matched.location,
264 additional_locations: Vec::new(),
265 confidence: matched.confidence,
266 entropy: matched.entropy,
267 },
268 );
269 debug_assert_eq!(seen_locations.len(), groups.len() - 1);
273 seen_locations.push(seen);
274 }
275 }
276 }
277
278 let mut deduped: Vec<(DedupKey, DedupedMatch)> = groups.into_iter().collect();
282 deduped.sort_by(|a, b| a.0.cmp(&b.0));
283 deduped.into_iter().map(|(_, v)| v).collect()
284}
285
286const DECODER_ALIAS_MAX_LINE_DELTA: usize = 1;
291
292const DECODER_ALIAS_MAX_OFFSET_DELTA: usize = 16;
296
297fn is_decoder_alias_pair(a: &MatchLocation, b: &MatchLocation) -> bool {
298 if a.file_path != b.file_path || a.commit != b.commit {
299 return false;
300 }
301 if is_decoder_location(a) == is_decoder_location(b) {
302 return false;
303 }
304 match (a.line, b.line) {
305 (Some(left), Some(right)) if left.abs_diff(right) <= DECODER_ALIAS_MAX_LINE_DELTA => {
306 return true
307 }
308 (Some(_), Some(_)) => return false,
309 _ => {}
310 }
311 a.offset.abs_diff(b.offset) <= DECODER_ALIAS_MAX_OFFSET_DELTA
312}
313
314fn serialize_companions_sorted<S>(
315 companions: &CompanionMap,
316 serializer: S,
317) -> Result<S::Ok, S::Error>
318where
319 S: serde::Serializer,
320{
321 let mut entries: Vec<_> = companions.iter().collect();
322 entries.sort_by(|left, right| left.0.cmp(right.0));
323 let mut map = serializer.serialize_map(Some(entries.len()))?;
324 for (key, value) in entries {
325 map.serialize_entry(key.as_ref(), value)?;
326 }
327 map.end()
328}
329
330fn is_decoder_location(location: &MatchLocation) -> bool {
331 crate::embedded::DECODER_SOURCE_SUFFIXES
332 .iter()
333 .any(|suffix| location.source.ends_with(*suffix))
334}
335
336fn effective_credential_hash(credential: &str, credential_hash: CredentialHash) -> CredentialHash {
337 if credential_hash.is_zero() {
338 sha256_hash(credential)
339 } else {
340 credential_hash
341 }
342}
343
344pub fn dedup_cross_detector(deduped: Vec<DedupedMatch>) -> Vec<DedupedMatch> {
365 if deduped.len() < 2 {
366 return deduped;
367 }
368
369 type GroupKey = (CredentialHash, Option<Arc<str>>);
372 let mut groups: IndexMap<GroupKey, Vec<DedupedMatch>> = IndexMap::with_capacity(deduped.len());
373 for m in deduped {
374 let key_ref = CrossDetectorGroupKeyRef {
375 credential_hash: m.credential_hash,
376 file_path: m.primary_location.file_path.as_deref(),
377 };
378 match groups.get_full_mut(&key_ref) {
379 Some((_, _, group)) => group.push(m),
380 None => {
381 let key = (m.credential_hash, m.primary_location.file_path.clone());
382 groups.insert(key, vec![m]);
383 }
384 }
385 }
386
387 let mut out: Vec<DedupedMatch> = Vec::with_capacity(groups.len());
388 for (_, mut group) in groups {
389 if group.len() == 1 {
390 match group.pop() {
396 Some(only) => out.push(only),
397 None => {
398 DEDUP_LOST_SINGLETON.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
399 eprintln!(
400 "keyhog: BUG, dedup_cross_detector hit an empty group under \
401 a len()==1 guard; a finding may have been dropped. Please \
402 report this with the scanned input shape."
403 );
404 }
405 }
406 continue;
407 }
408 group.sort_by(|a, b| {
417 let ac = a.confidence.unwrap_or(0.0); let bc = b.confidence.unwrap_or(0.0); bc.total_cmp(&ac)
424 .then_with(|| b.severity.cmp(&a.severity))
425 .then_with(|| a.detector_id.cmp(&b.detector_id))
426 .then_with(|| a.credential.cmp(&b.credential))
427 .then_with(|| a.credential_hash.cmp(&b.credential_hash))
428 .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
429 });
430 let mut winner = group.remove(0);
431 let mut seen_locations = IndexSet::new();
432 insert_new_location_identity(&mut seen_locations, &winner.primary_location);
433 for loc in &winner.additional_locations {
434 insert_new_location_identity(&mut seen_locations, loc);
435 }
436 for (idx, loser) in group.into_iter().enumerate() {
437 let key = format!("cross_detector.{idx}");
438 let value = format!(
439 "{} ({}) [{}]",
440 loser.service,
441 loser.detector_name,
442 loser
443 .confidence
444 .map(|c| format!("{c:.2}"))
445 .unwrap_or_else(|| "n/a".to_string()) );
447 winner.companions.entry(Arc::from(key)).or_insert(value);
448 winner.entropy = max_entropy(winner.entropy, loser.entropy);
449 merge_cross_detector_locations(&mut winner, &mut seen_locations, loser);
450 }
451 out.push(winner);
452 }
453
454 out.sort_by(|a, b| {
461 a.detector_id
462 .cmp(&b.detector_id)
463 .then_with(|| a.credential_hash.cmp(&b.credential_hash))
464 .then_with(|| {
465 a.primary_location
466 .file_path
467 .cmp(&b.primary_location.file_path)
468 })
469 .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
470 });
471 out
472}
473
474fn merge_cross_detector_locations(
475 winner: &mut DedupedMatch,
476 seen_locations: &mut IndexSet<LocationIdentity>,
477 loser: DedupedMatch,
478) {
479 if insert_new_location_identity(seen_locations, &loser.primary_location) {
480 winner.additional_locations.push(loser.primary_location);
481 }
482 for loc in loser.additional_locations {
483 if insert_new_location_identity(seen_locations, &loc) {
484 winner.additional_locations.push(loc);
485 }
486 }
487}
488
489#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
490struct FileScopeIdentity {
491 source: Arc<str>,
492 file_path: Option<Arc<str>>,
493 commit: Option<Arc<str>>,
494}
495
496struct FileScopeIdentityRef<'a> {
497 source: &'a str,
498 file_path: Option<&'a str>,
499 commit: Option<&'a str>,
500}
501
502impl Hash for FileScopeIdentityRef<'_> {
503 fn hash<H: Hasher>(&self, state: &mut H) {
504 self.source.hash(state);
505 self.file_path.hash(state);
506 self.commit.hash(state);
507 }
508}
509
510impl Equivalent<FileScopeIdentity> for FileScopeIdentityRef<'_> {
511 fn equivalent(&self, key: &FileScopeIdentity) -> bool {
512 self.source == key.source.as_ref()
513 && self.file_path == key.file_path.as_deref()
514 && self.commit == key.commit.as_deref()
515 }
516}
517
518struct DedupKeyRef<'a> {
519 detector_id: &'a str,
520 credential: &'a str,
521 file_scope: Option<FileScopeIdentityRef<'a>>,
522}
523
524impl Hash for DedupKeyRef<'_> {
525 fn hash<H: Hasher>(&self, state: &mut H) {
526 self.detector_id.hash(state);
527 self.credential.hash(state);
528 self.file_scope.hash(state);
529 }
530}
531
532impl Equivalent<(Arc<str>, SensitiveString, Option<FileScopeIdentity>)> for DedupKeyRef<'_> {
533 fn equivalent(&self, key: &(Arc<str>, SensitiveString, Option<FileScopeIdentity>)) -> bool {
534 self.detector_id == key.0.as_ref()
535 && self.credential == key.1.as_str()
536 && match (&self.file_scope, key.2.as_ref()) {
537 (None, None) => true,
538 (Some(scope_ref), Some(scope)) => scope_ref.equivalent(scope),
539 _ => false,
540 }
541 }
542}
543
544struct CrossDetectorGroupKeyRef<'a> {
545 credential_hash: CredentialHash,
546 file_path: Option<&'a str>,
547}
548
549impl Hash for CrossDetectorGroupKeyRef<'_> {
550 fn hash<H: Hasher>(&self, state: &mut H) {
551 self.credential_hash.hash(state);
552 self.file_path.hash(state);
553 }
554}
555
556impl Equivalent<(CredentialHash, Option<Arc<str>>)> for CrossDetectorGroupKeyRef<'_> {
557 fn equivalent(&self, key: &(CredentialHash, Option<Arc<str>>)) -> bool {
558 self.credential_hash == key.0 && self.file_path == key.1.as_deref()
559 }
560}
561
562fn file_scope_identity(location: &MatchLocation) -> FileScopeIdentity {
563 FileScopeIdentity {
564 source: Arc::clone(&location.source),
565 file_path: location.file_path.clone(),
566 commit: location.commit.clone(),
567 }
568}
569
570#[derive(Clone, Debug, PartialEq, Eq, Hash)]
588struct LocationIdentity {
589 source: Arc<str>,
590 file_path: Option<Arc<str>>,
591 line: Option<usize>,
592 commit: Option<Arc<str>>,
593}
594
595struct LocationIdentityRef<'a> {
596 source: &'a str,
597 file_path: Option<&'a str>,
598 line: Option<usize>,
599 commit: Option<&'a str>,
600}
601
602impl Hash for LocationIdentityRef<'_> {
603 fn hash<H: Hasher>(&self, state: &mut H) {
604 self.source.hash(state);
605 self.file_path.hash(state);
606 self.line.hash(state);
607 self.commit.hash(state);
608 }
609}
610
611impl Equivalent<LocationIdentity> for LocationIdentityRef<'_> {
612 fn equivalent(&self, key: &LocationIdentity) -> bool {
613 self.source == key.source.as_ref()
614 && self.file_path == key.file_path.as_deref()
615 && self.line == key.line
616 && self.commit == key.commit.as_deref()
617 }
618}
619
620fn location_identity(loc: &MatchLocation) -> LocationIdentity {
621 LocationIdentity {
622 source: Arc::clone(&loc.source),
623 file_path: loc.file_path.clone(),
624 line: loc.line,
625 commit: loc.commit.clone(),
626 }
627}
628
629fn location_identity_ref(loc: &MatchLocation) -> LocationIdentityRef<'_> {
630 LocationIdentityRef {
631 source: loc.source.as_ref(),
632 file_path: loc.file_path.as_deref(),
633 line: loc.line,
634 commit: loc.commit.as_deref(),
635 }
636}
637
638fn insert_new_location_identity(
639 seen: &mut IndexSet<LocationIdentity>,
640 location: &MatchLocation,
641) -> bool {
642 let identity = location_identity_ref(location);
643 if seen.contains(&identity) {
644 return false;
645 }
646 seen.insert(location_identity(location));
647 true
648}
649
650fn merge_companions(existing: &mut CompanionMap, incoming: CompanionMap) {
651 if incoming.is_empty() {
654 return;
655 }
656 let mut sorted: Vec<(Arc<str>, String)> = incoming.into_iter().collect();
660 sorted.sort_by(|a, b| a.0.cmp(&b.0));
661 for (name, value) in sorted {
662 match existing.get_mut(&name) {
663 Some(current) if current != &value => {
664 let already_present = current
665 .split(" | ")
666 .any(|candidate| candidate == value.as_str());
667 if !already_present {
668 current.push_str(" | ");
669 current.push_str(&value);
670 }
671 }
672 Some(_) => {}
673 None => {
674 existing.insert(name, value);
675 }
676 }
677 }
678}
679
680fn max_confidence(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
681 match (lhs, rhs) {
682 (Some(a), Some(b)) => Some(a.max(b)),
683 (Some(a), None) => Some(a),
684 (None, Some(b)) => Some(b),
685 (None, None) => None,
686 }
687}
688
689fn max_entropy(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
690 match (lhs, rhs) {
691 (Some(a), Some(b)) => Some(if a.total_cmp(&b).is_ge() { a } else { b }),
692 (Some(a), None) => Some(a),
693 (None, Some(b)) => Some(b),
694 (None, None) => None,
695 }
696}