use keyhog_core::DetectorSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Mechanism {
Regex,
Keywords,
Structure,
Entropy,
Bpe,
BytePairLikelihood,
Decode,
Companions,
DetectorRelations,
Verification,
Suppression,
SourceAdmission,
}
impl Mechanism {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Regex => "regex",
Self::Keywords => "keywords",
Self::Structure => "structure",
Self::Entropy => "entropy",
Self::Bpe => "bpe",
Self::BytePairLikelihood => "byte_pair_likelihood",
Self::Decode => "decode",
Self::Companions => "companions",
Self::DetectorRelations => "detector_relations",
Self::Verification => "verification",
Self::Suppression => "suppression",
Self::SourceAdmission => "source_admission",
}
}
pub(crate) fn describe(self) -> &'static str {
match self {
Self::Regex => "phase-1 pattern anchors",
Self::Keywords => "phase-2 keyword triggers for shapeless candidates",
Self::Structure => {
"offline structural proof: checksum, payload decode, or declared shape"
}
Self::Entropy => "detector-owned Shannon entropy floors",
Self::Bpe => "BPE token-efficiency precision gate",
Self::BytePairLikelihood => "fixed-point byte-pair log-likelihood scoring",
Self::Decode => "detector-declared evasion and transport decode recovery",
Self::Companions => "secondary patterns that confirm a match",
Self::DetectorRelations => "relations to findings from other detectors",
Self::Verification => "live verification against the provider",
Self::Suppression => {
"detector-owned allowlists, stopwords, and public-identifier markers"
}
Self::SourceAdmission => "positive source selectors gating where this detector fires",
}
}
pub(crate) const ALL: [Self; 12] = [
Self::Regex,
Self::Keywords,
Self::Structure,
Self::Entropy,
Self::Bpe,
Self::BytePairLikelihood,
Self::Decode,
Self::Companions,
Self::DetectorRelations,
Self::Verification,
Self::Suppression,
Self::SourceAdmission,
];
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
match self {
Self::BytePairLikelihood => Some(
"no detector field expresses this yet; the fixed-point byte-pair model \
is unbuilt (BACKLOG KH-850), so no detector can declare it and this \
row is structurally empty rather than measured",
),
_ => None,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct ActiveMechanism {
pub(crate) id: &'static str,
pub(crate) evidence: Vec<&'static str>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct DetectorMechanisms {
pub(crate) id: String,
pub(crate) service: String,
pub(crate) kind: &'static str,
pub(crate) mechanisms: Vec<ActiveMechanism>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct MechanismSummary {
pub(crate) id: &'static str,
pub(crate) description: &'static str,
pub(crate) available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) unavailable_reason: Option<&'static str>,
pub(crate) detectors: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct MechanismManifest {
pub(crate) schema_version: u16,
pub(crate) detector_count: usize,
pub(crate) corpus: String,
pub(crate) summary: Vec<MechanismSummary>,
pub(crate) detectors: Vec<DetectorMechanisms>,
}
const MANIFEST_SCHEMA_VERSION: u16 = 1;
fn mechanisms_for(detector: &DetectorSpec) -> Vec<ActiveMechanism> {
let mut out = Vec::new();
let mut push = |mechanism: Mechanism, evidence: Vec<&'static str>| {
if !evidence.is_empty() {
out.push(ActiveMechanism {
id: mechanism.as_str(),
evidence,
});
}
};
push(
Mechanism::Regex,
field(!detector.patterns.is_empty(), "patterns"),
);
push(
Mechanism::Keywords,
field(!detector.keywords.is_empty(), "keywords"),
);
let mut structure = Vec::new();
if !detector.validators.is_empty() {
structure.push("validators");
}
if detector.credential_shape.is_some() {
structure.push("credential_shape");
}
if !detector.entropy_shapes.is_empty() {
structure.push("entropy_shapes");
}
push(Mechanism::Structure, structure);
let mut entropy = Vec::new();
if !detector.entropy_floor.is_empty() {
entropy.push("entropy_floor");
}
if detector.entropy_high.is_some() {
entropy.push("entropy_high");
}
if detector.entropy_low.is_some() {
entropy.push("entropy_low");
}
if detector.entropy_very_high.is_some() {
entropy.push("entropy_very_high");
}
if !detector.entropy_roles.is_empty() {
entropy.push("entropy_roles");
}
push(Mechanism::Entropy, entropy);
let bpe = match detector.bpe_enabled {
Some(true) => vec!["bpe_enabled = true"],
Some(false) => vec!["bpe_enabled = false"],
None => detector
.bpe_max_bytes_per_token
.map(|_| vec!["bpe_max_bytes_per_token"])
.unwrap_or_default(), };
push(Mechanism::Bpe, bpe);
let mut decode = Vec::new();
if !detector.decode_transforms.reverse_prefixes.is_empty() {
decode.push("decode_transforms.reverse_prefixes");
}
if !detector.decode_transforms.caesar_prefixes.is_empty() {
decode.push("decode_transforms.caesar_prefixes");
}
if !detector.decoded_hex_key_material_lengths.is_empty() {
decode.push("decoded_hex_key_material_lengths");
}
if !detector.canonical_hex_key_material.is_empty() {
decode.push("canonical_hex_key_material");
}
push(Mechanism::Decode, decode);
push(
Mechanism::Companions,
field(!detector.companions.is_empty(), "companions"),
);
push(
Mechanism::DetectorRelations,
field(
!detector.detector_relations.is_empty(),
"detector_relations",
),
);
push(
Mechanism::Verification,
field(detector.verify.is_some(), "verify"),
);
let mut suppression = Vec::new();
if !detector.allowlist_paths.is_empty() {
suppression.push("allowlist_paths");
}
if !detector.allowlist_values.is_empty() {
suppression.push("allowlist_values");
}
if !detector.stopwords.is_empty() {
suppression.push("stopwords");
}
if !detector.public_identifier_assignment_markers.is_empty() {
suppression.push("public_identifier_assignment_markers");
}
push(Mechanism::Suppression, suppression);
let admission = &detector.source_admission;
let mut source = Vec::new();
if !admission.path_patterns.is_empty() {
source.push("source_admission.path_patterns");
}
if !admission.source_types.is_empty() {
source.push("source_admission.source_types");
}
if !admission.file_extensions.is_empty() {
source.push("source_admission.file_extensions");
}
push(Mechanism::SourceAdmission, source);
out
}
#[inline]
fn field(active: bool, name: &'static str) -> Vec<&'static str> {
if active {
vec![name]
} else {
Vec::new()
}
}
pub(crate) fn build(detectors: &[&DetectorSpec], corpus: String) -> MechanismManifest {
let mut rows: Vec<DetectorMechanisms> = detectors
.iter()
.map(|detector| DetectorMechanisms {
id: detector.id.clone(),
service: detector.service.clone(),
kind: match detector.kind {
keyhog_core::DetectorKind::Regex => "regex",
keyhog_core::DetectorKind::Phase2Generic => "phase2-generic",
},
mechanisms: mechanisms_for(detector),
})
.collect();
rows.sort_by(|a, b| a.id.cmp(&b.id));
let summary = Mechanism::ALL
.iter()
.map(|mechanism| {
let id = mechanism.as_str();
MechanismSummary {
id,
description: mechanism.describe(),
available: mechanism.unavailable_reason().is_none(),
unavailable_reason: mechanism.unavailable_reason(),
detectors: rows
.iter()
.filter(|row| row.mechanisms.iter().any(|active| active.id == id))
.count(),
}
})
.collect();
MechanismManifest {
schema_version: MANIFEST_SCHEMA_VERSION,
detector_count: rows.len(),
corpus,
summary,
detectors: rows,
}
}
pub(crate) fn render_text(manifest: &MechanismManifest, out: &mut String) {
use std::fmt::Write;
let _ = writeln!(
out,
"Mechanism manifest: {} detectors from {}",
manifest.detector_count,
manifest.corpus
);
let _ = writeln!(out); for row in &manifest.summary {
if row.available {
let _ = writeln!(
out,
" {:<22} {:>5} {}",
row.id,
row.detectors,
row.description
);
} else {
let _ = writeln!(
out,
" {:<22} {:>5} {} [UNAVAILABLE: {}]",
row.id,
"n/a",
row.description,
"see --format json for the reason"
);
}
}
let _ = writeln!(out);
let silent: Vec<&str> = manifest
.detectors
.iter()
.filter(|row| row.mechanisms.is_empty())
.map(|row| row.id.as_str())
.collect();
if silent.is_empty() {
let _ = writeln!(out, "Every detector declares at least one mechanism.");
} else {
let _ = writeln!(
out,
"{} detector(s) declare NO mechanism at all: {}",
silent.len(),
silent.join(", ")
);
}
}