Skip to main content

keyhog_core/
correlation.rs

1//! Cross-file credential correlation.
2//!
3//! A scanner reports one match at a time. An attacker does not use one match at
4//! a time: they use the AWS access key from `main.tf` together with the secret
5//! access key someone left in `.env`, and they notice that the "random" token in
6//! `staging.yaml` is byte-for-byte the token in `prod.yaml`. Neither relationship
7//! is visible in a flat findings list, and neither is reachable from inside a
8//! detector: a `[[detector.companions]]` regex is bounded to a few lines of ONE
9//! chunk, and per-detector dedup only folds repeats of the SAME detector into
10//! `additional_locations`.
11//!
12//! This module runs after the scan, over the findings the report is about to
13//! publish, and joins them into correlated groups:
14//!
15//! * [`CorrelationKind::ValueReuse`] - one credential digest at several distinct
16//!   file paths, crossing detector boundaries.
17//! * [`CorrelationKind::SplitComposite`] - a provider credential whose halves are
18//!   separate detectors, planted in different files of one directory.
19//!
20//! Every service this join names lives in the Tier-B
21//! `data/credential-correlation.toml` policy, never in a match arm here, so
22//! extending provider coverage is a reviewable data edit.
23//!
24//! Correlation is strictly additive: it reads findings and returns a separate
25//! list. It never adds, drops, reorders, or edits a finding, so a report
26//! produced without correlation is byte-identical to one produced before this
27//! module existed.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::sync::LazyLock;
31
32use crate::{hex_encode, CredentialHash, MatchLocation, Severity, VerifiedFinding};
33
34/// How a correlation group was joined.
35#[derive(
36    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
37)]
38#[serde(rename_all = "snake_case")]
39pub enum CorrelationKind {
40    /// One credential value observed at several distinct file paths.
41    ValueReuse,
42    /// A composite provider credential whose required parts are split across
43    /// different files.
44    SplitComposite,
45}
46
47impl CorrelationKind {
48    /// Stable machine-readable discriminator, shared by the JSON projection and
49    /// the text renderer so the two can never disagree about a group's kind.
50    #[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/// Why one finding belongs to a correlation group.
60#[derive(
61    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
62)]
63#[serde(rename_all = "snake_case")]
64pub enum CorrelationRole {
65    /// The member carries the correlated credential value itself.
66    SameValue,
67    /// The member satisfies a required part of the composite credential.
68    RequiredPart,
69    /// The member satisfies an optional part of the composite credential.
70    OptionalPart,
71}
72
73/// One place a correlated credential was seen.
74///
75/// Deliberately narrower than [`MatchLocation`]: a correlation answers "which
76/// files does this reach", so it carries the path and line and drops chunk
77/// offsets and commit provenance, which stay on the finding itself.
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
79pub struct CorrelatedLocation {
80    /// File path, object key, or logical path of the match.
81    pub file_path: String,
82    /// One-based line number when the source knew one.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub line: Option<usize>,
85}
86
87/// One finding participating in a correlation group.
88#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
89pub struct CorrelatedMember {
90    /// Detector that produced the member finding.
91    pub detector_id: String,
92    /// Human-readable detector name.
93    pub detector_name: String,
94    /// Service namespace of the member detector.
95    pub service: String,
96    /// Severity the member finding carries on its own.
97    pub severity: Severity,
98    /// Why this member is in the group.
99    pub role: CorrelationRole,
100    /// Redacted credential preview, identical to the member finding's.
101    pub credential_redacted: String,
102    /// Hex SHA-256 digest of the member credential, the join key for value
103    /// reuse and the stable link back to the finding it came from.
104    pub credential_hash: String,
105    /// Member confidence before correlation lifted it.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub confidence: Option<f64>,
108    /// Locations of this member that fall inside the group's scope.
109    pub locations: Vec<CorrelatedLocation>,
110}
111
112/// A credential risk assembled from several findings.
113#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
114pub struct CorrelatedCredential {
115    /// Stable identifier for the group, unique within one report.
116    pub id: String,
117    /// How the group was joined.
118    pub kind: CorrelationKind,
119    /// One-line operator-facing summary.
120    pub title: String,
121    /// Service namespace, or `multiple` when members disagree.
122    pub service: String,
123    /// Group severity: the strongest member severity, raised to the composite's
124    /// declared severity when the Tier-B policy declares a higher one.
125    pub severity: Severity,
126    /// Correlated confidence: the strongest member confidence lifted by the
127    /// Tier-B bonus and clamped to the configured ceiling. Absent when no
128    /// member scored a confidence at all.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub confidence: Option<f64>,
131    /// Strongest confidence any single member had before the lift, so a reader
132    /// can see exactly what correlation added.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub strongest_member_confidence: Option<f64>,
135    /// Directory the composite parts share. Absent for value reuse, which is
136    /// scoped to the whole scan.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub scope: Option<String>,
139    /// Number of distinct files the group spans.
140    pub file_count: usize,
141    /// What the correlation means for an operator, from Tier-B data.
142    pub impact: String,
143    /// Member findings, sorted by detector id then credential digest.
144    pub members: Vec<CorrelatedMember>,
145    /// Union of every member location in the group, sorted by path then line.
146    pub locations: Vec<CorrelatedLocation>,
147}
148
149/// Tunables shared by every correlation join.
150#[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/// One composite provider credential whose halves are separate detectors.
160#[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/// The parsed Tier-B correlation policy.
175#[derive(serde::Deserialize)]
176#[serde(deny_unknown_fields)]
177struct CorrelationPolicy {
178    settings: CorrelationSettings,
179    #[serde(default)]
180    composite: Vec<CompositeSpec>,
181}
182
183/// The compiled-in policy. `include_str!` makes an invalid document a BUILD bug,
184/// never a runtime condition the operator can act on, so the initializer panics
185/// rather than degrading to an empty policy: an empty policy would silently
186/// report zero correlations on a repo that has them, which is exactly the
187/// fail-silent shape Law 10 forbids.
188#[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
203/// Parse and validate one correlation policy document.
204///
205/// Returned as `Err` rather than panicking so the same validation runs over
206/// candidate documents in tests without taking the process down.
207fn 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
214/// Fail closed on a policy that would silently misbehave: a reuse threshold
215/// below two is not a cross-file join at all, a non-positive bonus makes
216/// correlation claim corroboration it did not add, a required list shorter than
217/// two parts is not a composite, and a duplicate part id means one of the rows
218/// can never be satisfied the way its author intended.
219fn 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/// Every detector id any composite row names, sorted and deduplicated.
296///
297/// Exposed so a corpus-integrity check can prove the Tier-B policy only names
298/// detectors that actually ship: a typo would otherwise make a whole composite
299/// silently unsatisfiable.
300#[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
313/// Validate a candidate correlation policy document.
314///
315/// The shipped policy is compiled in and validated at first use; this entry
316/// point exists so the same fail-closed rules can be exercised against
317/// hand-written documents without a rebuild.
318///
319/// # Errors
320///
321/// Returns the human-readable reason the document was rejected.
322pub fn validate_correlation_policy(raw: &str, origin: &str) -> Result<(), String> {
323    parse_policy(raw, origin).map(|_| ())
324}
325
326/// Directory portion of a scanned path, or `.` for a path with no separator.
327///
328/// Splits on both separators unconditionally: a report can carry Windows paths
329/// while running on a POSIX host (git history, remote sources, a report
330/// re-rendered elsewhere), so keying on the host separator alone would put
331/// `a\b.env` and `a\c.env` in different scopes.
332fn parent_dir(path: &str) -> &str {
333    match path.rfind(['/', '\\']) {
334        Some(0) => &path[..1],
335        Some(index) => &path[..index],
336        None => ".",
337    }
338}
339
340/// Locations a finding occupies: its primary plus every deduplicated repeat.
341fn finding_locations(finding: &VerifiedFinding) -> impl Iterator<Item = &MatchLocation> {
342    std::iter::once(&finding.location).chain(finding.additional_locations.iter())
343}
344
345/// Largest confidence in an iterator of member findings, ignoring members that
346/// never scored one.
347fn 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
355/// Apply a Tier-B bonus to the strongest member confidence, clamped to the
356/// configured ceiling. A member already at the ceiling keeps its value.
357fn 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
365/// Render a member for a correlation group, keeping only the locations that
366/// fall inside `scope` when a scope is given.
367fn 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
399/// Union of member locations, sorted and deduplicated.
400fn 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
410/// Distinct file paths a location list touches.
411fn distinct_files(locations: &[CorrelatedLocation]) -> usize {
412    locations
413        .iter()
414        .map(|location| location.file_path.as_str())
415        .collect::<BTreeSet<_>>()
416        .len()
417}
418
419/// Single service shared by every member, or `multiple` when they disagree.
420fn 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/// Correlate the findings a report is about to publish.
433///
434/// Returns a deterministically ordered list: identical findings always produce
435/// identical bytes, independent of scan order, filesystem enumeration, or
436/// thread scheduling.
437#[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
451/// Join findings that carry the same credential digest at several file paths.
452fn 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(); // LAW10: an empty correlated member set has no severity; this display model default cannot remove source findings.
501        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
519/// Per-directory index used by the composite join: which credential digests each
520/// detector produced inside each directory.
521type DirectoryIndex<'a> = BTreeMap<&'a str, BTreeMap<&'a str, BTreeSet<CredentialHash>>>;
522
523/// Join composite provider credentials whose required parts sit in different
524/// files of one directory.
525fn 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
555/// Build one composite group for `directory`, or `None` when the directory does
556/// not satisfy the composite unambiguously.
557fn 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        // Two candidate access keys and three candidate secrets sharing a
569        // directory is an ambiguous pairing. Report nothing rather than assert a
570        // pair that may not exist.
571        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    // The whole point is the SPLIT: when one file already holds every required
581    // part, the detector's own companion regex covers it and a correlation would
582    // only restate the finding.
583    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() // LAW10: absent companion severity leaves the composite's own severity authoritative; all member findings remain retained.
630        .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}