1use std::collections::{BTreeMap, BTreeSet};
30use std::sync::LazyLock;
31
32use crate::{hex_encode, CredentialHash, MatchLocation, Severity, VerifiedFinding};
33
34#[derive(
36 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
37)]
38#[serde(rename_all = "snake_case")]
39pub enum CorrelationKind {
40 ValueReuse,
42 SplitComposite,
45}
46
47impl CorrelationKind {
48 #[must_use]
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::ValueReuse => "value_reuse",
54 Self::SplitComposite => "split_composite",
55 }
56 }
57}
58
59#[derive(
61 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
62)]
63#[serde(rename_all = "snake_case")]
64pub enum CorrelationRole {
65 SameValue,
67 RequiredPart,
69 OptionalPart,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
79pub struct CorrelatedLocation {
80 pub file_path: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub line: Option<usize>,
85}
86
87#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
89pub struct CorrelatedMember {
90 pub detector_id: String,
92 pub detector_name: String,
94 pub service: String,
96 pub severity: Severity,
98 pub role: CorrelationRole,
100 pub credential_redacted: String,
102 pub credential_hash: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub confidence: Option<f64>,
108 pub locations: Vec<CorrelatedLocation>,
110}
111
112#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
114pub struct CorrelatedCredential {
115 pub id: String,
117 pub kind: CorrelationKind,
119 pub title: String,
121 pub service: String,
123 pub severity: Severity,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub confidence: Option<f64>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub strongest_member_confidence: Option<f64>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub scope: Option<String>,
139 pub file_count: usize,
141 pub impact: String,
143 pub members: Vec<CorrelatedMember>,
145 pub locations: Vec<CorrelatedLocation>,
147}
148
149#[derive(serde::Deserialize)]
151#[serde(deny_unknown_fields)]
152struct CorrelationSettings {
153 reuse_min_files: usize,
154 reuse_confidence_bonus: f64,
155 max_confidence: f64,
156 reuse_impact: String,
157}
158
159#[derive(serde::Deserialize)]
161#[serde(deny_unknown_fields)]
162struct CompositeSpec {
163 id: String,
164 service: String,
165 name: String,
166 severity: Severity,
167 required: Vec<String>,
168 #[serde(default)]
169 optional: Vec<String>,
170 confidence_bonus: f64,
171 impact: String,
172}
173
174#[derive(serde::Deserialize)]
176#[serde(deny_unknown_fields)]
177struct CorrelationPolicy {
178 settings: CorrelationSettings,
179 #[serde(default)]
180 composite: Vec<CompositeSpec>,
181}
182
183#[allow(clippy::panic)]
189static POLICY: LazyLock<CorrelationPolicy> = LazyLock::new(|| {
190 match parse_policy(
191 include_str!("../data/credential-correlation.toml"),
192 "<embedded data/credential-correlation.toml>",
193 ) {
194 Ok(policy) => policy,
195 Err(error) => panic!(
196 "keyhog: credential-correlation policy '<embedded \
197 data/credential-correlation.toml>' is invalid: {error}. \
198 Fix: correct crates/core/data/credential-correlation.toml and rebuild"
199 ),
200 }
201});
202
203fn parse_policy(raw: &str, origin: &str) -> Result<CorrelationPolicy, String> {
208 let policy = toml::from_str::<CorrelationPolicy>(raw)
209 .map_err(|error| format!("failed to parse {origin}: {error}"))?;
210 validate_policy(&policy, origin)?;
211 Ok(policy)
212}
213
214fn validate_policy(policy: &CorrelationPolicy, origin: &str) -> Result<(), String> {
220 let settings = &policy.settings;
221 if settings.reuse_min_files < 2 {
222 return Err(format!(
223 "{origin} [settings] reuse_min_files must be at least 2, got {}",
224 settings.reuse_min_files
225 ));
226 }
227 if !(settings.reuse_confidence_bonus > 0.0 && settings.reuse_confidence_bonus <= 1.0) {
228 return Err(format!(
229 "{origin} [settings] reuse_confidence_bonus must be in (0.0, 1.0], got {}",
230 settings.reuse_confidence_bonus
231 ));
232 }
233 if !(settings.max_confidence > 0.0 && settings.max_confidence <= 1.0) {
234 return Err(format!(
235 "{origin} [settings] max_confidence must be in (0.0, 1.0], got {}",
236 settings.max_confidence
237 ));
238 }
239 if settings.reuse_impact.trim().is_empty() {
240 return Err(format!(
241 "{origin} [settings] reuse_impact must not be empty"
242 ));
243 }
244
245 let mut seen_ids = BTreeSet::new();
246 for composite in &policy.composite {
247 let id = composite.id.trim();
248 if id.is_empty() {
249 return Err(format!("{origin} [[composite]] has an empty id"));
250 }
251 if !seen_ids.insert(id) {
252 return Err(format!("{origin} [[composite]] duplicate id {id:?}"));
253 }
254 if composite.service.trim().is_empty() {
255 return Err(format!(
256 "{origin} [[composite]] {id:?} has an empty service"
257 ));
258 }
259 if composite.name.trim().is_empty() {
260 return Err(format!("{origin} [[composite]] {id:?} has an empty name"));
261 }
262 if composite.impact.trim().is_empty() {
263 return Err(format!("{origin} [[composite]] {id:?} has an empty impact"));
264 }
265 if composite.required.len() < 2 {
266 return Err(format!(
267 "{origin} [[composite]] {id:?} needs at least 2 required parts, got {}",
268 composite.required.len()
269 ));
270 }
271 if !(composite.confidence_bonus > 0.0 && composite.confidence_bonus <= 1.0) {
272 return Err(format!(
273 "{origin} [[composite]] {id:?} confidence_bonus must be in (0.0, 1.0], got {}",
274 composite.confidence_bonus
275 ));
276 }
277 let mut seen_parts = BTreeSet::new();
278 for part in composite.required.iter().chain(composite.optional.iter()) {
279 let part = part.trim();
280 if part.is_empty() {
281 return Err(format!(
282 "{origin} [[composite]] {id:?} has an empty part id"
283 ));
284 }
285 if !seen_parts.insert(part) {
286 return Err(format!(
287 "{origin} [[composite]] {id:?} lists part {part:?} more than once"
288 ));
289 }
290 }
291 }
292 Ok(())
293}
294
295#[must_use]
301pub fn correlation_composite_part_ids() -> Vec<&'static str> {
302 let mut ids: Vec<&'static str> = POLICY
303 .composite
304 .iter()
305 .flat_map(|composite| composite.required.iter().chain(composite.optional.iter()))
306 .map(String::as_str)
307 .collect();
308 ids.sort_unstable();
309 ids.dedup();
310 ids
311}
312
313pub fn validate_correlation_policy(raw: &str, origin: &str) -> Result<(), String> {
323 parse_policy(raw, origin).map(|_| ())
324}
325
326fn parent_dir(path: &str) -> &str {
333 match path.rfind(['/', '\\']) {
334 Some(0) => &path[..1],
335 Some(index) => &path[..index],
336 None => ".",
337 }
338}
339
340fn finding_locations(finding: &VerifiedFinding) -> impl Iterator<Item = &MatchLocation> {
342 std::iter::once(&finding.location).chain(finding.additional_locations.iter())
343}
344
345fn strongest_confidence<'a>(members: impl Iterator<Item = &'a VerifiedFinding>) -> Option<f64> {
348 members
349 .filter_map(|finding| finding.confidence)
350 .fold(None, |best: Option<f64>, value| {
351 Some(best.map_or(value, |current| current.max(value)))
352 })
353}
354
355fn lift(strongest: Option<f64>, bonus: f64) -> Option<f64> {
358 strongest.map(|value| {
359 (value + bonus)
360 .min(POLICY.settings.max_confidence)
361 .max(value)
362 })
363}
364
365fn member_of(
368 finding: &VerifiedFinding,
369 role: CorrelationRole,
370 scope: Option<&str>,
371) -> CorrelatedMember {
372 let mut locations: Vec<CorrelatedLocation> = finding_locations(finding)
373 .filter_map(|location| {
374 let path = location.file_path.as_deref()?;
375 if scope.is_some_and(|dir| parent_dir(path) != dir) {
376 return None;
377 }
378 Some(CorrelatedLocation {
379 file_path: path.to_string(),
380 line: location.line,
381 })
382 })
383 .collect();
384 locations.sort();
385 locations.dedup();
386 CorrelatedMember {
387 detector_id: finding.detector_id.to_string(),
388 detector_name: finding.detector_name.to_string(),
389 service: finding.service.to_string(),
390 severity: finding.severity,
391 role,
392 credential_redacted: finding.credential_redacted.to_string(),
393 credential_hash: hex_encode(finding.credential_hash),
394 confidence: finding.confidence,
395 locations,
396 }
397}
398
399fn union_locations(members: &[CorrelatedMember]) -> Vec<CorrelatedLocation> {
401 let mut locations: Vec<CorrelatedLocation> = members
402 .iter()
403 .flat_map(|member| member.locations.iter().cloned())
404 .collect();
405 locations.sort();
406 locations.dedup();
407 locations
408}
409
410fn distinct_files(locations: &[CorrelatedLocation]) -> usize {
412 locations
413 .iter()
414 .map(|location| location.file_path.as_str())
415 .collect::<BTreeSet<_>>()
416 .len()
417}
418
419fn shared_service(members: &[CorrelatedMember]) -> String {
421 let mut services = members.iter().map(|member| member.service.as_str());
422 let Some(first) = services.next() else {
423 return "multiple".to_string();
424 };
425 if services.all(|service| service == first) {
426 first.to_string()
427 } else {
428 "multiple".to_string()
429 }
430}
431
432#[must_use]
438pub fn correlate_findings(findings: &[VerifiedFinding]) -> Vec<CorrelatedCredential> {
439 let mut correlations = value_reuse_groups(findings);
440 correlations.extend(split_composite_groups(findings));
441 correlations.sort_by(|left, right| {
442 left.kind
443 .cmp(&right.kind)
444 .then_with(|| right.severity.cmp(&left.severity))
445 .then_with(|| left.service.cmp(&right.service))
446 .then_with(|| left.id.cmp(&right.id))
447 });
448 correlations
449}
450
451fn value_reuse_groups(findings: &[VerifiedFinding]) -> Vec<CorrelatedCredential> {
453 let mut by_digest: BTreeMap<CredentialHash, Vec<&VerifiedFinding>> = BTreeMap::new();
454 for finding in findings {
455 by_digest
456 .entry(finding.credential_hash)
457 .or_default()
458 .push(finding);
459 }
460
461 let mut groups = Vec::new();
462 for (digest, mut group) in by_digest {
463 group.sort_by(|left, right| {
464 left.detector_id
465 .cmp(&right.detector_id)
466 .then_with(|| left.location.file_path.cmp(&right.location.file_path))
467 .then_with(|| left.location.line.cmp(&right.location.line))
468 });
469 let members: Vec<CorrelatedMember> = group
470 .iter()
471 .map(|finding| member_of(finding, CorrelationRole::SameValue, None))
472 .collect();
473 let locations = union_locations(&members);
474 let file_count = distinct_files(&locations);
475 if file_count < POLICY.settings.reuse_min_files {
476 continue;
477 }
478 let detectors: BTreeSet<&str> = members
479 .iter()
480 .map(|member| member.detector_id.as_str())
481 .collect();
482 let title = if detectors.len() > 1 {
483 format!(
484 "One secret value matched by {} detectors across {file_count} files",
485 detectors.len()
486 )
487 } else {
488 format!(
489 "{} value reused across {file_count} files",
490 members
491 .first()
492 .map_or("Credential", |member| member.detector_name.as_str())
493 )
494 };
495 let strongest = strongest_confidence(group.iter().copied());
496 let severity = members
497 .iter()
498 .map(|member| member.severity)
499 .max()
500 .unwrap_or_default(); groups.push(CorrelatedCredential {
502 id: format!("reuse:{}", hex_encode(digest)),
503 kind: CorrelationKind::ValueReuse,
504 title,
505 service: shared_service(&members),
506 severity,
507 confidence: lift(strongest, POLICY.settings.reuse_confidence_bonus),
508 strongest_member_confidence: strongest,
509 scope: None,
510 file_count,
511 impact: POLICY.settings.reuse_impact.clone(),
512 members,
513 locations,
514 });
515 }
516 groups
517}
518
519type DirectoryIndex<'a> = BTreeMap<&'a str, BTreeMap<&'a str, BTreeSet<CredentialHash>>>;
522
523fn split_composite_groups(findings: &[VerifiedFinding]) -> Vec<CorrelatedCredential> {
526 let mut index: DirectoryIndex<'_> = BTreeMap::new();
527 let mut by_part: BTreeMap<(&str, CredentialHash), &VerifiedFinding> = BTreeMap::new();
528 for finding in findings {
529 by_part.insert((&finding.detector_id, finding.credential_hash), finding);
530 for location in finding_locations(finding) {
531 let Some(path) = location.file_path.as_deref() else {
532 continue;
533 };
534 index
535 .entry(parent_dir(path))
536 .or_default()
537 .entry(&finding.detector_id)
538 .or_default()
539 .insert(finding.credential_hash);
540 }
541 }
542
543 let mut groups = Vec::new();
544 for (directory, detectors) in &index {
545 for composite in &POLICY.composite {
546 let Some(group) = composite_group(composite, directory, detectors, &by_part) else {
547 continue;
548 };
549 groups.push(group);
550 }
551 }
552 groups
553}
554
555fn composite_group(
558 composite: &CompositeSpec,
559 directory: &str,
560 detectors: &BTreeMap<&str, BTreeSet<CredentialHash>>,
561 by_part: &BTreeMap<(&str, CredentialHash), &VerifiedFinding>,
562) -> Option<CorrelatedCredential> {
563 let mut members = Vec::with_capacity(composite.required.len() + composite.optional.len());
564 let mut sources = Vec::with_capacity(composite.required.len());
565
566 for part in &composite.required {
567 let digests = detectors.get(part.as_str())?;
568 let [digest] = digests.iter().copied().collect::<Vec<_>>()[..] else {
572 return None;
573 };
574 let finding = by_part.get(&(part.as_str(), digest))?;
575 let member = member_of(finding, CorrelationRole::RequiredPart, Some(directory));
576 sources.push(*finding);
577 members.push(member);
578 }
579
580 let mut shared: Option<BTreeSet<&str>> = None;
584 for member in &members {
585 let files: BTreeSet<&str> = member
586 .locations
587 .iter()
588 .map(|location| location.file_path.as_str())
589 .collect();
590 shared = Some(match shared {
591 None => files,
592 Some(current) => current.intersection(&files).copied().collect(),
593 });
594 }
595 if shared.is_none_or(|files| !files.is_empty()) {
596 return None;
597 }
598
599 for part in &composite.optional {
600 let Some(digests) = detectors.get(part.as_str()) else {
601 continue;
602 };
603 let [digest] = digests.iter().copied().collect::<Vec<_>>()[..] else {
604 continue;
605 };
606 let Some(finding) = by_part.get(&(part.as_str(), digest)) else {
607 continue;
608 };
609 sources.push(*finding);
610 members.push(member_of(
611 finding,
612 CorrelationRole::OptionalPart,
613 Some(directory),
614 ));
615 }
616
617 members.sort_by(|left, right| {
618 left.detector_id
619 .cmp(&right.detector_id)
620 .then_with(|| left.credential_hash.cmp(&right.credential_hash))
621 });
622 let locations = union_locations(&members);
623 let file_count = distinct_files(&locations);
624 let strongest = strongest_confidence(sources.into_iter());
625 let severity = members
626 .iter()
627 .map(|member| member.severity)
628 .max()
629 .unwrap_or_default() .max(composite.severity);
631 Some(CorrelatedCredential {
632 id: format!("composite:{}@{directory}", composite.id),
633 kind: CorrelationKind::SplitComposite,
634 title: format!("{} split across {file_count} files", composite.name),
635 service: composite.service.clone(),
636 severity,
637 confidence: lift(strongest, composite.confidence_bonus),
638 strongest_member_confidence: strongest,
639 scope: Some(directory.to_string()),
640 file_count,
641 impact: composite.impact.clone(),
642 members,
643 locations,
644 })
645}