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.