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;