Skip to main content

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