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#![warn(missing_docs)]
12#![cfg_attr(
13    not(test),
14    deny(
15        clippy::unwrap_used,
16        clippy::expect_used,
17        clippy::todo,
18        clippy::unimplemented,
19        clippy::panic
20    )
21)]
22#![allow(
23    clippy::module_name_repetitions,
24    clippy::must_use_candidate,
25    clippy::missing_errors_doc,
26    clippy::pedantic
27)]
28
29//! Core types shared across all KeyHog crates.
30mod allowlist;
31mod api;
32pub mod ascii_ci;
33/// Offline AWS account-ID decode + canary-token classification (single source
34/// of truth shared by the scanner's finding metadata and the verifier's
35/// suppress-live-verification-for-canaries gate).
36mod aws;
37/// Configuration system for KeyHog scanning options.
38mod config;
39/// Secure credential storage and redaction.
40mod credential;
41mod dedup;
42mod detector_file_io;
43mod display;
44/// Shared standard Base64 decode (wire / K8s), bounded for DoS safety.
45mod encoding;
46mod finding;
47/// Git-LFS pointer recognition, shared by the scanner (oid suppression) and
48/// sources (unscanned-blob coverage gap).
49pub mod git_lfs;
50/// Security hardening: memory zeroization and process isolation helpers.
51mod hardening;
52mod hyperscan_cache;
53/// Detector verification response selector grammar and evaluator.
54pub mod json_selector;
55/// Structured reporting (JSON, SARIF, Text).
56mod report;
57/// Safe absolute-path resolution for external binaries.
58mod safe_bin;
59mod source;
60mod spec;
61mod state_file;
62/// Shared paired performance statistics used by release gates and routing evidence.
63pub mod timing;
64/// Verification-domain policy shared by detector validation and the network
65/// verifier.
66pub mod verification_domain;
67pub mod winpath;
68use std::borrow::Cow;
69
70pub use api::*;
71/// Auto-fix suggestion logic for SARIF output.
72mod auto_fix;
73/// Bayesian confidence calibration for detectors.
74mod calibration;
75/// Incremental scan state via BLAKE3 Merkle index.
76mod merkle_index;
77mod merkle_spec_hash;
78/// Declarative `.keyhogignore.toml` rule-based finding suppression.
79/// Wraps VYRE's CPU rule evaluator with a TOML schema scoped to
80/// keyhog's finding shape (detector / service / severity / path /
81/// credential_hash predicates).
82mod rule_filter;
83
84// Embedded detectors compiled into the binary at build time.
85// These are used when no external detectors directory is found.
86mod embedded {
87    include!(concat!(env!("OUT_DIR"), "/embedded_detectors.rs"));
88}
89
90/// Load detectors from embedded data (compiled into the binary).
91/// Returns detector TOML strings that can be parsed by the spec loader.
92pub(crate) fn embedded_detector_tomls() -> &'static [(&'static str, &'static str)] {
93    embedded::EMBEDDED_DETECTORS
94}
95
96/// Number of embedded detector specs (authoritative for banners and tests).
97#[inline]
98pub fn embedded_detector_count() -> usize {
99    embedded_detector_tomls().len()
100}
101
102/// Age (seconds) after which an abandoned `*.tmp` working file is considered
103/// stale and safe to remove. ONE owner for the tmp-file hygiene policy shared by
104/// `calibration` (calibration cache) and `merkle_index::tmp_hygiene` (index
105/// build), both used to define their own `60 * 60` const, which could silently
106/// drift apart.
107pub(crate) const STALE_TMP_CUTOFF_SECS: u64 = 60 * 60;
108
109/// The `keyhog` path component under the OS cache root. ONE owner for the
110/// cache-root segment shared by the calibration cache, the merkle index, and the
111/// lockdown past-findings gate, a rename here moves all three together so the
112/// lockdown scan can never desynchronize from where scan artifacts actually land.
113pub(crate) const KEYHOG_CACHE_SUBDIR: &str = "keyhog";
114
115/// Absolute path of keyhog's per-user cache root (`<os-cache>/keyhog`), or
116/// `None` when the platform exposes no cache directory.
117pub(crate) fn keyhog_cache_root() -> Option<std::path::PathBuf> {
118    dirs::cache_dir().map(|dir| dir.join(KEYHOG_CACHE_SUBDIR))
119}
120
121/// Parse the embedded detector corpus, FAILING CLOSED on any malformed TOML.
122///
123/// This is the SINGLE loader every entrypoint shares (the `scan` orchestrator
124/// via `cli::orchestrator_config`, and every other scan entry point) so the
125/// fail-closed contract holds uniformly, there is exactly one way to turn the
126/// compiled-in corpus into `DetectorSpec`s.
127///
128/// Law 10 (NO SILENT FALLBACKS): the embedded set is baked into the binary by
129/// `build.rs`; a TOML that fails to parse is a BUILD/SOURCE bug, never a runtime
130/// condition the operator can act on (the user cannot have edited a compiled-in
131/// string). The old per-callsite `tracing::debug!`-then-`continue` shape silently
132/// dropped the offender, exactly how the dead `discord-bot-token` detector (a
133/// single-quoted TOML literal that broke parsing) reached a benched release as an
134/// invisible recall hole. So this collects every offender and returns
135/// [`SpecError::EmbeddedCorpusCorrupt`] naming each, making a corrupt corpus a
136/// hard error rather than a buried log line. Each embedded TOML holds exactly one
137/// detector, so on success `result.len() == embedded_detector_count()`.
138pub fn load_embedded_detectors_or_fail() -> Result<Vec<DetectorSpec>, SpecError> {
139    let embedded = embedded_detector_tomls();
140    let mut detectors = Vec::with_capacity(embedded.len());
141    let mut failed = Vec::new();
142    for (name, toml_content) in embedded {
143        match parse_embedded_detector(name, toml_content) {
144            Ok(detector) => detectors.push(detector),
145            Err(error) => failed.push(error),
146        }
147    }
148    if !failed.is_empty() {
149        let detail = failed
150            .iter()
151            .map(|error| format!("  - {error}"))
152            .collect::<Vec<_>>()
153            .join("\n");
154        return Err(SpecError::EmbeddedCorpusCorrupt {
155            failed_count: failed.len(),
156            total: embedded.len(),
157            detail,
158        });
159    }
160    Ok(detectors)
161}
162
163fn parse_embedded_detector(name: &str, toml_content: &str) -> Result<DetectorSpec, String> {
164    let file =
165        toml::from_str::<DetectorFile>(toml_content).map_err(|error| format!("{name}: {error}"))?;
166    let errors: Vec<String> = spec::validate_detector(&file.detector)
167        .into_iter()
168        .filter_map(|issue| match issue {
169            spec::QualityIssue::Error(error) => Some(error),
170            spec::QualityIssue::Warning(_) => None,
171        })
172        .collect();
173    if errors.is_empty() {
174        Ok(file.detector)
175    } else {
176        Err(format!(
177            "{name}: detector quality gate rejected the embedded spec: {}",
178            errors.join("; ")
179        ))
180    }
181}
182
183/// Every embedded detector spec, parsed EXACTLY ONCE for the whole process.
184///
185/// This is the single materialization of the compiled-in corpus: both the
186/// id-keyed lookup ([`detector_spec_by_id`]) and whole-corpus consumers (the ML
187/// service-vocabulary derivation in `keyhog-scanner`, registry audits) borrow
188/// from this one `Vec` instead of re-running [`load_embedded_detectors_or_fail`]
189/// and holding their own copy of every spec. Fails closed on a corrupt embedded
190/// corpus: a bundled TOML that will not parse is a build/source defect, never a
191/// silent empty set (Law 10).
192pub fn embedded_detector_specs() -> &'static [DetectorSpec] {
193    static SPECS: std::sync::LazyLock<Vec<DetectorSpec>> =
194        std::sync::LazyLock::new(|| match load_embedded_detectors_or_fail() {
195            Ok(specs) => specs,
196            Err(error) => panic!(
197                "embedded detector corpus failed to load: {error}. The detector \
198                 specifications live in the bundled TOMLs; refusing to run without them."
199            ),
200        });
201    &SPECS
202}
203
204/// Canonical `id → DetectorSpec` lookup over the embedded corpus, built EXACTLY
205/// ONCE.
206///
207/// This is the single owner every "give me the spec for detector id X" consumer
208/// shares (entropy plausibility gates, entropy-scanner resolution, adjudication
209/// confidence/length floors). Before this, three separate
210/// `LazyLock<HashMap<String, DetectorSpec>>` statics each called
211/// [`load_embedded_detectors_or_fail`] and rebuilt an identical map, the
212/// compiled-in corpus was parsed three times at startup (Law 7) and the same
213/// lookup lived in three places (ONE PLACE). The map borrows from
214/// [`embedded_detector_specs`] (the one materialized corpus) rather than holding
215/// a second by-value copy of every spec. Fails closed on a corrupt embedded
216/// corpus, matching every prior consumer's contract.
217pub fn detector_spec_by_id(id: &str) -> Option<&'static DetectorSpec> {
218    static BY_ID: std::sync::LazyLock<
219        std::collections::HashMap<&'static str, &'static DetectorSpec>,
220    > = std::sync::LazyLock::new(|| {
221        embedded_detector_specs()
222            .iter()
223            .map(|spec| (spec.id.as_str(), spec))
224            .collect()
225    });
226    BY_ID.get(id).copied()
227}
228
229/// Git commit SHA the binary was built from, or `"unknown"` for a build with no
230/// reachable `.git` tree (e.g. a `cargo package` / crates.io build). Stamped by
231/// `build.rs` via `cargo:rustc-env=GIT_HASH`; `env!` resolves here because the
232/// rustc-env applies to THIS crate's compilation. Surfaced in `keyhog --version`
233/// and meant to be embedded in every result so a scan traces back to an exact
234/// commit (MC-06: the false "F1 regression" was a stale binary benched against
235/// HEAD, undetectable while every build reported the same empty version).
236#[inline]
237pub fn git_hash() -> &'static str {
238    env!("GIT_HASH")
239}
240
241/// Digest identifying the EXACT embedded detector set compiled into this binary
242/// (`<count>-<fnv1a_hex>`). Stamped by `build.rs` via
243/// `cargo:rustc-env=KEYHOG_DETECTOR_DIGEST`. Lets the benchmark and `--version`
244/// assert the running binary's detectors match the on-disk `detectors/` tree
245/// the authoritative answer to "what got compiled in" when cargo's
246/// `rerun-if-changed` can't be trusted across in-place TOML edits.
247#[inline]
248pub fn detector_digest() -> &'static str {
249    env!("KEYHOG_DETECTOR_DIGEST")
250}
251
252/// Redact a sensitive credential string for safe display.
253pub fn redact(s: &str) -> Cow<'static, str> {
254    // ASCII fast path: byte indexing is valid (no UTF-8 boundary risk),
255    // skips the O(n) `chars().count()` walk plus two intermediate `String`
256    // allocations from `take(4).collect()` / `skip(n).collect()`. Most
257    // credentials are pure ASCII (provider keys, hashes, base64 tokens).
258    if s.is_ascii() {
259        if s.len() <= 8 {
260            return Cow::Borrowed("****");
261        }
262        let edge = redaction_edge_len(s.len());
263        let mut out = String::with_capacity((edge * 2) + 3);
264        out.push_str(&s[..edge]);
265        out.push_str("...");
266        out.push_str(&s[s.len() - edge..]);
267        return Cow::Owned(out);
268    }
269    // UTF-8 path: char-count for grapheme correctness.
270    let char_count = s.chars().count();
271    if char_count <= 8 {
272        return Cow::Borrowed("****");
273    }
274    let edge = redaction_edge_len(char_count);
275    let prefix: String = s.chars().take(edge).collect();
276    let suffix: String = s.chars().skip(char_count.saturating_sub(edge)).collect();
277    Cow::Owned(format!("{prefix}...{suffix}"))
278}
279
280fn redaction_edge_len(char_count: usize) -> usize {
281    (char_count / 8).clamp(1, 4)
282}
283
284#[doc(hidden)]
285pub mod testing;