use keyhog_core::{DetectorSpec, Severity};
#[derive(Debug)]
enum CompiledDetectorKeywordMatcher {
None,
One(Box<[u8]>),
Multiple(aho_corasick::AhoCorasick),
}
impl CompiledDetectorKeywordMatcher {
fn compile(detector: &DetectorSpec) -> Result<Self, String> {
if let Some(empty_index) = detector.keywords.iter().position(String::is_empty) {
return Err(format!(
"detector {:?} keyword {empty_index} is empty; remove it or declare a non-empty detector-owned context literal",
detector.id
));
}
match detector.keywords.as_slice() {
[] => Ok(Self::None),
[keyword] => Ok(Self::One(keyword.as_bytes().into())),
keywords => aho_corasick::AhoCorasickBuilder::new()
.kind(Some(aho_corasick::AhoCorasickKind::ContiguousNFA))
.build(keywords)
.map(Self::Multiple)
.map_err(|error| {
format!(
"detector {:?} keyword matcher could not compile: {error}",
detector.id
)
}),
}
}
#[inline]
fn is_match(&self, haystack: &[u8]) -> bool {
match self {
Self::None => false,
Self::One(keyword) => memchr::memmem::find(haystack, keyword).is_some(),
Self::Multiple(matcher) => matcher.is_match(haystack),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CandidateLengthRejection {
TooShort,
TooLong,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct WholeAssignmentValue {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) covered_end: usize,
}
impl WholeAssignmentValue {
#[inline]
pub(crate) fn as_str<'a>(self, data: &'a str) -> &'a str {
data.get(self.start..self.end)
.expect("whole assignment spans are canonical UTF-8 byte ranges")
}
#[inline]
pub(crate) const fn is_exact(self, start: usize, end: usize) -> bool {
self.start == start && self.end == end
}
}
pub(crate) fn whole_assignment_value(
data: &str,
candidate_start: usize,
candidate_end: usize,
) -> WholeAssignmentValue {
let bytes = data.as_bytes();
let candidate_start = crate::engine::floor_char_boundary(data, candidate_start);
let candidate_end = crate::engine::ceil_char_boundary(data, candidate_end).max(candidate_start);
let mut active_quote = None;
let mut escaped = false;
let mut cursor = 0;
while cursor < candidate_start {
let byte = bytes[cursor];
if matches!(byte, b'\n' | b'\r') {
active_quote = None;
escaped = false;
} else if matches!(byte, b'"' | b'\'' | b'`') && !escaped {
active_quote = match active_quote {
Some((quote, _)) if quote == byte => None,
None => Some((byte, cursor)),
current => current,
};
}
if byte == b'\\' {
escaped = !escaped;
} else {
escaped = false;
}
cursor += 1;
}
if let Some((quote, opening)) = active_quote {
let nested_assignment = bytes[opening + 1..candidate_start]
.iter()
.any(|byte| matches!(byte, b'=' | b':'));
if !nested_assignment {
while cursor < bytes.len() {
let byte = bytes[cursor];
if matches!(byte, b'\n' | b'\r') {
break;
}
if byte == quote && !escaped {
return WholeAssignmentValue {
start: opening + 1,
end: cursor,
covered_end: cursor + 1,
};
}
if byte == b'\\' {
escaped = !escaped;
} else {
escaped = false;
}
cursor += 1;
}
return WholeAssignmentValue {
start: opening + 1,
end: cursor,
covered_end: cursor,
};
}
}
let mut end = candidate_end;
while let Some(&byte) = bytes.get(end) {
if byte.is_ascii_whitespace()
|| matches!(byte, b',' | b';' | b')' | b']' | b'}' | b'"' | b'\'' | b'`')
{
break;
}
end += 1;
}
WholeAssignmentValue {
start: candidate_start,
end,
covered_end: end,
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CompiledDetectorLengthPolicy {
pub(crate) min_len: Option<usize>,
pub(crate) max_len: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CompiledRequiredDetectorLengthPolicy {
pub(crate) min_len: usize,
pub(crate) max_len: usize,
}
impl CompiledDetectorLengthPolicy {
pub(crate) const fn compile(detector: &DetectorSpec) -> Self {
Self {
min_len: detector.min_len,
max_len: detector.max_len,
}
}
#[inline]
pub(crate) fn rejection(self, candidate_len: usize) -> Option<CandidateLengthRejection> {
if self.min_len.is_some_and(|min_len| candidate_len < min_len) {
Some(CandidateLengthRejection::TooShort)
} else if self.max_len.is_some_and(|max_len| candidate_len > max_len) {
Some(CandidateLengthRejection::TooLong)
} else {
None
}
}
pub(crate) fn require_bounded(
self,
detector_id: &str,
) -> Result<CompiledRequiredDetectorLengthPolicy, String> {
let min_len = self.min_len.ok_or_else(|| {
format!(
"detector {detector_id:?} owns entropy detection but omits min_len; declare the complete policy in its detector TOML"
)
})?;
let max_len = self.max_len.ok_or_else(|| {
format!(
"detector {detector_id:?} owns entropy detection but omits max_len; declare the complete policy in its detector TOML"
)
})?;
Ok(CompiledRequiredDetectorLengthPolicy { min_len, max_len })
}
}
#[derive(Debug)]
pub(crate) struct CompiledDetectorExecutionPolicy {
pub(crate) is_generic: bool,
pub(crate) length: CompiledDetectorLengthPolicy,
pub(crate) min_confidence: Option<f64>,
pub(crate) severity: Severity,
pub(crate) structural_password_slot: bool,
keywords: CompiledDetectorKeywordMatcher,
public_identifier_assignment_markers: Box<[Box<[u8]>]>,
}
impl CompiledDetectorExecutionPolicy {
pub(crate) fn compile(detector: &DetectorSpec) -> Result<Self, String> {
Ok(Self {
is_generic: detector.owns_entropy_policy(),
length: CompiledDetectorLengthPolicy::compile(detector),
min_confidence: detector.min_confidence,
severity: detector.severity,
structural_password_slot: detector.structural_password_slot,
keywords: CompiledDetectorKeywordMatcher::compile(detector)?,
public_identifier_assignment_markers: detector
.public_identifier_assignment_markers
.iter()
.map(|marker| marker.as_bytes().into())
.collect(),
})
}
#[inline]
pub(crate) fn line_has_public_identifier_assignment(&self, line: &[u8]) -> bool {
self.public_identifier_assignment_markers
.iter()
.any(|marker| crate::ascii_ci::ci_find_nonempty(line, marker.as_ref()))
}
#[inline]
pub(crate) fn keyword_nearby(&self, chunk_data: &[u8], preprocessed: &[u8]) -> bool {
let same_buffer = chunk_data.len() == preprocessed.len()
&& std::ptr::eq(chunk_data.as_ptr(), preprocessed.as_ptr());
let text_differs = !same_buffer && preprocessed != chunk_data;
self.keywords.is_match(chunk_data) || (text_differs && self.keywords.is_match(preprocessed))
}
}