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, EvidenceVerdict, MatchLocation, RawMatch,
19 SensitiveString, Severity,
20};
21
22pub(crate) static DEDUP_LOST_SINGLETON: AtomicU64 = AtomicU64::new(0);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum DedupScope {
31 None,
33 File,
35 Credential,
37}
38
39#[derive(Clone, Serialize)]
44pub struct DedupedMatch {
45 #[serde(with = "crate::finding::serde_arc_str")]
47 pub detector_id: Arc<str>,
48 #[serde(with = "crate::finding::serde_arc_str")]
50 pub detector_name: Arc<str>,
51 #[serde(with = "crate::finding::serde_arc_str")]
53 pub service: Arc<str>,
54 pub severity: Severity,
56 pub credential: SensitiveString,
58 pub credential_hash: CredentialHash,
61 #[serde(serialize_with = "serialize_companions_sorted")]
63 pub companions: CompanionMap,
64 pub primary_location: MatchLocation,
66 pub additional_locations: Vec<MatchLocation>,
68 pub confidence: Option<f64>,
70 pub evidence: EvidenceVerdict,
72 #[serde(skip_serializing_if = "Option::is_none")]
75 pub entropy: Option<f64>,
76}
77
78impl std::fmt::Debug for DedupedMatch {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("DedupedMatch")
81 .field("detector_id", &self.detector_id)
82 .field("detector_name", &self.detector_name)
83 .field("service", &self.service)
84 .field("severity", &self.severity)
85 .field(
86 "credential",
87 &format_args!("<redacted {} bytes>", self.credential.len()),
88 )
89 .field(
90 "credential_hash",
91 &crate::finding::hex_encode(self.credential_hash),
92 )
93 .field(
94 "companions",
95 &format_args!("<{} redacted companions>", self.companions.len()),
96 )
97 .field("primary_location", &self.primary_location)
98 .field("additional_locations", &self.additional_locations)
99 .field("confidence", &self.confidence)
100 .field("evidence", &self.evidence)
101 .field("entropy", &self.entropy)
102 .finish()
103 }
104}
105
106pub fn dedup_matches(matches: Vec<RawMatch>, scope: &DedupScope) -> Vec<DedupedMatch> {
108 if *scope == DedupScope::None {
109 return matches
110 .into_iter()
111 .map(|m| {
112 let credential_hash =
113 effective_credential_hash(m.credential.as_ref(), m.credential_hash);
114 DedupedMatch {
115 detector_id: m.detector_id,
116 detector_name: m.detector_name,
117 service: m.service,
118 severity: m.severity,
119 credential: m.credential,
120 credential_hash,
121 companions: m.companions,
122 primary_location: m.location,
123 additional_locations: Vec::new(),
124 confidence: m.confidence,
125 evidence: m.evidence,
126 entropy: m.entropy,
127 }
128 })
129 .collect();
130 }
131
132 type DedupKey = (Arc<str>, SensitiveString, Option<FileScopeIdentity>);
138
139 let mut matches = matches;
167 let match_count = matches.len();
168 let mut groups: IndexMap<DedupKey, DedupedMatch> = IndexMap::with_capacity(match_count);
169 let mut seen_locations: Vec<IndexSet<LocationIdentity>> = Vec::with_capacity(match_count);
170 matches.sort_by(|a, b| {
171 a.location
172 .file_path
173 .cmp(&b.location.file_path)
174 .then_with(|| a.location.offset.cmp(&b.location.offset))
175 .then_with(|| a.location.line.cmp(&b.location.line))
176 .then_with(|| a.location.source.cmp(&b.location.source))
177 .then_with(|| a.location.commit.cmp(&b.location.commit))
178 .then_with(|| a.detector_id.cmp(&b.detector_id))
179 .then_with(|| a.credential.cmp(&b.credential))
180 });
181
182 for matched in matches {
183 let key_ref = DedupKeyRef {
184 detector_id: matched.detector_id.as_ref(),
185 credential: matched.credential.as_str(),
186 file_scope: match scope {
187 DedupScope::Credential => None,
188 DedupScope::File => Some(FileScopeIdentityRef {
189 source: matched.location.source.as_ref(),
190 file_path: matched.location.file_path.as_deref(),
191 commit: matched.location.commit.as_deref(),
192 }),
193 DedupScope::None => continue,
194 },
195 };
196
197 match groups.get_full_mut(&key_ref) {
198 Some((idx, _, existing)) => {
199 if is_decoder_alias_pair(&existing.primary_location, &matched.location) {
200 if is_decoder_location(&existing.primary_location)
201 && !is_decoder_location(&matched.location)
202 {
203 let seen = &mut seen_locations[idx];
209 seen.shift_remove(&location_identity_ref(&existing.primary_location));
210 seen.insert(location_identity(&matched.location));
211 existing.primary_location = matched.location;
212 }
213 merge_companions(&mut existing.companions, matched.companions);
214 existing.confidence = max_confidence(existing.confidence, matched.confidence);
215 existing.entropy = max_entropy(existing.entropy, matched.entropy);
216 existing.evidence = existing.evidence.stronger(matched.evidence);
217 continue;
218 }
219 if insert_new_location_identity(&mut seen_locations[idx], &matched.location) {
239 existing.additional_locations.push(matched.location);
240 }
241 merge_companions(&mut existing.companions, matched.companions);
242 existing.confidence = max_confidence(existing.confidence, matched.confidence);
243 existing.entropy = max_entropy(existing.entropy, matched.entropy);
244 existing.evidence = existing.evidence.stronger(matched.evidence);
245 }
246 None => {
247 let mut seen = IndexSet::with_capacity(1);
248 seen.insert(location_identity(&matched.location));
249 let credential_hash =
250 effective_credential_hash(matched.credential.as_ref(), matched.credential_hash);
251 let file_scope = match scope {
252 DedupScope::File => Some(file_scope_identity(&matched.location)),
253 DedupScope::Credential | DedupScope::None => None,
254 };
255 let key = (
256 Arc::clone(&matched.detector_id),
257 matched.credential.clone(),
258 file_scope,
259 );
260 groups.insert(
261 key,
262 DedupedMatch {
263 detector_id: matched.detector_id,
264 detector_name: matched.detector_name,
265 service: matched.service,
266 severity: matched.severity,
267 credential: matched.credential,
268 credential_hash,
269 companions: matched.companions,
270 primary_location: matched.location,
271 additional_locations: Vec::new(),
272 confidence: matched.confidence,
273 evidence: matched.evidence,
274 entropy: matched.entropy,
275 },
276 );
277 debug_assert_eq!(seen_locations.len(), groups.len() - 1);
281 seen_locations.push(seen);
282 }
283 }
284 }
285
286 groups.sort_keys();
290 groups.into_values().collect()
291}
292
293const DECODER_ALIAS_MAX_LINE_DELTA: usize = 1;
298
299const DECODER_ALIAS_MAX_OFFSET_DELTA: usize = 16;
303
304fn is_decoder_alias_pair(a: &MatchLocation, b: &MatchLocation) -> bool {
305 if a.file_path != b.file_path || a.commit != b.commit {
306 return false;
307 }
308 if is_decoder_location(a) == is_decoder_location(b) {
309 return false;
310 }
311 match (a.line, b.line) {
312 (Some(left), Some(right)) if left.abs_diff(right) <= DECODER_ALIAS_MAX_LINE_DELTA => {
313 return true
314 }
315 (Some(_), Some(_)) => return false,
316 _ => {}
317 }
318 a.offset.abs_diff(b.offset) <= DECODER_ALIAS_MAX_OFFSET_DELTA
319}
320
321fn serialize_companions_sorted<S>(
322 companions: &CompanionMap,
323 serializer: S,
324) -> Result<S::Ok, S::Error>
325where
326 S: serde::Serializer,
327{
328 let mut entries: Vec<_> = companions.iter().collect();
329 entries.sort_by(|left, right| left.0.cmp(right.0));
330 let mut map = serializer.serialize_map(Some(entries.len()))?;
331 for (key, value) in entries {
332 map.serialize_entry(key.as_ref(), value)?;
333 }
334 map.end()
335}
336
337fn is_decoder_location(location: &MatchLocation) -> bool {
338 crate::embedded::DECODER_SOURCE_SUFFIXES
339 .iter()
340 .any(|suffix| location.source.ends_with(*suffix))
341}
342
343fn effective_credential_hash(credential: &str, credential_hash: CredentialHash) -> CredentialHash {
344 if credential_hash.is_zero() {
345 sha256_hash(credential)
346 } else {
347 credential_hash
348 }
349}
350
351pub fn dedup_cross_detector(deduped: Vec<DedupedMatch>) -> Vec<DedupedMatch> {
372 if deduped.len() < 2 {
373 return deduped;
374 }
375
376 type GroupKey = (CredentialHash, Option<Arc<str>>);
379 let mut groups: IndexMap<GroupKey, Vec<DedupedMatch>> = IndexMap::with_capacity(deduped.len());
380 for m in deduped {
381 let key_ref = CrossDetectorGroupKeyRef {
382 credential_hash: m.credential_hash,
383 file_path: m.primary_location.file_path.as_deref(),
384 };
385 match groups.get_full_mut(&key_ref) {
386 Some((_, _, group)) => group.push(m),
387 None => {
388 let key = (m.credential_hash, m.primary_location.file_path.clone());
389 groups.insert(key, vec![m]);
390 }
391 }
392 }
393
394 let mut out: Vec<DedupedMatch> = Vec::with_capacity(groups.len());
395 for (_, mut group) in groups {
396 if group.len() == 1 {
397 match group.pop() {
403 Some(only) => out.push(only),
404 None => {
405 DEDUP_LOST_SINGLETON.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
406 eprintln!(
407 "keyhog: BUG, dedup_cross_detector hit an empty group under \
408 a len()==1 guard; a finding may have been dropped. Please \
409 report this with the scanned input shape."
410 );
411 }
412 }
413 continue;
414 }
415 group.sort_by(|a, b| {
424 let ac = a.confidence.unwrap_or(0.0); let bc = b.confidence.unwrap_or(0.0); bc.total_cmp(&ac)
431 .then_with(|| b.severity.cmp(&a.severity))
432 .then_with(|| a.detector_id.cmp(&b.detector_id))
433 .then_with(|| a.credential.cmp(&b.credential))
434 .then_with(|| a.credential_hash.cmp(&b.credential_hash))
435 .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
436 });
437 let mut winner = group.remove(0);
438 let mut seen_locations = IndexSet::new();
439 insert_new_location_identity(&mut seen_locations, &winner.primary_location);
440 for loc in &winner.additional_locations {
441 insert_new_location_identity(&mut seen_locations, loc);
442 }
443 for (idx, loser) in group.into_iter().enumerate() {
444 let key = format!("cross_detector.{idx}");
445 let value = format!(
446 "{} ({}) [{}]",
447 loser.service,
448 loser.detector_name,
449 loser
450 .confidence
451 .map(|c| format!("{c:.2}"))
452 .unwrap_or_else(|| "n/a".to_string()) );
454 winner.companions.entry(Arc::from(key)).or_insert(value);
455 winner.entropy = max_entropy(winner.entropy, loser.entropy);
456 let strongest_reason = winner.evidence.stronger(loser.evidence).reason_code();
457 winner.evidence = winner.evidence.with_reason(strongest_reason);
458 merge_cross_detector_locations(&mut winner, &mut seen_locations, loser);
459 }
460 out.push(winner);
461 }
462
463 out.sort_by(|a, b| {
470 a.detector_id
471 .cmp(&b.detector_id)
472 .then_with(|| a.credential_hash.cmp(&b.credential_hash))
473 .then_with(|| {
474 a.primary_location
475 .file_path
476 .cmp(&b.primary_location.file_path)
477 })
478 .then_with(|| a.primary_location.offset.cmp(&b.primary_location.offset))
479 });
480 out
481}
482
483fn merge_cross_detector_locations(
484 winner: &mut DedupedMatch,
485 seen_locations: &mut IndexSet<LocationIdentity>,
486 loser: DedupedMatch,
487) {
488 if insert_new_location_identity(seen_locations, &loser.primary_location) {
489 winner.additional_locations.push(loser.primary_location);
490 }
491 for loc in loser.additional_locations {
492 if insert_new_location_identity(seen_locations, &loc) {
493 winner.additional_locations.push(loc);
494 }
495 }
496}
497
498#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
499struct FileScopeIdentity {
500 source: Arc<str>,
501 file_path: Option<Arc<str>>,
502 commit: Option<Arc<str>>,
503}
504
505struct FileScopeIdentityRef<'a> {
506 source: &'a str,
507 file_path: Option<&'a str>,
508 commit: Option<&'a str>,
509}
510
511impl Hash for FileScopeIdentityRef<'_> {
512 fn hash<H: Hasher>(&self, state: &mut H) {
513 self.source.hash(state);
514 self.file_path.hash(state);
515 self.commit.hash(state);
516 }
517}
518
519impl Equivalent<FileScopeIdentity> for FileScopeIdentityRef<'_> {
520 fn equivalent(&self, key: &FileScopeIdentity) -> bool {
521 self.source == key.source.as_ref()
522 && self.file_path == key.file_path.as_deref()
523 && self.commit == key.commit.as_deref()
524 }
525}
526
527struct DedupKeyRef<'a> {
528 detector_id: &'a str,
529 credential: &'a str,
530 file_scope: Option<FileScopeIdentityRef<'a>>,
531}
532
533impl Hash for DedupKeyRef<'_> {
534 fn hash<H: Hasher>(&self, state: &mut H) {
535 self.detector_id.hash(state);
536 self.credential.hash(state);
537 self.file_scope.hash(state);
538 }
539}
540
541impl Equivalent<(Arc<str>, SensitiveString, Option<FileScopeIdentity>)> for DedupKeyRef<'_> {
542 fn equivalent(&self, key: &(Arc<str>, SensitiveString, Option<FileScopeIdentity>)) -> bool {
543 self.detector_id == key.0.as_ref()
544 && self.credential == key.1.as_str()
545 && match (&self.file_scope, key.2.as_ref()) {
546 (None, None) => true,
547 (Some(scope_ref), Some(scope)) => scope_ref.equivalent(scope),
548 _ => false,
549 }
550 }
551}
552
553struct CrossDetectorGroupKeyRef<'a> {
554 credential_hash: CredentialHash,
555 file_path: Option<&'a str>,
556}
557
558impl Hash for CrossDetectorGroupKeyRef<'_> {
559 fn hash<H: Hasher>(&self, state: &mut H) {
560 self.credential_hash.hash(state);
561 self.file_path.hash(state);
562 }
563}
564
565impl Equivalent<(CredentialHash, Option<Arc<str>>)> for CrossDetectorGroupKeyRef<'_> {
566 fn equivalent(&self, key: &(CredentialHash, Option<Arc<str>>)) -> bool {
567 self.credential_hash == key.0 && self.file_path == key.1.as_deref()
568 }
569}
570
571fn file_scope_identity(location: &MatchLocation) -> FileScopeIdentity {
572 FileScopeIdentity {
573 source: Arc::clone(&location.source),
574 file_path: location.file_path.clone(),
575 commit: location.commit.clone(),
576 }
577}
578
579#[derive(Clone, Debug, PartialEq, Eq, Hash)]
597struct LocationIdentity {
598 source: Arc<str>,
599 file_path: Option<Arc<str>>,
600 line: Option<usize>,
601 commit: Option<Arc<str>>,
602}
603
604struct LocationIdentityRef<'a> {
605 source: &'a str,
606 file_path: Option<&'a str>,
607 line: Option<usize>,
608 commit: Option<&'a str>,
609}
610
611impl Hash for LocationIdentityRef<'_> {
612 fn hash<H: Hasher>(&self, state: &mut H) {
613 self.source.hash(state);
614 self.file_path.hash(state);
615 self.line.hash(state);
616 self.commit.hash(state);
617 }
618}
619
620impl Equivalent<LocationIdentity> for LocationIdentityRef<'_> {
621 fn equivalent(&self, key: &LocationIdentity) -> bool {
622 self.source == key.source.as_ref()
623 && self.file_path == key.file_path.as_deref()
624 && self.line == key.line
625 && self.commit == key.commit.as_deref()
626 }
627}
628
629fn location_identity(loc: &MatchLocation) -> LocationIdentity {
630 LocationIdentity {
631 source: Arc::clone(&loc.source),
632 file_path: loc.file_path.clone(),
633 line: loc.line,
634 commit: loc.commit.clone(),
635 }
636}
637
638fn location_identity_ref(loc: &MatchLocation) -> LocationIdentityRef<'_> {
639 LocationIdentityRef {
640 source: loc.source.as_ref(),
641 file_path: loc.file_path.as_deref(),
642 line: loc.line,
643 commit: loc.commit.as_deref(),
644 }
645}
646
647fn insert_new_location_identity(
648 seen: &mut IndexSet<LocationIdentity>,
649 location: &MatchLocation,
650) -> bool {
651 let identity = location_identity_ref(location);
652 if seen.contains(&identity) {
653 return false;
654 }
655 seen.insert(location_identity(location));
656 true
657}
658
659fn merge_companions(existing: &mut CompanionMap, incoming: CompanionMap) {
660 if incoming.is_empty() {
663 return;
664 }
665 let mut sorted: Vec<(Arc<str>, String)> = incoming.into_iter().collect();
669 sorted.sort_by(|a, b| a.0.cmp(&b.0));
670 for (name, value) in sorted {
671 match existing.get_mut(&name) {
672 Some(current) if current != &value => {
673 let already_present = current
674 .split(" | ")
675 .any(|candidate| candidate == value.as_str());
676 if !already_present {
677 current.push_str(" | ");
678 current.push_str(&value);
679 }
680 }
681 Some(_) => {}
682 None => {
683 existing.insert(name, value);
684 }
685 }
686 }
687}
688
689fn max_confidence(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
690 match (lhs, rhs) {
691 (Some(a), Some(b)) => Some(a.max(b)),
692 (Some(a), None) => Some(a),
693 (None, Some(b)) => Some(b),
694 (None, None) => None,
695 }
696}
697
698fn max_entropy(lhs: Option<f64>, rhs: Option<f64>) -> Option<f64> {
699 match (lhs, rhs) {
700 (Some(a), Some(b)) => Some(if a.total_cmp(&b).is_ge() { a } else { b }),
701 (Some(a), None) => Some(a),
702 (None, Some(b)) => Some(b),
703 (None, None) => None,
704 }
705}