use crate::error::{RuntimeError, RuntimeResult};
mod bridge_fragments;
mod credential_triggers;
mod entropy;
mod false_positive_filters;
mod known_patterns;
mod masking;
mod submitted_atom_digest;
use bridge_fragments::{
bridge_fragment_chain, contains_bounded_word, contains_word, is_bridge_fragment_shape,
trailing_bridge_fragment_cut,
};
#[cfg(test)]
use credential_triggers::LOOKUP_KEY_LABELS;
use credential_triggers::{
assignment_credential_trigger, build_match, extract_token, find_trigger,
inline_credential_trigger, is_assignment_label_gap, is_base64_content_hash,
is_lookup_key_label, is_pure_hex, is_structured_identifier, is_uuid_canonical, shannon_entropy,
strip_delimiters, value_candidates, wrapper_strip_repeated,
};
#[cfg(test)]
use entropy::TRIGGER_WINDOW;
use entropy::{
check_entropy_heuristic, tokenize_entropy_tokens, EntropyScanContext, COMPOUND_TRIGGER_WORDS,
ENTROPY_THRESHOLD, HEX_CREDENTIAL_LENGTHS, MAX_BRIDGE_FRAGMENTS, MAX_BRIDGE_GLUE_TOKENS,
MIN_BRIDGE_FRAGMENT_LEN, MIN_ENTROPY_LEN, TRIGGER_WORDS,
};
use false_positive_filters::{
after_last_sentence_boundary, before_first_sentence_boundary,
has_clause_credential_label_with_inline, has_direct_repository_credential_label_with_inline,
has_immediate_credential_label, is_aws_resource_name, is_environment_name,
is_git_revision_reference, is_labeled_sha256_digest, is_latex_fragment_without_credential_run,
is_latex_prose_macro, is_plausible_file_path, is_prose_code_reference,
is_repository_revision_reference, is_vcs_marker_before_hex, normalized_hex_credential_span,
ClauseValueKind,
};
#[cfg(test)]
use false_positive_filters::{is_clause_narrative_gerund, is_clause_narrative_participle};
use known_patterns::check_known_patterns;
#[cfg(doc)]
use known_patterns::find_url_userinfo;
#[cfg(test)]
use known_patterns::{find_prefix_token, is_filename_shaped_prefix_match, PREFIX_DETECTORS};
pub use masking::{
bounded_masked_log_text, mask_bounded, mask_secrets, BoundedMask, MASK_WINDOW_CHARS,
};
#[cfg(test)]
use masking::{collect_mask_spans, MAX_LOG_TEXT_OUTPUT_CHARS, TRUNCATION_MARKER};
use masking::{extend_across_invisible_bridge, MAX_LOG_TEXT_MASK_INPUT_CHARS};
pub use submitted_atom_digest::masked_submitted_atom_digest_v1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretMatch {
pub detector: &'static str,
pub trigger: Option<&'static str>,
pub masked: String,
pub location: Option<String>,
}
impl std::fmt::Display for SecretMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "content matches secret pattern {}", self.detector)?;
if let Some(trigger) = self.trigger {
write!(f, " near '{trigger}'")?;
}
if let Some(location) = &self.location {
write!(f, " in {location}")?;
}
write!(f, ". {}", block_guidance(self.detector))
}
}
fn block_guidance(detector: &'static str) -> &'static str {
match detector {
"url-userinfo" => {
"Placeholders in URL credential positions still match this pattern. \
Replace the whole URL with an environment-variable name or config key, \
or remove the entire user/password segment before writing."
}
"high-entropy-token"
| "uuid-near-trigger"
| "content-hash-near-trigger"
| "hex-credential-token" => {
"If this is a real credential, remove it before writing. If it is not \
(e.g. a file path, UUID, or hash that happens to sit near a word like \
key/secret/auth/token), put the candidate in a separate sentence or \
paragraph from those words, or express a source hash as an explicit \
commit/revision reference."
}
_ => {
"If this is a real credential, remove it before writing; store secrets \
in an env var or secrets manager instead."
}
}
}
pub fn check(content: &str) -> RuntimeResult<()> {
if let Some(m) = scan(content) {
return Err(RuntimeError::SecretDetected(m));
}
Ok(())
}
pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
scan_json_value(value)
}
pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
for tag in tags {
check(tag)?;
}
Ok(())
}
pub fn locate<T>(result: RuntimeResult<T>, record: &str, field: &str) -> RuntimeResult<T> {
result.map_err(|error| match error {
RuntimeError::SecretDetected(matched) => RuntimeError::SecretDetected(SecretMatch {
location: Some(format!("{record}.{field}")),
..matched
}),
other => other,
})
}
pub fn check_at(content: &str, record: &str, field: &str) -> RuntimeResult<()> {
locate(check(content), record, field)
}
pub fn check_json_at(value: &serde_json::Value, record: &str, field: &str) -> RuntimeResult<()> {
locate(check_json(value), record, field)
}
pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> {
locate(check_tags(tags), record, field)
}
pub const RESERVED_SECRET_GATE_KEY: &str = "khive:secret_gate";
pub const RESERVED_WEB_RECEIPT_KEY: &str = "khive:web_receipt";
pub const WEB_RECEIPT_PROVENANCE_VALUE: &str = "v1";
pub fn reject_reserved_secret_gate_property(
properties: Option<&serde_json::Value>,
) -> RuntimeResult<()> {
if let Some(serde_json::Value::Object(map)) = properties {
if map.contains_key(RESERVED_SECRET_GATE_KEY) {
return Err(RuntimeError::InvalidInput(format!(
"property key `{RESERVED_SECRET_GATE_KEY}` is runtime-owned and cannot be \
created, replaced, merged, or removed by callers"
)));
}
if map.contains_key(RESERVED_WEB_RECEIPT_KEY) {
return Err(RuntimeError::InvalidInput(format!(
"property key `{RESERVED_WEB_RECEIPT_KEY}` is web-pack-owned and cannot be \
created, replaced, merged, or removed by callers"
)));
}
}
Ok(())
}
fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
match value {
serde_json::Value::String(s) => check(s),
serde_json::Value::Array(arr) => {
for v in arr {
scan_json_value(v)?;
}
Ok(())
}
serde_json::Value::Object(map) => {
for (k, v) in map {
check(k)?;
scan_json_value(v)?;
}
Ok(())
}
_ => Ok(()),
}
}
const REDACTION_MARKER: &str = "***MASKED***";
const MAX_MASK_SCAN_WORK_BYTES: usize = MAX_LOG_TEXT_MASK_INPUT_CHARS * 2;
#[cfg(test)]
thread_local! {
static ENTROPY_TOKENIZATION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn scan_match(text: &str) -> Option<(&str, &'static str)> {
let context = EntropyScanContext::new(text);
scan_from(text, 0, &context)
}
fn scan_from<'a>(
text: &'a str,
from: usize,
context: &EntropyScanContext<'a>,
) -> Option<(&'a str, &'static str)> {
scan_from_with_trigger(text, from, context).map(|(slice, detector, _)| (slice, detector))
}
fn scan_from_with_trigger<'a>(
text: &'a str,
from: usize,
context: &EntropyScanContext<'a>,
) -> Option<(&'a str, &'static str, Option<&'static str>)> {
let mut best =
check_known_patterns(&text[from..]).map(|(slice, detector)| (slice, detector, None));
if let Some(candidate) = check_entropy_heuristic(text, from, context) {
if best
.as_ref()
.is_none_or(|current| candidate.0.as_ptr() < current.0.as_ptr())
{
best = Some(candidate);
}
}
best
}
fn keep_leftmost<'a>(
best: &mut Option<(&'a str, &'static str)>,
cand: Option<(&'a str, &'static str)>,
base: usize,
) {
if let Some((slice, name)) = cand {
let start = slice.as_ptr() as usize - base;
let replace = match *best {
Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
None => true,
};
if replace {
*best = Some((slice, name));
}
}
}
fn scan(text: &str) -> Option<SecretMatch> {
let context = EntropyScanContext::new(text);
scan_from_with_trigger(text, 0, &context).map(|(slice, detector, trigger)| {
let mut matched = build_match(detector, slice);
matched.trigger = trigger;
matched
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedactionSurface {
GitIngest,
SessionMirror,
McpDiagnostic,
GateProbe,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedactionSurfaceMode {
PermanentMaskOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedactionSurfaceContract {
pub mode: RedactionSurfaceMode,
pub final_stored_target: Option<&'static str>,
pub stamp_property: Option<&'static str>,
pub atomic_success_event: Option<&'static str>,
}
pub const GIT_INGEST_STORED_TARGET: &str = "final git-ingest entity/note fields";
pub const SESSION_MIRROR_STORED_TARGET: &str =
"session_messages.text, session_messages.raw, sessions.cwd, sessions.git_branch, \
and sessions.slug";
pub const fn redaction_surface_contract(surface: RedactionSurface) -> RedactionSurfaceContract {
let final_stored_target = match surface {
RedactionSurface::GitIngest => Some(GIT_INGEST_STORED_TARGET),
RedactionSurface::SessionMirror => Some(SESSION_MIRROR_STORED_TARGET),
RedactionSurface::McpDiagnostic | RedactionSurface::GateProbe => None,
};
RedactionSurfaceContract {
mode: RedactionSurfaceMode::PermanentMaskOnly,
final_stored_target,
stamp_property: None,
atomic_success_event: None,
}
}
pub fn mask_for_redaction_surface(
surface: RedactionSurface,
text: &str,
) -> std::borrow::Cow<'_, str> {
match redaction_surface_contract(surface).mode {
RedactionSurfaceMode::PermanentMaskOnly => mask_secrets(text),
}
}
#[cfg(test)]
#[path = "secret_gate/issue_2655_tests.rs"]
mod issue_2655_tests;
#[cfg(test)]
#[path = "secret_gate_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "secret_gate/corpus_replay_tests.rs"]
mod corpus_replay;