use crate::error::{Result, ScanError};
use crate::types::*;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
use keyhog_core::{CompanionSpec, DetectorSpec, PatternSpec};
use regex::Regex;
pub(crate) fn build_ac_pattern_set(literals: &[String]) -> Result<Option<AhoCorasick>> {
if literals.is_empty() {
return Ok(None);
}
Ok(Some(
AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.build(literals)?,
))
}
pub(crate) fn build_gpu_literals(
ac_literals: &[String],
phase2_keywords: &[String],
phase2_always_anchor_literals: &[String],
confirmed_anchor_literals: &[String],
generic_keyword_literals: &[String],
) -> Option<std::sync::Arc<Vec<Vec<u8>>>> {
build_gpu_literal_rows(
ac_literals
.iter()
.chain(phase2_keywords)
.chain(phase2_always_anchor_literals)
.chain(confirmed_anchor_literals)
.chain(generic_keyword_literals),
"GPU fused literal set",
)
}
static GPU_LITERAL_EMPTY_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
fn build_gpu_literal_rows<'a>(
literals: impl Iterator<Item = &'a String>,
label: &'static str,
) -> Option<std::sync::Arc<Vec<Vec<u8>>>> {
let mut rows = Vec::new();
for literal in literals {
if literal.is_empty() {
tracing::warn!("{label} contains an empty literal; disabling GPU literal scan");
if GPU_LITERAL_EMPTY_WARNED.set(()).is_ok() {
eprintln!(
"keyhog: a detector produced an empty literal in the {label}, so the GPU \
literal matcher was discarded and every scan will route through CPU/SIMD instead of the GPU \
literal path. Check your detector definitions for an empty AC literal (an empty `keywords`/\
prefix entry). Use --require-gpu when GPU acceleration is mandatory."
);
}
return None;
}
rows.push(literal.as_bytes().to_vec());
}
if rows.is_empty() {
None
} else {
tracing::info!(patterns = rows.len(), "{} prepared for VYRE", label);
Some(std::sync::Arc::new(rows))
}
}
pub(crate) fn build_same_prefix_patterns(literals: &[String]) -> Vec<Vec<usize>> {
let mut groups: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
for (i, lit) in literals.iter().enumerate() {
groups.entry(lit.as_str()).or_default().push(i);
}
let mut map = vec![Vec::new(); literals.len()];
for indices in groups.values() {
if indices.len() > 1 {
for &i in indices {
map[i] = indices.iter().copied().filter(|&j| j != i).collect();
}
}
}
map
}
pub(crate) fn build_prefix_propagation(literals: &[String]) -> Vec<Vec<usize>> {
crate::prefix_trie::build_propagation_table(literals)
}
pub(crate) fn build_phase2_keyword_ac(
phase2_patterns: &[(CompiledPattern, Vec<String>)],
) -> (Option<AhoCorasick>, Vec<Vec<usize>>, Vec<String>) {
let mut all_keywords = Vec::new();
let mut keyword_to_patterns = Vec::new();
let mut keyword_map: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (pattern_idx, (pattern, keywords)) in phase2_patterns.iter().enumerate() {
let allows_repeated_separator =
regex_allows_repeated_compound_keyword_separator(pattern.regex.as_str());
for kw in keywords {
let mut candidates = Vec::with_capacity(2);
if kw.len() >= 4 {
candidates.push(kw.clone());
}
if allows_repeated_separator {
if let Some(stem) = longest_compound_keyword_segment(kw) {
if stem.len() >= 2 && !candidates.iter().any(|candidate| candidate == &stem) {
candidates.push(stem);
}
}
}
for candidate in candidates {
let idx = *keyword_map.entry(candidate.clone()).or_insert_with(|| {
all_keywords.push(candidate.clone());
keyword_to_patterns.push(Vec::new());
all_keywords.len() - 1
});
keyword_to_patterns[idx].push(pattern_idx);
}
}
}
if all_keywords.is_empty() {
return (None, Vec::new(), Vec::new());
}
let keyword_count = all_keywords.len();
let ac = match AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.build(&all_keywords)
{
Ok(ac) => Some(ac),
Err(error) => {
tracing::warn!(
keywords = keyword_count,
%error,
"phase-2 keyword Aho-Corasick build failed; keyword-gate optimization disabled (recall preserved)"
);
None
}
};
(ac, keyword_to_patterns, all_keywords)
}
fn longest_compound_keyword_segment(keyword: &str) -> Option<String> {
keyword
.split(['_', '-', '.'])
.filter(|segment| {
segment.len() >= 2 && segment.bytes().all(|byte| byte.is_ascii_alphanumeric())
})
.max_by_key(|segment| segment.len())
.map(str::to_ascii_lowercase)
}
fn regex_allows_repeated_compound_keyword_separator(regex: &str) -> bool {
let Ok(hir) = regex_syntax::Parser::new().parse(regex) else {
return false;
};
hir_contains_repeated_separator(&hir)
}
fn hir_contains_repeated_separator(hir: ®ex_syntax::hir::Hir) -> bool {
use regex_syntax::hir::HirKind;
match hir.kind() {
HirKind::Repetition(repetition) => {
let repeats = repetition.max.is_none_or(|maximum| maximum > 1);
(repeats && hir_is_compound_keyword_separator(&repetition.sub))
|| hir_contains_repeated_separator(&repetition.sub)
}
HirKind::Capture(capture) => hir_contains_repeated_separator(&capture.sub),
HirKind::Concat(parts) | HirKind::Alternation(parts) => {
parts.iter().any(hir_contains_repeated_separator)
}
HirKind::Empty | HirKind::Literal(_) | HirKind::Class(_) | HirKind::Look(_) => false,
}
}
fn hir_is_compound_keyword_separator(hir: ®ex_syntax::hir::Hir) -> bool {
use regex_syntax::hir::{Class, HirKind};
match hir.kind() {
HirKind::Class(Class::Unicode(class)) => {
let mut has_join_punctuation = false;
for range in class.iter() {
let start = u32::from(range.start());
let end = u32::from(range.end());
if end - start > 32 {
return false;
}
for codepoint in start..=end {
let Some(character) = char::from_u32(codepoint) else {
return false;
};
if matches!(character, '_' | '-' | '.') {
has_join_punctuation = true;
} else if !character.is_whitespace() {
return false;
}
}
}
has_join_punctuation
}
HirKind::Class(Class::Bytes(class)) => {
let mut has_join_punctuation = false;
for range in class.iter() {
for byte in range.start()..=range.end() {
if matches!(byte, b'_' | b'-' | b'.') {
has_join_punctuation = true;
} else if !byte.is_ascii_whitespace() {
return false;
}
}
}
has_join_punctuation
}
_ => false,
}
}
pub(crate) fn log_quality_warnings(warnings: &[String]) {
for warning in warnings {
tracing::warn!(target: "keyhog::scanner::quality", "{}", warning);
}
}
pub(crate) fn compile_detector_companions(
detector: &DetectorSpec,
) -> Result<Vec<CompiledCompanion>> {
detector
.companions
.iter()
.map(|companion| compile_companion(companion, &detector.id))
.collect()
}
pub(crate) fn compile_pattern(
detector_index: usize,
pattern_index: usize,
spec: &PatternSpec,
detector_id: &str,
detector_keywords: &[String],
) -> Result<CompiledPattern> {
spec.validate_required_literals()
.map_err(|reason| ScanError::DetectorPatternPolicy {
detector_id: detector_id.to_string(),
index: pattern_index,
reason,
})?;
let regex = shared_regex(spec.regex.as_str()).map_err(|source| ScanError::RegexCompile {
detector_id: detector_id.to_string(),
index: pattern_index,
source,
})?;
if let Some(group) = spec.group {
let captures_len = regex.captures_len();
if group >= captures_len {
return Err(ScanError::CaptureGroupOutOfRange {
detector_id: detector_id.to_string(),
index: pattern_index,
group,
captures_len,
});
}
}
Ok(CompiledPattern {
detector_index,
regex: LazyRegex::detector_compiled(spec.regex.as_str(), regex),
group: spec.group,
client_safe: spec.client_safe,
weak_anchor: spec.weak_anchor,
structural_password_slot: spec.structural_password_slot,
match_proves_keyword_nearby: match_proves_keyword_nearby(
spec.regex.as_str(),
detector_keywords,
),
homoglyph_variant: false,
})
}
pub(crate) fn match_proves_keyword_nearby(regex: &str, detector_keywords: &[String]) -> bool {
let prefixes = super::compiler_prefix::extract_literal_prefixes(regex);
!prefixes.is_empty()
&& prefixes.iter().all(|prefix| {
detector_keywords.iter().any(|keyword| {
!keyword.is_empty()
&& prefix
.as_bytes()
.get(..keyword.len())
.is_some_and(|head| head.eq_ignore_ascii_case(keyword.as_bytes()))
})
})
}
const REGEX_CACHE_SHARDS: usize = 64;
const REGEX_CACHE_CAPACITY: usize = 8192;
type RegexCacheShard = parking_lot::Mutex<lru::LruCache<String, std::sync::Arc<Regex>>>;
static REGEX_CACHE: std::sync::OnceLock<Box<[RegexCacheShard]>> = std::sync::OnceLock::new();
fn regex_cache() -> &'static [RegexCacheShard] {
REGEX_CACHE.get_or_init(|| {
let per_shard = (REGEX_CACHE_CAPACITY / REGEX_CACHE_SHARDS).max(1);
let nz = std::num::NonZeroUsize::new(per_shard).unwrap_or(std::num::NonZeroUsize::MIN); (0..REGEX_CACHE_SHARDS)
.map(|_| parking_lot::Mutex::new(lru::LruCache::new(nz)))
.collect::<Vec<_>>()
.into_boxed_slice()
})
}
fn regex_cache_shard(pattern: &str) -> &'static RegexCacheShard {
let idx = (crate::util_hash::hash_fast(pattern.as_bytes()) as usize) % REGEX_CACHE_SHARDS;
®ex_cache()[idx]
}
pub(crate) fn shared_regex_compile(
pattern: &str,
) -> std::result::Result<std::sync::Arc<Regex>, regex::Error> {
let regex = regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.size_limit(REGEX_SIZE_LIMIT_BYTES)
.dfa_size_limit(regex_dfa_limit())
.crlf(true)
.build()?;
Ok(std::sync::Arc::new(regex))
}
pub(crate) fn shared_regex(
pattern: &str,
) -> std::result::Result<std::sync::Arc<Regex>, regex::Error> {
let shard = regex_cache_shard(pattern);
if let Some(hit) = shard.lock().get(pattern) {
return Ok(std::sync::Arc::clone(hit));
}
let arc = shared_regex_compile(pattern)?;
let mut lock = shard.lock();
if let Some(hit) = lock.get(pattern) {
return Ok(std::sync::Arc::clone(hit));
}
lock.put(pattern.to_string(), std::sync::Arc::clone(&arc));
Ok(arc)
}
pub(crate) fn compile_companion(
spec: &CompanionSpec,
detector_id: &str,
) -> Result<CompiledCompanion> {
let regex = regex::RegexBuilder::new(&spec.regex)
.size_limit(REGEX_SIZE_LIMIT_BYTES)
.dfa_size_limit(regex_dfa_limit())
.crlf(true)
.build()
.map_err(|e| ScanError::RegexCompile {
detector_id: detector_id.to_string(),
index: FIRST_CAPTURE_GROUP_INDEX,
source: e,
})?;
let capture_group = (regex.captures_len() > 1).then_some(FIRST_CAPTURE_GROUP_INDEX);
Ok(CompiledCompanion {
name: spec.name.clone(),
regex,
capture_group,
within_lines: spec.within_lines,
required: spec.required,
})
}