Skip to main content

keyhog_core/
access_target.rs

1//! Access targets: the door a credential opens.
2//!
3//! A finding says "there is a Postgres password on line 2 of `config.yaml`". It
4//! does not say which database that password reaches, and that is the first
5//! thing a responder needs in order to decide whether this is a staging toy or
6//! the production customer store. The address is almost always sitting right
7//! next to the credential (in the same connection string, in the same `.env`,
8//! in the same Terraform variable block) and no detector can see it: a
9//! `[[detector.companions]]` regex is bounded to a few lines of one chunk and
10//! is written to capture the OTHER HALF OF THE CREDENTIAL, not the resource.
11//!
12//! This module runs after the scan, over the findings the report is about to
13//! publish, and attaches typed access targets to them:
14//!
15//! * [`AccessTargetKind::Account`] - a billing or ownership boundary.
16//! * [`AccessTargetKind::Tenant`] - an identity/org boundary inside a provider.
17//! * [`AccessTargetKind::Endpoint`] - a network address it authenticates to.
18//! * [`AccessTargetKind::Database`] - a named logical database inside one.
19//! * [`AccessTargetKind::Resource`] - a concrete addressable object.
20//!
21//! Every provider this pass understands lives in the Tier-B
22//! `data/access-targets.toml` policy, never in a match arm here, so extending
23//! coverage is a reviewable data edit.
24//!
25//! Three guarantees hold by construction.
26//!
27//! **Additive.** The pass reads findings and returns a separate report. It never
28//! adds, drops, reorders, or edits a finding, so a report produced without it is
29//! byte-identical to one produced before this module existed.
30//!
31//! **Redaction-safe.** A rule may only capture an address, never an
32//! authenticator: connection-string rules skip userinfo with a non-capturing
33//! group, so a password cannot reach a capture. On top of that every candidate
34//! whose SHA-256 equals a credential digest in the same report is dropped
35//! unconditionally, and the per-rule [`Redaction`] policy is applied before the
36//! value reaches an artifact. Evidence carries the rule id, line, column, span
37//! length, and line distance; it never carries document text, so an artifact
38//! contains no plaintext secret and no unrelated document body.
39//!
40//! **Bounded.** File context comes from an index built at most once per distinct
41//! file, over at most `max_file_bytes` of it, under a whole-pass
42//! `max_total_bytes` ceiling. Cost is linear in indexed bytes plus a sort per
43//! finding, never quadratic in findings.
44//!
45//! When context cannot be honored the pass says so. A finding read from git
46//! history, a container layer, stdin, or an unreadable path is counted in
47//! [`AccessTargetCoverage::gaps`] with the reason, and
48//! [`AccessTargetCoverage::complete`] goes false. An empty target list under an
49//! incomplete coverage report means "not looked at", which is a different fact
50//! from "looked at, found no door", and the two are never conflated.
51
52use std::collections::{BTreeMap, BTreeSet, HashSet};
53use std::io::Read;
54use std::sync::LazyLock;
55
56use regex::Regex;
57
58use crate::{hex_encode, sha256_hash, CredentialHash, VerifiedFinding};
59
60/// What kind of thing a credential opens.
61///
62/// Ordered broadest blast radius first, so the derived `Ord` sorts an account
63/// above a single resource when two targets tie on distance and confidence.
64#[derive(
65    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum AccessTargetKind {
69    /// A billing or ownership boundary, such as an AWS account id.
70    Account,
71    /// An identity or organization boundary inside a provider.
72    Tenant,
73    /// A network address the credential authenticates to.
74    Endpoint,
75    /// A named logical database or schema inside an endpoint.
76    Database,
77    /// A concrete addressable object, such as a bucket, ARN, or repository.
78    Resource,
79}
80
81impl AccessTargetKind {
82    /// Stable machine-readable discriminator, shared by the JSON projection and
83    /// any renderer so the two can never disagree about a target's kind.
84    #[must_use]
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Account => "account",
88            Self::Tenant => "tenant",
89            Self::Endpoint => "endpoint",
90            Self::Database => "database",
91            Self::Resource => "resource",
92        }
93    }
94}
95
96/// How a target was tied to a credential.
97///
98/// Ordered strongest first: a value decoded out of the credential itself cannot
99/// be a coincidence of proximity, while a same-file hit is the weakest claim the
100/// pass makes.
101#[derive(
102    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
103)]
104#[serde(rename_all = "snake_case")]
105pub enum TargetRelation {
106    /// Recovered from the credential itself, offline, with no file context.
107    Decoded,
108    /// Found on the same line as the finding.
109    SameLine,
110    /// Found elsewhere in the same file, inside the indexed prefix.
111    SameFile,
112}
113
114impl TargetRelation {
115    /// Stable machine-readable discriminator.
116    #[must_use]
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Self::Decoded => "decoded",
120            Self::SameLine => "same_line",
121            Self::SameFile => "same_file",
122        }
123    }
124}
125
126/// What was done to a target value before it was allowed into an artifact.
127#[derive(
128    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
129)]
130#[serde(rename_all = "snake_case")]
131pub enum Redaction {
132    /// Emitted verbatim. Only for values that are addresses by construction.
133    None,
134    /// Emitted as an ellipsis plus a short suffix.
135    Tail,
136    /// Emitted as `sha256:` plus the first 16 hex characters of the digest.
137    Hash,
138}
139
140/// Why the pass could not build file context for some findings.
141#[derive(
142    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
143)]
144#[serde(rename_all = "snake_case")]
145pub enum CoverageGapReason {
146    /// The finding carried no file path at all.
147    NoFilePath,
148    /// The source backend does not expose a re-readable local file.
149    SourceNotReadable,
150    /// The finding describes historical content at a commit, not the file on
151    /// disk today, so indexing the working-tree file would attribute a door
152    /// that may never have coexisted with the credential.
153    HistoricalContent,
154    /// The file could not be read for a reason that may not hold a moment
155    /// later: it was removed or replaced after the scan, briefly locked, or the
156    /// read was interrupted. This is a candidate for retry by whatever owns the
157    /// scan's retry policy, and it is NOT the same fact as a permanent hole.
158    TransientReadFailed,
159    /// The file could not be read for a reason retrying cannot change:
160    /// permission denied, the path is a directory, or the device rejected it.
161    PermanentReadFailed,
162    /// The indexed prefix is not valid UTF-8, so byte offsets could not be
163    /// mapped to lines without corrupting them.
164    NotUtf8,
165    /// The credential was recovered from a DERIVED view of the file rather than
166    /// from the file's own byte stream. Two kinds produce this, both labelled
167    /// `filesystem/<view>`: a decode view (`filesystem/base64`,
168    /// `filesystem/hex`, `filesystem/reverse`, `filesystem/quoted-printable`)
169    /// and a windowed read of a large file (`filesystem/windowed`), whose line
170    /// numbers are relative to the window, not the file.
171    ///
172    /// The file on disk is readable and its doors are real, so it is still
173    /// indexed and its targets are still reported. What is missing is a line
174    /// ANCHOR: the finding's line does not index the file, so no honest
175    /// distance exists and every target is charged the maximum decay rather
176    /// than claiming a proximity the number cannot support.
177    DerivedViewAnchorless,
178    /// The file is longer than `max_file_bytes`; only its prefix was indexed.
179    FileTruncated,
180    /// The whole-pass `max_total_bytes` ceiling was reached before this file.
181    ByteBudgetExhausted,
182}
183
184impl CoverageGapReason {
185    /// Stable machine-readable discriminator.
186    #[must_use]
187    pub fn as_str(self) -> &'static str {
188        match self {
189            Self::NoFilePath => "no_file_path",
190            Self::SourceNotReadable => "source_not_readable",
191            Self::HistoricalContent => "historical_content",
192            Self::TransientReadFailed => "transient_read_failed",
193            Self::PermanentReadFailed => "permanent_read_failed",
194            Self::NotUtf8 => "not_utf8",
195            Self::DerivedViewAnchorless => "derived_view_anchorless",
196            Self::FileTruncated => "file_truncated",
197            Self::ByteBudgetExhausted => "byte_budget_exhausted",
198        }
199    }
200
201    /// One calm sentence an operator can act on.
202    #[must_use]
203    pub fn explain(self) -> &'static str {
204        match self {
205            Self::NoFilePath => "the finding carries no file path, so there is nothing to index",
206            Self::SourceNotReadable => {
207                "this source backend does not expose a re-readable local file; \
208                 rescan the extracted content from disk to get access targets"
209            }
210            Self::HistoricalContent => {
211                "the finding is historical content at a commit; the working-tree \
212                 file was not indexed because its neighbours may postdate the credential"
213            }
214            Self::TransientReadFailed => {
215                "the file could not be read, for a reason that may not hold a moment \
216                 later; it was removed, replaced, or locked between the scan and this \
217                 pass, so rerunning may cover it"
218            }
219            Self::PermanentReadFailed => {
220                "the file could not be read and rerunning will not change that; check \
221                 permissions and whether the path is a regular file"
222            }
223            Self::NotUtf8 => "the indexed prefix is not valid UTF-8",
224            Self::DerivedViewAnchorless => {
225                "the credential came from a derived view of this file (a decode view, \
226                 or a windowed read of a large file), so its line number does not \
227                 index the file; the file was still indexed and its targets are still \
228                 reported, but at maximum distance decay rather than by proximity"
229            }
230            Self::FileTruncated => {
231                "the file is larger than the configured max_file_bytes, so only \
232                 its prefix was indexed"
233            }
234            Self::ByteBudgetExhausted => {
235                "the pass reached max_total_bytes before reaching this file"
236            }
237        }
238    }
239}
240
241/// Why one target is attributed to one credential.
242///
243/// Deliberately structural. There is no excerpt field, and adding one would
244/// break the module's redaction guarantee: a line holding a credential also
245/// holds the credential.
246#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
247pub struct TargetEvidence {
248    /// How the target was tied to the credential.
249    pub relation: TargetRelation,
250    /// Tier-B rule id, or `metadata:<key>` for a decoded target.
251    pub rule_id: String,
252    /// File the evidence was observed in. Absent for a decoded target.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub file_path: Option<String>,
255    /// One-based line of the evidence span. Absent for a decoded target.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub line: Option<usize>,
258    /// One-based byte column of the evidence span start.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub column: Option<usize>,
261    /// Length of the evidence span in bytes. The span itself is not emitted.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub span_bytes: Option<usize>,
264    /// Absolute line distance between the finding and the evidence.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub line_distance: Option<usize>,
267    /// Where the confidence number came from, so a reader can audit the score
268    /// without reverse-engineering it.
269    pub provenance: ConfidenceProvenance,
270}
271
272/// Exactly how a target's confidence was produced.
273#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
274pub struct ConfidenceProvenance {
275    /// `tier_b_rule` or `credential_metadata`.
276    pub source: String,
277    /// Confidence the rule declared before relation weighting.
278    pub base: f64,
279    /// Number of `same_file_decay` applications, from the line distance.
280    pub decay_steps: u32,
281    /// Multiplier applied per decay step.
282    pub decay_factor: f64,
283}
284
285/// One resource a credential is believed to open.
286#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
287pub struct AccessTarget {
288    /// What kind of thing this is.
289    pub kind: AccessTargetKind,
290    /// The address, after the rule's redaction policy.
291    pub value: String,
292    /// What was done to `value` before emitting it.
293    pub redaction: Redaction,
294    /// Short operator-facing name of the target class, from Tier-B data.
295    pub label: String,
296    /// Provider namespace the rule belongs to, when it names one.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub service: Option<String>,
299    /// Score after relation weighting and distance decay, rounded to 3 places.
300    pub confidence: f64,
301    /// Why this target is attributed to this credential.
302    pub evidence: TargetEvidence,
303}
304
305/// Where a credential with access targets was found.
306#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
307pub struct TargetedLocation {
308    /// Logical source backend of the finding.
309    pub source: String,
310    /// File path, object key, or logical path when the finding had one.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub file_path: Option<String>,
313    /// One-based line when the source knew one.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub line: Option<usize>,
316}
317
318/// One finding and the doors it opens.
319#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
320pub struct CredentialAccessTargets {
321    /// Hex SHA-256 digest of the credential, the stable link back to the
322    /// finding this row describes.
323    pub credential_hash: String,
324    /// Detector that produced the finding.
325    pub detector_id: String,
326    /// Service namespace of the finding.
327    pub service: String,
328    /// Where the finding was.
329    pub location: TargetedLocation,
330    /// Targets, strongest attribution first.
331    pub targets: Vec<AccessTarget>,
332}
333
334/// One reason some findings had no file context, and how many were affected.
335#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
336pub struct CoverageGap {
337    /// Why context was unavailable.
338    pub reason: CoverageGapReason,
339    /// One calm sentence an operator can act on.
340    pub explanation: String,
341    /// Number of findings affected.
342    pub findings: usize,
343    /// Up to a handful of affected paths or source labels, for triage.
344    pub examples: Vec<String>,
345}
346
347/// What the pass actually managed to look at.
348///
349/// This exists so that an empty `targets` list can never be mistaken for "this
350/// credential opens nothing". If `complete` is false, some findings were never
351/// inspected and the reason is in `gaps`.
352#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
353pub struct AccessTargetCoverage {
354    /// Findings the pass was given.
355    pub findings_total: usize,
356    /// Findings whose file was successfully indexed.
357    pub findings_with_file_context: usize,
358    /// Distinct files indexed.
359    pub files_indexed: usize,
360    /// Bytes read while indexing.
361    pub bytes_indexed: u64,
362    /// True only when every finding got file context.
363    pub complete: bool,
364    /// Why the rest did not, sorted by reason.
365    pub gaps: Vec<CoverageGap>,
366}
367
368/// The complete result of one association pass.
369#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
370pub struct AccessTargetReport {
371    /// Findings that got at least one target, sorted by path then line.
372    pub targets: Vec<CredentialAccessTargets>,
373    /// What the pass looked at and what it could not.
374    pub coverage: AccessTargetCoverage,
375}
376
377impl AccessTargetReport {
378    /// True when the pass produced nothing at all and hit no coverage gap, the
379    /// only case in which a caller may omit the section entirely.
380    #[must_use]
381    pub fn is_empty(&self) -> bool {
382        self.targets.is_empty() && self.coverage.gaps.is_empty()
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Tier-B policy
388// ---------------------------------------------------------------------------
389
390#[derive(serde::Deserialize)]
391#[serde(deny_unknown_fields)]
392struct Settings {
393    max_file_bytes: u64,
394    max_total_bytes: u64,
395    max_targets_per_finding: usize,
396    max_matches_per_rule: usize,
397    min_confidence: f64,
398    same_file_decay: f64,
399    decay_line_step: usize,
400    decay_max_steps: u32,
401    decoded_confidence: f64,
402}
403
404#[derive(serde::Deserialize)]
405#[serde(deny_unknown_fields)]
406struct MetadataRule {
407    key: String,
408    kind: AccessTargetKind,
409    #[serde(default)]
410    service: Option<String>,
411    label: String,
412}
413
414#[derive(serde::Deserialize)]
415#[serde(deny_unknown_fields)]
416struct RuleSpec {
417    id: String,
418    kind: AccessTargetKind,
419    label: String,
420    #[serde(default)]
421    service: Option<String>,
422    pattern: String,
423    group: usize,
424    confidence: f64,
425    redact: Redaction,
426    #[serde(default)]
427    redact_keep: Option<usize>,
428}
429
430#[derive(serde::Deserialize)]
431#[serde(deny_unknown_fields)]
432struct PolicyFile {
433    settings: Settings,
434    #[serde(default)]
435    metadata: Vec<MetadataRule>,
436    #[serde(default)]
437    rule: Vec<RuleSpec>,
438}
439
440/// A rule with its pattern compiled.
441struct CompiledRule {
442    spec: RuleSpec,
443    regex: Regex,
444}
445
446struct Policy {
447    settings: Settings,
448    metadata: Vec<MetadataRule>,
449    rules: Vec<CompiledRule>,
450}
451
452/// The compiled-in policy. `include_str!` makes an invalid document a BUILD bug,
453/// never a runtime condition the operator can act on, so the initializer panics
454/// rather than degrading to an empty policy: an empty policy would report zero
455/// access targets on a repository full of them while still claiming complete
456/// coverage, which is exactly the fail-silent shape Law 10 forbids.
457///
458/// Nothing forces this `LazyLock` unless the caller asks for access targets, so
459/// a default scan pays neither the parse nor the regex compilation.
460#[allow(clippy::panic)]
461static POLICY: LazyLock<Policy> = LazyLock::new(|| {
462    match compile_policy(
463        include_str!("../data/access-targets.toml"),
464        "<embedded data/access-targets.toml>",
465    ) {
466        Ok(policy) => policy,
467        Err(error) => panic!(
468            "keyhog: access-target policy '<embedded data/access-targets.toml>' \
469             is invalid: {error}. Fix: correct crates/core/data/access-targets.toml and rebuild"
470        ),
471    }
472});
473
474/// Parse, validate, and compile one access-target policy document.
475///
476/// Returned as `Err` rather than panicking so the same validation runs over
477/// candidate documents in tests without taking the process down.
478fn compile_policy(raw: &str, origin: &str) -> Result<Policy, String> {
479    let file = toml::from_str::<PolicyFile>(raw)
480        .map_err(|error| format!("failed to parse {origin}: {error}"))?;
481    validate_settings(&file.settings, origin)?;
482
483    let mut seen = BTreeSet::new();
484    let mut rules = Vec::with_capacity(file.rule.len());
485    for spec in file.rule {
486        let id = spec.id.trim().to_string();
487        if id.is_empty() {
488            return Err(format!("{origin} [[rule]] has an empty id"));
489        }
490        if !seen.insert(id.clone()) {
491            return Err(format!("{origin} [[rule]] duplicate id {id:?}"));
492        }
493        if spec.label.trim().is_empty() {
494            return Err(format!("{origin} [[rule]] {id:?} has an empty label"));
495        }
496        if !(spec.confidence > 0.0 && spec.confidence <= 1.0) {
497            return Err(format!(
498                "{origin} [[rule]] {id:?} confidence must be in (0.0, 1.0], got {}",
499                spec.confidence
500            ));
501        }
502        if spec.group == 0 {
503            return Err(format!(
504                "{origin} [[rule]] {id:?} group must be at least 1; group 0 is the \
505                 whole match, which would emit surrounding text"
506            ));
507        }
508        if matches!(spec.redact, Redaction::Tail) && spec.redact_keep.unwrap_or(0) == 0 {
509            // LAW10: absent tail length is treated as invalid here, so validation fails closed before any credential is emitted.
510            return Err(format!(
511                "{origin} [[rule]] {id:?} uses redact = \"tail\" and must set a \
512                 positive redact_keep"
513            ));
514        }
515        let regex = Regex::new(&spec.pattern)
516            .map_err(|error| format!("{origin} [[rule]] {id:?} pattern is invalid: {error}"))?;
517        let groups = regex.captures_len();
518        if spec.group >= groups {
519            return Err(format!(
520                "{origin} [[rule]] {id:?} wants capture group {} but the pattern has {}",
521                spec.group,
522                groups.saturating_sub(1)
523            ));
524        }
525        rules.push(CompiledRule { spec, regex });
526    }
527
528    let mut seen_keys = BTreeSet::new();
529    for entry in &file.metadata {
530        if entry.key.trim().is_empty() {
531            return Err(format!("{origin} [[metadata]] has an empty key"));
532        }
533        if !seen_keys.insert(entry.key.clone()) {
534            return Err(format!(
535                "{origin} [[metadata]] duplicate key {:?}",
536                entry.key
537            ));
538        }
539        if entry.label.trim().is_empty() {
540            return Err(format!(
541                "{origin} [[metadata]] {:?} has an empty label",
542                entry.key
543            ));
544        }
545    }
546
547    Ok(Policy {
548        settings: file.settings,
549        metadata: file.metadata,
550        rules,
551    })
552}
553
554/// Fail closed on settings that would make the pass claim more than it did: a
555/// zero byte budget indexes nothing while reporting itself complete, a decay
556/// outside `(0, 1]` either amplifies distant matches or erases them, and a zero
557/// target cap silently discards every target the rules found.
558fn validate_settings(settings: &Settings, origin: &str) -> Result<(), String> {
559    if settings.max_file_bytes == 0 {
560        return Err(format!(
561            "{origin} [settings] max_file_bytes must be positive"
562        ));
563    }
564    if settings.max_total_bytes < settings.max_file_bytes {
565        return Err(format!(
566            "{origin} [settings] max_total_bytes ({}) must be at least max_file_bytes ({})",
567            settings.max_total_bytes, settings.max_file_bytes
568        ));
569    }
570    if settings.max_targets_per_finding == 0 {
571        return Err(format!(
572            "{origin} [settings] max_targets_per_finding must be positive"
573        ));
574    }
575    if settings.max_matches_per_rule == 0 {
576        return Err(format!(
577            "{origin} [settings] max_matches_per_rule must be positive"
578        ));
579    }
580    if !(settings.min_confidence >= 0.0 && settings.min_confidence < 1.0) {
581        return Err(format!(
582            "{origin} [settings] min_confidence must be in [0.0, 1.0), got {}",
583            settings.min_confidence
584        ));
585    }
586    if !(settings.same_file_decay > 0.0 && settings.same_file_decay <= 1.0) {
587        return Err(format!(
588            "{origin} [settings] same_file_decay must be in (0.0, 1.0], got {}",
589            settings.same_file_decay
590        ));
591    }
592    if settings.decay_line_step == 0 {
593        return Err(format!(
594            "{origin} [settings] decay_line_step must be positive"
595        ));
596    }
597    if settings.decay_max_steps == 0 {
598        return Err(format!(
599            "{origin} [settings] decay_max_steps must be at least 1; zero would make \
600             a match on the far end of a file score exactly as a match on the \
601             credential's own line"
602        ));
603    }
604    if !(settings.decoded_confidence > 0.0 && settings.decoded_confidence <= 1.0) {
605        return Err(format!(
606            "{origin} [settings] decoded_confidence must be in (0.0, 1.0], got {}",
607            settings.decoded_confidence
608        ));
609    }
610    Ok(())
611}
612
613/// Validate a candidate access-target policy document without installing it.
614///
615/// Exposed so a regression test can prove the shipped file and any contributed
616/// edit are both accepted by the same code path the binary uses.
617pub fn validate_access_target_policy(raw: &str, origin: &str) -> Result<(), String> {
618    compile_policy(raw, origin).map(|_| ())
619}
620
621/// Rule ids the shipped policy defines, in file order.
622///
623/// Lets a test assert that a rule was not silently dropped by an edit.
624#[must_use]
625pub fn access_target_rule_ids() -> Vec<&'static str> {
626    POLICY
627        .rules
628        .iter()
629        .map(|rule| rule.spec.id.as_str())
630        .collect()
631}
632
633// ---------------------------------------------------------------------------
634// File content
635// ---------------------------------------------------------------------------
636
637/// Why a file could not be turned into an index.
638///
639/// Transient and permanent are separate variants on purpose. This pass re-opens
640/// a file AFTER the scan finished, so between the two a file can be deleted,
641/// replaced, or briefly locked by another process. That is a different fact
642/// from "this file is a directory" or "you may not read it", and collapsing the
643/// two would report a momentary blip as a permanent hole in coverage.
644///
645/// The failure the pass can design out, it does: [`FilesystemContent`] opens
646/// once and works from the handle rather than stat-ing a path and then opening
647/// it, so there is no check-then-use race of its own making. What is left is
648/// genuinely external, which is why it is classified rather than looped over.
649/// Retry policy is not decided here; a caller that owns one wraps
650/// [`FileContentSource`] and retries only [`ContentError::TransientRead`].
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum ContentError {
653    /// The read failed for a reason that may not hold a moment later: the file
654    /// was removed or replaced after the scan, a lock was held, a syscall was
655    /// interrupted, or a resource was momentarily busy.
656    TransientRead,
657    /// The read failed for a reason retrying cannot change: permission denied,
658    /// the path is a directory, or the device rejected it.
659    PermanentRead,
660    /// The prefix is not valid UTF-8.
661    NotUtf8,
662}
663
664impl ContentError {
665    /// Classify an I/O failure. Anything not known to be permanent is treated
666    /// as transient, because calling a momentary condition permanent is the
667    /// error that costs coverage; the reverse only costs one retry.
668    #[must_use]
669    pub fn classify(error: &std::io::Error) -> Self {
670        use std::io::ErrorKind;
671        match error.kind() {
672            ErrorKind::PermissionDenied
673            | ErrorKind::InvalidInput
674            | ErrorKind::InvalidData
675            | ErrorKind::Unsupported => Self::PermanentRead,
676            _ => Self::TransientRead,
677        }
678    }
679}
680
681/// One file's content prefix, plus whether it was cut short.
682#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct FileContent {
684    /// The prefix that was read, valid UTF-8.
685    pub text: String,
686    /// True when the file was longer than the requested cap.
687    pub truncated: bool,
688}
689
690/// Where the association pass gets file bytes.
691///
692/// A trait so the association logic is pure and testable without a filesystem,
693/// so a future source backend that can re-materialize its own content (an
694/// archive member, a container layer) can supply it without this module
695/// learning about that backend, and so a caller that owns a retry policy can
696/// wrap the implementation instead of this module growing a loop.
697pub trait FileContentSource {
698    /// Read at most `max_bytes` from `path`, or say why not.
699    ///
700    /// # Errors
701    /// Returns [`ContentError`] when the file cannot be opened, cannot be read,
702    /// or its prefix is not valid UTF-8.
703    fn read_prefix(&self, path: &str, max_bytes: u64) -> Result<FileContent, ContentError>;
704}
705
706/// Reads from the local filesystem.
707#[derive(Debug, Clone, Copy, Default)]
708pub struct FilesystemContent;
709
710impl FileContentSource for FilesystemContent {
711    fn read_prefix(&self, path: &str, max_bytes: u64) -> Result<FileContent, ContentError> {
712        // Open once and read from the handle. Nothing stats the path first, so
713        // a replacement between two syscalls cannot produce a wrong answer or a
714        // failure this pass created for itself.
715        let file = std::fs::File::open(path).map_err(|error| ContentError::classify(&error))?;
716        // Read one byte past the cap so a file sitting exactly at the cap is not
717        // reported as truncated, and a longer one always is. The capacity is
718        // bounded by the cap, never by a size the file claims to have, so a file
719        // growing under the read cannot cost unbounded memory.
720        let mut buffer = Vec::new();
721        file.take(max_bytes.saturating_add(1))
722            .read_to_end(&mut buffer)
723            .map_err(|error| ContentError::classify(&error))?;
724        let truncated = buffer.len() as u64 > max_bytes;
725        if truncated {
726            buffer.truncate(usize::try_from(max_bytes).unwrap_or(usize::MAX)); // LAW10: a u64 limit wider than usize cannot truncate an in-memory buffer further; usize::MAX is the exact effective cap.
727        }
728        let text = String::from_utf8(buffer).map_err(|_| ContentError::NotUtf8)?;
729        Ok(FileContent { text, truncated })
730    }
731}
732
733/// Source backends whose `file_path` names a re-readable local file.
734///
735/// Deliberately an allowlist. A backend not named here is reported as a
736/// coverage gap rather than guessed at, because guessing means opening whatever
737/// happens to sit at that path in the working tree and attributing its contents
738/// to a credential that came from somewhere else entirely.
739const READABLE_SOURCES: &[&str] = &["filesystem", "fs"];
740
741// ---------------------------------------------------------------------------
742// Association
743// ---------------------------------------------------------------------------
744
745/// One target candidate found in a file, before it is tied to a finding.
746struct IndexedTarget {
747    rule: usize,
748    line: usize,
749    column: usize,
750    span_bytes: usize,
751    value: String,
752}
753
754/// A file's target candidates, sorted by line.
755struct FileIndex {
756    targets: Vec<IndexedTarget>,
757    truncated: bool,
758}
759
760/// Build the bounded index for one file's text.
761///
762/// Runs each rule once over the whole prefix. Per-rule output is capped at
763/// `max_matches_per_rule`, so one pathological file cannot let one rule consume
764/// the whole per-finding target budget.
765fn index_content(text: &str, deny: &HashSet<CredentialHash>, truncated: bool) -> FileIndex {
766    let policy = &*POLICY;
767    let line_starts = line_start_offsets(text);
768    let mut targets = Vec::new();
769    for (index, rule) in policy.rules.iter().enumerate() {
770        let mut emitted = 0usize;
771        for captures in rule.regex.captures_iter(text) {
772            if emitted >= policy.settings.max_matches_per_rule {
773                break;
774            }
775            let Some(group) = captures.get(rule.spec.group) else {
776                continue;
777            };
778            let raw = group.as_str();
779            if raw.is_empty() {
780                continue;
781            }
782            // Hard redaction guard: a candidate that hashes to a credential in
783            // this report is the credential, whatever the rule intended.
784            if deny.contains(&sha256_hash(raw)) {
785                continue;
786            }
787            let (line, column) = position_of(&line_starts, group.start());
788            targets.push(IndexedTarget {
789                rule: index,
790                line,
791                column,
792                span_bytes: raw.len(),
793                value: apply_redaction(raw, &rule.spec),
794            });
795            emitted += 1;
796        }
797    }
798    targets.sort_by(|a, b| {
799        a.line
800            .cmp(&b.line)
801            .then_with(|| a.column.cmp(&b.column))
802            .then_with(|| a.rule.cmp(&b.rule))
803    });
804    FileIndex { targets, truncated }
805}
806
807fn apply_redaction(raw: &str, spec: &RuleSpec) -> String {
808    match spec.redact {
809        Redaction::None => raw.to_string(),
810        Redaction::Tail => {
811            let keep = spec.redact_keep.unwrap_or(4); // LAW10: absent optional tail length uses the documented redaction default and never exposes more than four characters.
812            let start = raw
813                .char_indices()
814                .rev()
815                .take(keep)
816                .last()
817                .map_or(raw.len(), |(offset, _)| offset);
818            let mut out = String::with_capacity(3 + raw.len() - start);
819            out.push_str("...");
820            out.push_str(&raw[start..]);
821            out
822        }
823        Redaction::Hash => {
824            let digest = hex_encode(sha256_hash(raw));
825            let mut out = String::with_capacity(23);
826            out.push_str("sha256:");
827            out.push_str(&digest[..16]);
828            out
829        }
830    }
831}
832
833/// Byte offset of the start of every line in `text`.
834fn line_start_offsets(text: &str) -> Vec<usize> {
835    let mut starts = Vec::with_capacity(text.len() / 40 + 1);
836    starts.push(0);
837    for (offset, byte) in text.bytes().enumerate() {
838        if byte == b'\n' {
839            starts.push(offset + 1);
840        }
841    }
842    starts
843}
844
845/// One-based line and one-based byte column of `offset`.
846fn position_of(line_starts: &[usize], offset: usize) -> (usize, usize) {
847    let line_index = match line_starts.binary_search(&offset) {
848        Ok(index) => index,
849        Err(index) => index.saturating_sub(1),
850    };
851    let start = line_starts.get(line_index).copied().unwrap_or(0); // LAW10: absent line metadata conservatively measures the column from byte zero; it does not drop the target.
852    (line_index + 1, offset - start + 1)
853}
854
855/// Round to three decimals so the emitted score is stable across platforms and
856/// diffable between runs.
857fn round3(value: f64) -> f64 {
858    (value * 1000.0).round() / 1000.0
859}
860
861/// Accumulates coverage gaps without letting one bad directory flood the report.
862#[derive(Default)]
863struct GapTally {
864    counts: BTreeMap<CoverageGapReason, (usize, Vec<String>)>,
865}
866
867const MAX_GAP_EXAMPLES: usize = 5;
868
869impl GapTally {
870    fn record(&mut self, reason: CoverageGapReason, example: &str) {
871        let entry = self.counts.entry(reason).or_insert((0, Vec::new()));
872        entry.0 += 1;
873        if entry.1.len() < MAX_GAP_EXAMPLES && !entry.1.iter().any(|seen| seen == example) {
874            entry.1.push(example.to_string());
875        }
876    }
877
878    fn finish(self) -> Vec<CoverageGap> {
879        self.counts
880            .into_iter()
881            .map(|(reason, (findings, examples))| CoverageGap {
882                reason,
883                explanation: reason.explain().to_string(),
884                findings,
885                examples,
886            })
887            .collect()
888    }
889}
890
891/// Attach access targets to a finding set, reading file context from disk.
892///
893/// This is the entry point the CLI uses behind `--access-targets`. It is never
894/// called on the default path, so a default scan pays nothing for it.
895///
896/// The read goes through [`RetryingContentSource`](crate::retry::RetryingContentSource),
897/// the product's one retry policy, so a file removed or locked between the scan
898/// and this pass gets a bounded second look before it becomes a coverage gap.
899/// Only [`ContentError::TransientRead`] is retried; a permission denial returns
900/// on the first attempt.
901#[must_use]
902pub fn associate_access_targets(findings: &[VerifiedFinding]) -> AccessTargetReport {
903    let content = crate::retry::RetryingContentSource::new(&FilesystemContent);
904    associate_access_targets_with(findings, &content)
905}
906
907/// Attach access targets using a caller-supplied content source.
908#[must_use]
909pub fn associate_access_targets_with(
910    findings: &[VerifiedFinding],
911    content: &dyn FileContentSource,
912) -> AccessTargetReport {
913    let policy = &*POLICY;
914    let settings = &policy.settings;
915
916    // Every credential digest in this report. A candidate value that hashes into
917    // this set is a secret, not an address, and is dropped before redaction.
918    let deny: HashSet<CredentialHash> = findings
919        .iter()
920        .map(|finding| finding.credential_hash)
921        .collect();
922
923    // One entry per distinct path: the index, or the reason there is none. The
924    // reason is cached with the path so the second finding in an unreadable file
925    // is tallied as a gap too, instead of quietly counting as "no doors found".
926    let mut indexes: BTreeMap<String, Result<FileIndex, CoverageGapReason>> = BTreeMap::new();
927    let mut bytes_indexed: u64 = 0;
928    let mut budget_exhausted = false;
929    let mut gaps = GapTally::default();
930    let mut with_context = 0usize;
931    let mut rows: Vec<CredentialAccessTargets> = Vec::new();
932
933    for finding in findings {
934        let mut targets = decoded_targets(finding, policy);
935
936        let index = match indexable(finding) {
937            Ok(target) => {
938                let path = target.path();
939                if !indexes.contains_key(path) {
940                    let entry = if budget_exhausted || bytes_indexed >= settings.max_total_bytes {
941                        budget_exhausted = true;
942                        Err(CoverageGapReason::ByteBudgetExhausted)
943                    } else {
944                        let remaining = settings.max_total_bytes - bytes_indexed;
945                        let cap = settings.max_file_bytes.min(remaining);
946                        match content.read_prefix(path, cap) {
947                            Ok(file) => {
948                                bytes_indexed =
949                                    bytes_indexed.saturating_add(file.text.len() as u64);
950                                let truncated = file.truncated || cap < settings.max_file_bytes;
951                                Ok(index_content(&file.text, &deny, truncated))
952                            }
953                            Err(ContentError::TransientRead) => {
954                                Err(CoverageGapReason::TransientReadFailed)
955                            }
956                            Err(ContentError::PermanentRead) => {
957                                Err(CoverageGapReason::PermanentReadFailed)
958                            }
959                            Err(ContentError::NotUtf8) => Err(CoverageGapReason::NotUtf8),
960                        }
961                    };
962                    indexes.insert(path.to_string(), entry);
963                }
964                match indexes.get(path) {
965                    Some(Ok(index)) => {
966                        with_context += 1;
967                        if index.truncated {
968                            gaps.record(CoverageGapReason::FileTruncated, path);
969                        }
970                        if target.anchor().is_none() {
971                            gaps.record(CoverageGapReason::DerivedViewAnchorless, path);
972                        }
973                        Some((path, index, target.anchor()))
974                    }
975                    Some(Err(reason)) => {
976                        gaps.record(*reason, path);
977                        None
978                    }
979                    None => None,
980                }
981            }
982            Err(reason) => {
983                let example = finding
984                    .location
985                    .file_path
986                    .as_deref()
987                    .unwrap_or(finding.location.source.as_ref()); // LAW10: absent optional file path uses the finding source only as a coverage-gap example; the gap remains recorded.
988                gaps.record(reason, example);
989                None
990            }
991        };
992
993        if let Some((path, index, anchor)) = index {
994            for candidate in &index.targets {
995                if let Some(target) = score(candidate, anchor, path, policy) {
996                    targets.push(target);
997                }
998            }
999        }
1000
1001        if targets.is_empty() {
1002            continue;
1003        }
1004
1005        targets.sort_by(|a, b| {
1006            let a_distance = a.evidence.line_distance.unwrap_or(0); // LAW10: absent optional distance affects deterministic ranking only; every access target remains present.
1007            let b_distance = b.evidence.line_distance.unwrap_or(0); // LAW10: absent optional distance affects deterministic ranking only; every access target remains present.
1008            a.evidence
1009                .relation
1010                .cmp(&b.evidence.relation)
1011                .then_with(|| a_distance.cmp(&b_distance))
1012                .then_with(|| {
1013                    b.confidence
1014                        .partial_cmp(&a.confidence)
1015                        .unwrap_or(std::cmp::Ordering::Equal) // LAW10: incomparable confidence values tie only in ordering; subsequent keys retain every target deterministically.
1016                })
1017                .then_with(|| a.kind.cmp(&b.kind))
1018                .then_with(|| a.value.cmp(&b.value))
1019        });
1020        targets.dedup_by(|a, b| a.kind == b.kind && a.value == b.value);
1021        targets.truncate(settings.max_targets_per_finding);
1022
1023        rows.push(CredentialAccessTargets {
1024            credential_hash: hex_encode(finding.credential_hash),
1025            detector_id: finding.detector_id.to_string(),
1026            service: finding.service.to_string(),
1027            location: TargetedLocation {
1028                source: finding.location.source.to_string(),
1029                file_path: finding.location.file_path.as_deref().map(str::to_string),
1030                line: finding.location.line,
1031            },
1032            targets,
1033        });
1034    }
1035
1036    rows.sort_by(|a, b| {
1037        a.location
1038            .file_path
1039            .cmp(&b.location.file_path)
1040            .then_with(|| a.location.line.cmp(&b.location.line))
1041            .then_with(|| a.detector_id.cmp(&b.detector_id))
1042            .then_with(|| a.credential_hash.cmp(&b.credential_hash))
1043    });
1044
1045    let gaps = gaps.finish();
1046    AccessTargetReport {
1047        targets: rows,
1048        coverage: AccessTargetCoverage {
1049            findings_total: findings.len(),
1050            findings_with_file_context: with_context,
1051            files_indexed: indexes.values().filter(|entry| entry.is_ok()).count(),
1052            bytes_indexed,
1053            complete: gaps.is_empty(),
1054            gaps,
1055        },
1056    }
1057}
1058
1059/// Targets recovered from the credential itself, with no file read.
1060fn decoded_targets(finding: &VerifiedFinding, policy: &Policy) -> Vec<AccessTarget> {
1061    let mut out = Vec::new();
1062    for entry in &policy.metadata {
1063        let Some(value) = finding.metadata.get(&entry.key) else {
1064            continue;
1065        };
1066        if value.is_empty() {
1067            continue;
1068        }
1069        out.push(AccessTarget {
1070            kind: entry.kind,
1071            value: value.clone(),
1072            redaction: Redaction::None,
1073            label: entry.label.clone(),
1074            service: entry.service.clone(),
1075            confidence: round3(policy.settings.decoded_confidence),
1076            evidence: TargetEvidence {
1077                relation: TargetRelation::Decoded,
1078                rule_id: format!("metadata:{}", entry.key),
1079                file_path: None,
1080                line: None,
1081                column: None,
1082                span_bytes: None,
1083                line_distance: None,
1084                provenance: ConfidenceProvenance {
1085                    source: "credential_metadata".to_string(),
1086                    base: round3(policy.settings.decoded_confidence),
1087                    decay_steps: 0,
1088                    decay_factor: 1.0,
1089                },
1090            },
1091        });
1092    }
1093    out
1094}
1095
1096/// Score one indexed candidate against one finding, or reject it.
1097///
1098/// `anchor` is `None` when the finding's line number indexes a derived view of
1099/// the file rather than the file itself. There is no honest distance to
1100/// measure in that case, so every candidate is charged the maximum decay: the
1101/// door is still reported, but never with a proximity claim it cannot support.
1102fn score(
1103    candidate: &IndexedTarget,
1104    anchor: Option<usize>,
1105    path: &str,
1106    policy: &Policy,
1107) -> Option<AccessTarget> {
1108    let settings = &policy.settings;
1109    let rule = policy.rules.get(candidate.rule)?;
1110    let (relation, steps, distance) = match anchor {
1111        Some(anchor) => {
1112            let distance = candidate.line.abs_diff(anchor);
1113            if distance == 0 {
1114                (TargetRelation::SameLine, 0u32, Some(0usize))
1115            } else {
1116                let steps = u32::try_from(distance / settings.decay_line_step)
1117                    .unwrap_or(settings.decay_max_steps) // LAW10: distance-to-step overflow conservatively applies maximum confidence decay; it cannot create a stronger target.
1118                    .clamp(1, settings.decay_max_steps);
1119                (TargetRelation::SameFile, steps, Some(distance))
1120            }
1121        }
1122        None => (TargetRelation::SameFile, settings.decay_max_steps, None),
1123    };
1124    let confidence = rule.spec.confidence * settings.same_file_decay.powi(steps as i32);
1125    if confidence < settings.min_confidence {
1126        return None;
1127    }
1128    Some(AccessTarget {
1129        kind: rule.spec.kind,
1130        value: candidate.value.clone(),
1131        redaction: rule.spec.redact,
1132        label: rule.spec.label.clone(),
1133        service: rule.spec.service.clone(),
1134        confidence: round3(confidence),
1135        evidence: TargetEvidence {
1136            relation,
1137            rule_id: rule.spec.id.clone(),
1138            file_path: Some(path.to_string()),
1139            line: Some(candidate.line),
1140            column: Some(candidate.column),
1141            span_bytes: Some(candidate.span_bytes),
1142            line_distance: distance,
1143            provenance: ConfidenceProvenance {
1144                source: "tier_b_rule".to_string(),
1145                base: round3(rule.spec.confidence),
1146                decay_steps: steps,
1147                decay_factor: settings.same_file_decay,
1148            },
1149        },
1150    })
1151}
1152
1153/// How a finding's file context may be used.
1154enum Indexable<'a> {
1155    /// Read the file and anchor targets at this line.
1156    Anchored(&'a str, usize),
1157    /// Read the file, but the finding's line indexes a derived view of it (a
1158    /// decode view or a window), so there is no anchor and the caller must also
1159    /// record the caveat.
1160    Anchorless(&'a str),
1161}
1162
1163impl<'a> Indexable<'a> {
1164    fn path(&self) -> &'a str {
1165        match *self {
1166            Self::Anchored(path, _) | Self::Anchorless(path) => path,
1167        }
1168    }
1169
1170    fn anchor(&self) -> Option<usize> {
1171        match *self {
1172            Self::Anchored(_, line) => Some(line),
1173            Self::Anchorless(_) => None,
1174        }
1175    }
1176}
1177
1178/// The local path this finding's context may be read from, and how, or why not.
1179///
1180/// A DERIVED view is labelled `filesystem/<view>`. Two kinds occur: decode
1181/// views (`filesystem/hex`, `filesystem/base64`, `filesystem/reverse`,
1182/// `filesystem/quoted-printable`) and the windowed reader used for large files
1183/// (`filesystem/windowed`), whose line numbers are relative to the window. In
1184/// both the underlying file is real and its doors are real, so refusing to
1185/// index it would throw away true coverage; but the finding's line number does
1186/// not index the file, so claiming a same-line pairing would be a lie. Those
1187/// get the file, not the anchor.
1188///
1189/// The `/` test is what keeps this honest as new views appear: any future
1190/// `filesystem/<something>` is treated as derived and anchorless by default,
1191/// which under-claims rather than inventing a proximity.
1192fn indexable(finding: &VerifiedFinding) -> Result<Indexable<'_>, CoverageGapReason> {
1193    if finding.location.commit.is_some() {
1194        return Err(CoverageGapReason::HistoricalContent);
1195    }
1196    let source = finding.location.source.as_ref();
1197    let anchored = READABLE_SOURCES.contains(&source);
1198    let derived = !anchored
1199        && READABLE_SOURCES.iter().any(|readable| {
1200            source.starts_with(readable) && source.as_bytes().get(readable.len()) == Some(&b'/')
1201        });
1202    if !anchored && !derived {
1203        return Err(CoverageGapReason::SourceNotReadable);
1204    }
1205    let path = match finding.location.file_path.as_deref() {
1206        Some(path) if !path.is_empty() => path,
1207        _ => return Err(CoverageGapReason::NoFilePath),
1208    };
1209    if anchored {
1210        let line = finding.location.line.unwrap_or(1); // LAW10: absent line uses the canonical default; finding remains indexable.
1211        Ok(Indexable::Anchored(path, line))
1212    } else {
1213        Ok(Indexable::Anchorless(path))
1214    }
1215}
1216
1217// Tests live in `crates/core/tests/` (KH-GAP-004: no inline test modules in
1218// `src/`). See `regression_access_target_policy.rs` for the Tier-B policy
1219// contract and `regression_access_target_association.rs` for association,
1220// redaction, bounding, and coverage behavior.