1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! Detector-owned credential shape rules.
//!
//! The per-detector shape CONSTRAINT (`exact_length` / `prefix` / `body_*`) is a
//! `keyhog_core::CredentialShape` declared in each detector's own TOML
//! (`[detector.credential_shape]`, DET-0, was the centralized
//! `rules/detector-credential-shapes.toml` `[[shape]]` list keyed by detector id).
//! Core owns the data AND its internal-consistency validation
//! (`CredentialShape::validate`); this module owns the SCANNER side: the compiled
//! [`CredentialShapeRule`] + its per-credential [`CredentialShapeRule::allows`]
//! gate, built per detector from that spec. Because the shape now lives on the
//! detector's own spec, the previous "shape rule for an unknown detector id" class
//! is impossible by construction (no id list, no id validation).
use keyhog_core::{CredentialShape, DetectorSpec};
/// The PEM armor header that opens every `-----BEGIN … PRIVATE KEY-----` block
/// (and X.509 certs). SINGLE OWNER: it is the load-bearing prefix of the
/// `private-key` / `ssh-private-key` / `github-app-private-key` detector
/// patterns, and scanner logic keys off it in two places, the suppression
/// carve-out (a PEM body must NOT be masking-pattern suppressed, or the detector
/// silently misses real OPENSSH keys) and the entropy plausibility gate. Both
/// now read this const via [`is_pem_block`] instead of two bare `"-----BEGIN"`
/// literals free to drift; a guard test binds it to its authoritative detector.
pub(crate) const PEM_BEGIN_MARKER: &str = "-----BEGIN";
/// True when `value` opens a PEM armor block (private key, certificate, …).
/// One predicate so every "is this a PEM body?" decision agrees byte-for-byte.
pub(crate) fn is_pem_block(value: &str) -> bool {
value.starts_with(PEM_BEGIN_MARKER)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CredentialShapeRule {
exact_length: Option<usize>,
prefix: Option<String>,
body_min_length: Option<usize>,
body_max_length: Option<usize>,
}
impl CredentialShapeRule {
pub(crate) fn allows(&self, credential: &str) -> bool {
if self
.exact_length
.is_some_and(|expected| credential.len() != expected)
{
return false;
}
if let Some(prefix) = self.prefix.as_deref() {
let Some(body) = credential.strip_prefix(prefix) else {
return true;
};
if self
.body_min_length
.is_some_and(|minimum| body.len() < minimum)
{
return false;
}
if self
.body_max_length
.is_some_and(|maximum| body.len() > maximum)
{
return false;
}
}
true
}
/// Compile the scanner-side gate from a detector's own declared shape
/// (`DetectorSpec::credential_shape`). The spec is validated separately at
/// build time (`CredentialShape::validate`); this only maps the fields.
fn from_spec(shape: &CredentialShape) -> Self {
Self {
exact_length: shape.exact_length,
prefix: shape.prefix.clone(),
body_min_length: shape.body_min_length,
body_max_length: shape.body_max_length,
}
}
#[cfg(test)]
pub(crate) fn exact_length_for_test(exact_length: usize) -> Self {
Self {
exact_length: Some(exact_length),
prefix: None,
body_min_length: None,
body_max_length: None,
}
}
#[cfg(test)]
pub(crate) fn prefix_body_range_for_test(
prefix: &str,
body_min_length: usize,
body_max_length: usize,
) -> Self {
Self {
exact_length: None,
prefix: Some(prefix.to_string()),
body_min_length: Some(body_min_length),
body_max_length: Some(body_max_length),
}
}
}
/// Compile one detector's credential-shape gate for its runtime plan. The
/// shape comes from its OWN spec
/// (`DetectorSpec::credential_shape`, DET-0), validated fail-closed
/// (`CredentialShape::validate`) so a malformed shape is a build error, never a
/// silent skip. A detector with no `[detector.credential_shape]` maps to `None`
/// (no shape gate). There is no id list and no "unknown detector" case; the
/// shape rides on the detector's own spec, so it cannot name a detector that does
/// not exist.
pub(crate) fn compile_detector_shape_rule(
detector: &DetectorSpec,
) -> Result<Option<CredentialShapeRule>, String> {
let Some(shape) = detector.credential_shape.as_ref() else {
return Ok(None);
};
shape.validate(&detector.id)?;
Ok(Some(CredentialShapeRule::from_spec(shape)))
}
#[cfg(test)]
#[path = "../tests/unit/credential_shapes.rs"]
mod tests;