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