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