keyhog_core/lib.rs
1// Lint bars for keyhog-core.
2//
3// Hard floor (kept in `deny`): the *security* lints. Every panic-y shortcut in
4// production code is a real bug. These never relax.
5//
6// `missing_docs` is `warn` at the crate floor (Santh STANDARD.md). Debt-bucket
7// modules (spec, finding, registry, source, credential, hardening, calibration)
8// carry per-module `allow(missing_docs)` that names the debt explicitly; each
9// per-module allow is removed once that module is fully documented and the
10// warn fires at full strength for it.
11#![doc = include_str!("../README.md")]
12#![warn(missing_docs)]
13#![cfg_attr(
14 not(test),
15 deny(
16 clippy::unwrap_used,
17 clippy::expect_used,
18 clippy::todo,
19 clippy::unimplemented,
20 clippy::panic
21 )
22)]
23#![allow(
24 clippy::module_name_repetitions,
25 clippy::must_use_candidate,
26 clippy::missing_errors_doc,
27 clippy::pedantic
28)]
29
30//! Core types shared across all KeyHog crates.
31/// Access-target ("door") association over an already-reported finding set.
32mod access_target;
33mod allowlist;
34mod api;
35pub mod ascii_ci;
36/// Offline AWS account-ID decode + canary-token classification (single source
37/// of truth shared by the scanner's finding metadata and the verifier's
38/// suppress-live-verification-for-canaries gate).
39mod aws;
40/// Unified cache layout classification and eviction policy contracts.
41pub mod cache_layout;
42/// Compiled-artifact class model and canonical identity contracts.
43pub mod compiled_artifact;
44/// Configuration system for KeyHog scanning options.
45mod config;
46/// Cross-file credential correlation over an already-reported finding set.
47mod correlation;
48/// Secure credential storage and redaction.
49mod credential;
50mod dedup;
51mod detector_corpus;
52mod detector_file_io;
53mod display;
54/// Shared standard Base64 decode (wire / K8s), bounded for DoS safety.
55mod encoding;
56mod evidence;
57mod finding;
58/// Git-LFS pointer recognition, shared by the scanner (oid suppression) and
59/// sources (unscanned-blob coverage gap).
60pub mod git_lfs;
61/// Perpetual guard root state machine, policy identity, and receipt types.
62pub mod guard_state;
63/// Durable guard state store: schema, tables, and memory-bounded LRU.
64pub mod guard_store;
65/// Security hardening: memory zeroization and process isolation helpers.
66mod hardening;
67mod hyperscan_cache;
68/// Detector verification response selector grammar and evaluator.
69pub mod json_selector;
70/// Structured reporting (JSON, SARIF, Text).
71mod report;
72/// The one retry policy: bounded attempts, one backoff, one classification of
73/// transient versus permanent. Retry is the second choice; see the module docs
74/// for what must never be routed through it.
75pub mod retry;
76/// Safe absolute-path resolution for external binaries.
77mod safe_bin;
78mod source;
79mod spec;
80/// Bounded reads and atomic durable writes for on-disk KeyHog state artifacts.
81pub mod state_file;
82/// Shared paired performance statistics used by release gates and routing evidence.
83pub mod timing;
84/// Versioned redacted triage contracts and derived feedback artifacts.
85pub mod triage;
86/// Verification-domain policy shared by detector validation and the network
87/// verifier.
88pub mod verification_domain;
89pub mod winpath;
90use std::borrow::Cow;
91
92pub use api::*;
93pub use detector_corpus::{
94 compose_detector_corpus, compute_detector_corpus_digest,
95 compute_detector_corpus_digest_for_schema, DetectorCorpusError, DetectorCorpusMode,
96};
97/// Auto-fix suggestion logic for SARIF output.
98mod auto_fix;
99/// Bayesian confidence calibration for detectors.
100mod calibration;
101/// Incremental scan state via BLAKE3 Merkle index.
102mod merkle_index;
103mod merkle_spec_hash;
104/// Finding suppression rules and filters.
105pub mod suppression;
106
107// Embedded detectors compiled into the binary at build time.
108// These are used when no external detectors directory is found.
109mod embedded {
110 include!(concat!(env!("OUT_DIR"), "/embedded_detectors.rs"));
111}
112
113/// Load detectors from embedded data (compiled into the binary).
114/// Returns detector TOML strings that can be parsed by the spec loader.
115pub(crate) fn embedded_detector_tomls() -> &'static [(&'static str, &'static str)] {
116 embedded::EMBEDDED_DETECTORS
117}
118
119/// Sorted detector identities generated from the same embedded TOML corpus.
120pub(crate) fn embedded_detector_ids() -> &'static [&'static str] {
121 embedded::EMBEDDED_DETECTOR_IDS
122}
123
124/// Number of embedded detector specs (authoritative for banners and tests).
125#[inline]
126pub fn embedded_detector_count() -> usize {
127 embedded_detector_tomls().len()
128}
129
130/// Age (seconds) after which an abandoned `*.tmp` working file is considered
131/// stale and safe to remove. ONE owner for the tmp-file hygiene policy shared by
132/// `calibration` (calibration cache) and `merkle_index::tmp_hygiene` (index
133/// build), both used to define their own `60 * 60` const, which could silently
134/// drift apart.
135pub(crate) const STALE_TMP_CUTOFF_SECS: u64 = 60 * 60;
136
137/// The `keyhog` path component under the OS cache root. ONE owner for the
138/// cache-root segment shared by the calibration cache, the merkle index, and the
139/// lockdown past-findings gate, a rename here moves all three together so the
140/// lockdown scan can never desynchronize from where scan artifacts actually land.
141pub(crate) const KEYHOG_CACHE_SUBDIR: &str = "keyhog";
142/// Subdirectory of [`KEYHOG_CACHE_SUBDIR`] holding signed execution packs.
143/// Contains compiled detector packs and a signing key only, never findings,
144/// so lockdown treats it as clean.
145pub const EXECUTION_PACKS_SUBDIR: &str = "execution-packs";
146/// Sibling of [`KEYHOG_CACHE_SUBDIR`] used for MatcherArtifact `.khm` files.
147pub const KEYHOG_MATCHER_ARTIFACTS_SUBDIR: &str = "keyhog-matcher-artifacts";
148/// On-disk magic for MatcherArtifact cache files (`KHMA`).
149pub const MATCHER_ARTIFACT_MAGIC: &[u8; 4] = b"KHMA";
150/// Filename prefix for MatcherArtifact cache files (`matcher-<hex>.khm`).
151pub const MATCHER_ARTIFACT_FILENAME_PREFIX: &str = "matcher-";
152/// Filename suffix for MatcherArtifact cache files.
153pub const MATCHER_ARTIFACT_SUFFIX: &str = ".khm";
154/// MatcherArtifact on-disk envelope version shared with lockdown header checks.
155pub const MATCHER_ARTIFACT_FORMAT_VERSION: u32 = 4;
156
157/// Absolute path of keyhog's per-user cache root (`<os-cache>/keyhog`), or
158/// `None` when the platform exposes no cache directory.
159pub(crate) fn keyhog_cache_root() -> Option<std::path::PathBuf> {
160 dirs::cache_dir().map(|dir| dir.join(KEYHOG_CACHE_SUBDIR))
161}
162
163/// Absolute path of the MatcherArtifact cache root
164/// (`<os-cache>/keyhog-matcher-artifacts`), or `None` when the platform exposes
165/// no cache directory.
166pub fn keyhog_matcher_artifacts_root() -> Option<std::path::PathBuf> {
167 dirs::cache_dir().map(|dir| dir.join(KEYHOG_MATCHER_ARTIFACTS_SUBDIR))
168}
169
170/// Global atomic counter tracking the number of times the embedded detector TOML corpus has been parsed.
171static DETECTOR_CORPUS_LOAD_COUNT: std::sync::atomic::AtomicUsize =
172 std::sync::atomic::AtomicUsize::new(0);
173
174/// Return the number of times the embedded detector TOML corpus has been parsed.
175#[inline]
176pub fn detector_corpus_load_count() -> usize {
177 DETECTOR_CORPUS_LOAD_COUNT.load(std::sync::atomic::Ordering::Relaxed)
178}
179
180/// Reset the detector corpus load count for testing assertions.
181#[doc(hidden)]
182pub fn reset_detector_corpus_load_count_for_test() {
183 DETECTOR_CORPUS_LOAD_COUNT.store(0, std::sync::atomic::Ordering::Relaxed);
184}
185
186/// Parse the embedded detector corpus, FAILING CLOSED on any malformed TOML.
187///
188/// This is the SINGLE loader every entrypoint shares (the `scan` orchestrator
189/// via `cli::orchestrator_config`, and every other scan entry point) so the
190/// fail-closed contract holds uniformly, there is exactly one way to turn the
191/// compiled-in corpus into `DetectorSpec`s.
192///
193/// Law 10 (NO SILENT FALLBACKS): the embedded set is baked into the binary by
194/// `build.rs`; a TOML that fails to parse is a BUILD/SOURCE bug, never a runtime
195/// condition the operator can act on (the user cannot have edited a compiled-in
196/// string). The old per-callsite `tracing::debug!`-then-`continue` shape silently
197/// dropped the offender, exactly how the dead `discord-bot-token` detector (a
198/// single-quoted TOML literal that broke parsing) reached a benched release as an
199/// invisible recall hole. So this collects every offender and returns
200/// [`SpecError::EmbeddedCorpusCorrupt`] naming each, making a corrupt corpus a
201/// hard error rather than a buried log line. Each embedded TOML holds exactly one
202/// detector, so on success `result.len() == embedded_detector_count()`.
203pub fn load_embedded_detectors_or_fail() -> Result<Vec<DetectorSpec>, SpecError> {
204 DETECTOR_CORPUS_LOAD_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
205 let embedded = embedded_detector_tomls();
206 let mut detectors = Vec::with_capacity(embedded.len());
207 let mut failed = Vec::new();
208 for (name, toml_content) in embedded {
209 match parse_embedded_detector(name, toml_content) {
210 Ok(detector) => detectors.push(detector),
211 Err(error) => failed.push(error),
212 }
213 }
214 if !failed.is_empty() {
215 let detail = failed
216 .iter()
217 .map(|error| format!(" - {error}"))
218 .collect::<Vec<_>>()
219 .join("\n");
220 return Err(SpecError::EmbeddedCorpusCorrupt {
221 failed_count: failed.len(),
222 total: embedded.len(),
223 detail,
224 });
225 }
226 Ok(detectors)
227}
228
229fn parse_embedded_detector(name: &str, toml_content: &str) -> Result<DetectorSpec, String> {
230 let file =
231 toml::from_str::<DetectorFile>(toml_content).map_err(|error| format!("{name}: {error}"))?;
232 let errors: Vec<String> = spec::validate_detector_for_corpus_schema(
233 &file.detector,
234 spec::DETECTOR_CORPUS_SCHEMA_VERSION,
235 )
236 .into_iter()
237 .filter_map(|issue| match issue {
238 spec::QualityIssue::Error(error) => Some(error),
239 spec::QualityIssue::Warning(_) => None,
240 })
241 .collect();
242 if errors.is_empty() {
243 Ok(file.detector)
244 } else {
245 Err(format!(
246 "{name}: detector quality gate rejected the embedded spec: {}",
247 errors.join("; ")
248 ))
249 }
250}
251
252/// Every embedded detector spec, parsed EXACTLY ONCE for the whole process.
253///
254/// This is the single materialization of the compiled-in corpus: both the
255/// id-keyed lookup ([`detector_spec_by_id`]) and whole-corpus consumers (the ML
256/// service-vocabulary derivation in `keyhog-scanner`, registry audits) borrow
257/// from this one `Vec` instead of re-running [`load_embedded_detectors_or_fail`]
258/// and holding their own copy of every spec. Fails closed on a corrupt embedded
259/// corpus: a bundled TOML that will not parse is a build/source defect, never a
260/// silent empty set (Law 10).
261#[allow(clippy::panic)] // Corrupt compiled-in detectors must stop startup.
262pub fn embedded_detector_specs() -> &'static [DetectorSpec] {
263 static SPECS: std::sync::LazyLock<Vec<DetectorSpec>> =
264 std::sync::LazyLock::new(|| match load_embedded_detectors_or_fail() {
265 Ok(specs) => specs,
266 Err(error) => panic!(
267 "embedded detector corpus failed to load: {error}. The detector \
268 specifications live in the bundled TOMLs; refusing to run without them."
269 ),
270 });
271 &SPECS
272}
273
274/// Suffix attached to findings reconstructed from bounded source fragments.
275pub const REASSEMBLED_DETECTOR_SUFFIX: &str = ":reassembled";
276
277/// Canonical `id → DetectorSpec` lookup over the embedded corpus, built EXACTLY
278/// ONCE.
279///
280/// This is the single owner every "give me the spec for detector id X" consumer
281/// shares (entropy plausibility gates, entropy-scanner resolution, adjudication
282/// confidence/length floors). Before this, three separate
283/// `LazyLock<HashMap<String, DetectorSpec>>` statics each called
284/// [`load_embedded_detectors_or_fail`] and rebuilt an identical map, the
285/// compiled-in corpus was parsed three times at startup (Law 7) and the same
286/// lookup lived in three places (ONE PLACE). The map borrows from
287/// [`embedded_detector_specs`] (the one materialized corpus) rather than holding
288/// a second by-value copy of every spec. Fails closed on a corrupt embedded
289/// corpus, matching every prior consumer's contract.
290pub fn detector_spec_by_id(id: &str) -> Option<&'static DetectorSpec> {
291 static BY_ID: std::sync::LazyLock<
292 std::collections::HashMap<&'static str, &'static DetectorSpec, ahash::RandomState>,
293 > = std::sync::LazyLock::new(|| {
294 let specs = embedded_detector_specs();
295 let mut map = std::collections::HashMap::with_capacity_and_hasher(
296 specs.len(),
297 ahash::RandomState::new(),
298 );
299 for spec in specs {
300 map.insert(spec.id.as_str(), spec);
301 }
302 map
303 });
304 BY_ID.get(id).copied()
305}
306
307/// Git commit SHA the binary was built from, or `"unknown"` for a build with no
308/// reachable `.git` tree (e.g. a `cargo package` / crates.io build). Stamped by
309/// `build.rs` via `cargo:rustc-env=GIT_HASH`; `env!` resolves here because the
310/// rustc-env applies to THIS crate's compilation. Surfaced in `keyhog --version`
311/// and meant to be embedded in every result so a scan traces back to an exact
312/// commit (MC-06: the false "F1 regression" was a stale binary benched against
313/// HEAD, undetectable while every build reported the same empty version).
314#[inline]
315pub fn git_hash() -> &'static str {
316 env!("GIT_HASH")
317}
318
319/// Path of the currently running executable.
320pub fn current_executable_path() -> Result<std::path::PathBuf, String> {
321 std::env::current_exe().map_err(|error| format!("locate running executable: {error}"))
322}
323
324/// SHA-256 hex digest of the currently running executable, memoized once per process.
325///
326/// Shared by MatcherArtifact identity and autoroute calibration so a scan does
327/// not read and hash the binary twice.
328pub fn current_executable_sha256() -> Result<String, String> {
329 use sha2::{Digest, Sha256};
330 use std::io::Read;
331 use std::sync::LazyLock;
332 static DIGEST: LazyLock<Result<String, String>> = LazyLock::new(|| {
333 let path = current_executable_path()?;
334 let mut file = std::fs::File::open(&path).map_err(|error| {
335 format!(
336 "open running executable {} for identity: {error}",
337 path.display()
338 )
339 })?;
340 let mut hasher = Sha256::new();
341 let mut buffer = [0u8; 128 * 1024];
342 loop {
343 let read = file.read(&mut buffer).map_err(|error| {
344 format!(
345 "read running executable {} for identity: {error}",
346 path.display()
347 )
348 })?;
349 if read == 0 {
350 break;
351 }
352 hasher.update(&buffer[..read]);
353 }
354 Ok(format!("{:x}", hasher.finalize()))
355 });
356 DIGEST.clone()
357}
358
359/// Effective digest identifying the EXACT embedded detector set and the
360/// directory-scoped `corpus.toml` schema contract compiled into this binary
361/// (`<detector-count>-<fnv1a_hex>`). Binding the manifest ensures caches,
362/// benchmarks, and autoroute evidence invalidate when parsing compatibility
363/// semantics change even if detector TOML bytes do not. Stamped by `build.rs`
364/// via `cargo:rustc-env=KEYHOG_DETECTOR_DIGEST`, this is the authoritative
365/// answer to "which effective corpus was compiled in" when cargo's
366/// `rerun-if-changed` cannot be trusted across in-place TOML edits.
367#[inline]
368pub fn detector_digest() -> &'static str {
369 env!("KEYHOG_DETECTOR_DIGEST")
370}
371
372/// Redact a sensitive credential string for safe display.
373pub fn redact(s: &str) -> Cow<'static, str> {
374 // ASCII fast path: byte indexing is valid (no UTF-8 boundary risk),
375 // skips the O(n) `chars().count()` walk plus two intermediate `String`
376 // allocations from `take(4).collect()` / `skip(n).collect()`. Most
377 // credentials are pure ASCII (provider keys, hashes, base64 tokens).
378 if s.is_ascii() {
379 if s.len() <= 8 {
380 return Cow::Borrowed("****");
381 }
382 let edge = redaction_edge_len(s.len());
383 let mut out = String::with_capacity((edge * 2) + 3);
384 out.push_str(&s[..edge]);
385 out.push_str("...");
386 out.push_str(&s[s.len() - edge..]);
387 return Cow::Owned(out);
388 }
389 // UTF-8 path: char-count for grapheme correctness.
390 let char_count = s.chars().count();
391 if char_count <= 8 {
392 return Cow::Borrowed("****");
393 }
394 let edge = redaction_edge_len(char_count);
395 let prefix: String = s.chars().take(edge).collect();
396 let suffix: String = s.chars().skip(char_count.saturating_sub(edge)).collect();
397 Cow::Owned(format!("{prefix}...{suffix}"))
398}
399
400fn redaction_edge_len(char_count: usize) -> usize {
401 (char_count / 8).clamp(1, 4)
402}
403
404#[doc(hidden)]
405pub mod testing;