keyhog_scanner/decode/pipeline/
registry.rs1use crate::decode::base64::{Base64Decoder, Z85Decoder};
2use crate::decode::caesar::CaesarDecoder;
3use crate::decode::hex::HexDecoder;
4#[cfg(feature = "decode")]
5use crate::decode::javascript_static::JavaScriptStaticDecoder;
6use crate::decode::json::JsonDecoder;
7use crate::decode::reverse::ReverseDecoder;
8use crate::decode::url::{
9 HtmlNamedEntityDecoder, HtmlNumericEntityDecoder, MimeEncodedWordDecoder, OctalEscapeDecoder,
10 QuotedPrintableDecoder, UnicodeEscapeDecoder, UrlDecoder,
11};
12#[cfg(feature = "decode")]
13use crate::decode::DecodeAdmission;
14#[cfg(any(feature = "decode", test))]
15use crate::decode::DecodeAdmissionSketch;
16use crate::decode::Decoder;
17use aho_corasick::AhoCorasick;
18use parking_lot::RwLock;
19#[cfg(test)]
20use std::cell::RefCell;
21use std::sync::Arc;
22
23static DECODERS: std::sync::OnceLock<RwLock<DecoderRegistryState>> = std::sync::OnceLock::new();
28
29struct DecoderRegistryState {
30 decoders: Arc<Vec<RegisteredDecoder>>,
31 compatibility_failure: Option<DecoderRegistrationError>,
32}
33
34#[derive(Clone)]
35pub(crate) enum RegisteredDecoder {
36 Shared(Arc<dyn Decoder>),
37 Reverse,
38 Caesar,
39}
40
41impl RegisteredDecoder {
42 pub(crate) fn name(&self) -> &'static str {
43 match self {
44 Self::Shared(decoder) => decoder.name(),
45 Self::Reverse => "reverse",
46 Self::Caesar => "caesar",
47 }
48 }
49
50 fn version(&self) -> &'static str {
51 match self {
52 Self::Shared(decoder) => decoder.version(),
53 Self::Reverse | Self::Caesar => "detector-policy-1",
54 }
55 }
56
57 #[cfg(any(feature = "decode", test))]
58 fn admission(
59 &self,
60 chunk: &keyhog_core::Chunk,
61 policy: &super::super::policy::CompiledDecodeTransformPolicy,
62 ) -> DecodeAdmission {
63 match self {
64 Self::Shared(decoder) => decoder.admission(chunk),
65 Self::Reverse => ReverseDecoder
66 .admission_sketch_with_policy(chunk, policy)
67 .admission(),
68 Self::Caesar => CaesarDecoder
69 .admission_sketch_with_policy(chunk, policy)
70 .admission(),
71 }
72 }
73
74 #[cfg(any(feature = "decode", test))]
75 fn admission_sketch(
76 &self,
77 chunk: &keyhog_core::Chunk,
78 policy: &super::super::policy::CompiledDecodeTransformPolicy,
79 ) -> DecodeAdmissionSketch {
80 match self {
81 Self::Shared(decoder) => decoder.admission_sketch(chunk),
82 Self::Reverse => ReverseDecoder.admission_sketch_with_policy(chunk, policy),
83 Self::Caesar => CaesarDecoder.admission_sketch_with_policy(chunk, policy),
84 }
85 }
86
87 pub(super) fn decode_chunk_into(
88 &self,
89 chunk: &keyhog_core::Chunk,
90 policy: &super::super::policy::CompiledDecodeTransformPolicy,
91 sink: &mut dyn crate::decode::DecodeOutputSink,
92 ) {
93 match self {
94 Self::Shared(decoder) => decoder.decode_chunk_into(chunk, sink),
95 Self::Reverse => ReverseDecoder.decode_chunk_with_policy_into(chunk, policy, sink),
96 Self::Caesar => CaesarDecoder.decode_chunk_with_policy_into(chunk, policy, sink),
97 }
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
102pub enum DecoderRegistrationError {
103 #[error("decoder name must be non-empty ASCII without whitespace")]
104 InvalidName,
105 #[error("decoder {name:?} version must be non-empty ASCII without whitespace")]
106 InvalidVersion { name: &'static str },
107 #[error("decoder name {0:?} is already registered")]
108 DuplicateName(&'static str),
109 #[error("could not compile the all-decoder trigger automaton: {0}")]
110 TriggerBuild(String),
111}
112
113#[derive(Clone)]
114pub(crate) struct CompiledDecoderPlan {
115 decoders: Arc<Vec<RegisteredDecoder>>,
116 all_decoder_trigger: Option<AhoCorasick>,
117 identity: u64,
118}
119
120impl std::fmt::Debug for CompiledDecoderPlan {
121 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 formatter
123 .debug_struct("CompiledDecoderPlan")
124 .field("decoder_count", &self.decoders.len())
125 .field(
126 "has_all_decoder_trigger",
127 &self.all_decoder_trigger.is_some(),
128 )
129 .field("identity", &self.identity)
130 .finish()
131 }
132}
133
134impl CompiledDecoderPlan {
135 pub(crate) fn snapshot() -> Result<Self, DecoderRegistrationError> {
136 let decoders = snapshot_decoders()?;
137 let mut names = std::collections::HashSet::with_capacity(decoders.len());
138 let mut hasher = blake3::Hasher::new();
139 hasher.update(b"keyhog-compiled-decoder-plan-v1\0");
140 for decoder in decoders.iter() {
141 let name = decoder.name();
142 let version = decoder.version();
143 validate_descriptor(name, version)?;
144 if !names.insert(name) {
145 return Err(DecoderRegistrationError::DuplicateName(name));
146 }
147 hash_descriptor(&mut hasher, name, version);
148 }
149 let mut bytes = [0u8; 8];
150 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..8]);
151 let all_decoder_trigger = compile_all_decoder_trigger(&decoders)?;
152 Ok(Self {
153 decoders,
154 all_decoder_trigger,
155 identity: u64::from_le_bytes(bytes),
156 })
157 }
158
159 pub(crate) fn identity(&self) -> u64 {
160 self.identity
161 }
162
163 #[cfg(any(feature = "decode", test))]
164 pub(crate) fn decoders(&self) -> &[RegisteredDecoder] {
165 &self.decoders
166 }
167
168 pub(crate) fn all_decoder_may_match(&self, data: &str) -> bool {
169 self.all_decoder_trigger
170 .as_ref()
171 .is_none_or(|trigger| trigger.is_match(data.as_bytes()))
172 }
173}
174
175fn validate_descriptor(
176 name: &'static str,
177 version: &'static str,
178) -> Result<(), DecoderRegistrationError> {
179 if name.is_empty() || !name.is_ascii() || name.bytes().any(|byte| byte.is_ascii_whitespace()) {
180 return Err(DecoderRegistrationError::InvalidName);
181 }
182 if version.is_empty()
183 || !version.is_ascii()
184 || version.bytes().any(|byte| byte.is_ascii_whitespace())
185 {
186 return Err(DecoderRegistrationError::InvalidVersion { name });
187 }
188 Ok(())
189}
190
191fn hash_descriptor(hasher: &mut blake3::Hasher, name: &str, version: &str) {
192 for value in [name.as_bytes(), version.as_bytes()] {
193 hasher.update(&(value.len() as u64).to_le_bytes());
194 hasher.update(value);
195 }
196}
197
198fn is_default_decoder_name(name: &str) -> bool {
199 matches!(
200 name,
201 "base64"
202 | "hex"
203 | "url"
204 | "quoted-printable"
205 | "html-named-entity"
206 | "html-numeric-entity"
207 | "octal-escape"
208 | "mime-encoded-word"
209 | "json"
210 | "unicode-escape"
211 | "z85"
212 | "javascript-static"
213 | "reverse"
214 | "caesar"
215 )
216}
217
218fn compile_all_decoder_trigger(
219 decoders: &[RegisteredDecoder],
220) -> Result<Option<AhoCorasick>, DecoderRegistrationError> {
221 if decoders
222 .iter()
223 .any(|decoder| !is_default_decoder_name(decoder.name()))
224 {
225 return Ok(None);
226 }
227 let patterns = (b'!'..=b'~').map(|byte| [byte]);
228 AhoCorasick::builder()
229 .kind(Some(aho_corasick::AhoCorasickKind::ContiguousNFA))
230 .build(patterns)
231 .map(Some)
232 .map_err(|error| DecoderRegistrationError::TriggerBuild(error.to_string()))
233}
234
235#[cfg(test)]
236thread_local! {
237 static THREAD_DECODERS: RefCell<Vec<Arc<dyn Decoder>>> = RefCell::new(Vec::new());
238}
239
240pub(super) fn profile_enabled() -> bool {
245 crate::scan_profile::enabled()
246}
247
248const MAX_PROFILED_DECODERS: usize = 16;
255
256static DECODER_NS: [std::sync::atomic::AtomicU64; MAX_PROFILED_DECODERS] = {
257 const Z: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
258 [Z; MAX_PROFILED_DECODERS]
259};
260
261static DECODER_PRODUCED: [std::sync::atomic::AtomicU64; MAX_PROFILED_DECODERS] = {
265 const Z: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
266 [Z; MAX_PROFILED_DECODERS]
267};
268
269pub(super) fn record_decoder_run(
270 decoder_index: usize,
271 elapsed: std::time::Duration,
272 produced: usize,
273) {
274 if decoder_index >= MAX_PROFILED_DECODERS {
275 return;
276 }
277 use std::sync::atomic::Ordering::Relaxed;
278 DECODER_NS[decoder_index].fetch_add(elapsed.as_nanos() as u64, Relaxed);
279 DECODER_PRODUCED[decoder_index].fetch_add(produced as u64, Relaxed);
280}
281
282pub(crate) fn decoder_profile_dump() {
285 use std::sync::atomic::Ordering::Relaxed;
286 let decoders = active_decoders();
287 let names: Vec<&str> = decoders.iter().map(|d| d.name()).collect();
288 let mut rows: Vec<(String, f64)> = (0..names.len().min(MAX_PROFILED_DECODERS))
289 .map(|i| {
290 (
291 names[i].to_string(),
292 DECODER_NS[i].swap(0, Relaxed) as f64 / 1e6,
293 )
294 })
295 .collect();
296 rows.sort_by(|a, b| b.1.total_cmp(&a.1));
297 let total: f64 = rows.iter().map(|r| r.1).sum();
298 let mut prod: Vec<(String, u64)> = (0..names.len().min(MAX_PROFILED_DECODERS))
299 .map(|i| (names[i].to_string(), DECODER_PRODUCED[i].swap(0, Relaxed)))
300 .collect();
301 prod.sort_by(|a, b| b.1.cmp(&a.1));
302 let prod_total: u64 = prod.iter().map(|r| r.1).sum();
303 if total == 0.0 && prod_total == 0 {
304 return;
305 }
306 eprintln!("=== per-decoder decode_chunk time ===");
307 for (name, ms) in &rows {
308 let pct = if total > 0.0 { 100.0 * ms / total } else { 0.0 };
309 eprintln!(" {name:<18}: {ms:>8.1} ms ({pct:>5.1}%)");
310 }
311 eprintln!(" {:<18}: {total:>8.1} ms", "TOTAL");
312 eprintln!("=== per-decoder sub-chunks EMITTED (pre-dedup/screen) ===");
313 for (name, n) in &prod {
314 let pct = if prod_total > 0 {
315 100.0 * *n as f64 / prod_total as f64
316 } else {
317 0.0
318 };
319 eprintln!(" {name:<18}: {n:>8} ({pct:>5.1}%)");
320 }
321 eprintln!(" {:<18}: {prod_total:>8}", "TOTAL");
322}
323
324pub(crate) fn decoder_profile_reset() {
325 use std::sync::atomic::Ordering::Relaxed;
326 for slot in &DECODER_NS {
327 slot.store(0, Relaxed);
328 }
329 for slot in &DECODER_PRODUCED {
330 slot.store(0, Relaxed);
331 }
332}
333
334fn default_decoders() -> Vec<RegisteredDecoder> {
335 vec![
336 RegisteredDecoder::Shared(Arc::new(Base64Decoder)),
337 RegisteredDecoder::Shared(Arc::new(HexDecoder)),
338 RegisteredDecoder::Shared(Arc::new(UrlDecoder)),
339 RegisteredDecoder::Shared(Arc::new(QuotedPrintableDecoder)),
340 RegisteredDecoder::Shared(Arc::new(HtmlNamedEntityDecoder)),
341 RegisteredDecoder::Shared(Arc::new(HtmlNumericEntityDecoder)),
342 RegisteredDecoder::Shared(Arc::new(OctalEscapeDecoder)),
343 RegisteredDecoder::Shared(Arc::new(MimeEncodedWordDecoder)),
344 RegisteredDecoder::Shared(Arc::new(JsonDecoder)),
348 RegisteredDecoder::Shared(Arc::new(UnicodeEscapeDecoder)),
349 RegisteredDecoder::Shared(Arc::new(Z85Decoder)),
350 #[cfg(feature = "decode")]
353 RegisteredDecoder::Shared(Arc::new(JavaScriptStaticDecoder)),
354 RegisteredDecoder::Reverse,
355 RegisteredDecoder::Caesar,
356 ]
357}
358
359#[cfg(feature = "decode")]
365pub(crate) fn default_decoder_names() -> Vec<&'static str> {
366 default_decoders().iter().map(|d| d.name()).collect()
367}
368
369#[cfg(feature = "decode")]
376pub(crate) fn decoder_admission(
377 chunk: &keyhog_core::Chunk,
378 policy: &super::super::policy::CompiledDecodeTransformPolicy,
379 plan: &CompiledDecoderPlan,
380) -> DecodeAdmission {
381 super::extractor::clear_shared_candidates();
382 super::extractor::prime_shared_candidates(&chunk.data);
383
384 let mut aggregate = DecodeAdmission::Impossible;
385 for decoder in plan.decoders() {
386 match decoder.admission(chunk, policy) {
387 DecodeAdmission::Possible => {
388 aggregate = DecodeAdmission::Possible;
389 break;
390 }
391 DecodeAdmission::Unknown => aggregate = DecodeAdmission::Unknown,
392 DecodeAdmission::Impossible => {}
393 }
394 }
395
396 super::extractor::clear_shared_candidates();
397 aggregate
398}
399
400#[cfg(any(feature = "decode", test))]
401pub(crate) fn decoder_admission_sketch(
402 chunk: &keyhog_core::Chunk,
403 policy: &super::super::policy::CompiledDecodeTransformPolicy,
404 plan: &CompiledDecoderPlan,
405) -> DecodeAdmissionSketch {
406 decoder_admission_sketch_with_decoders(chunk, policy, plan.decoders())
407}
408
409#[cfg(any(feature = "decode", test))]
410pub(crate) fn active_decoder_admission_sketch(
411 chunk: &keyhog_core::Chunk,
412 policy: &super::super::policy::CompiledDecodeTransformPolicy,
413) -> DecodeAdmissionSketch {
414 let decoders = active_decoders();
415 decoder_admission_sketch_with_decoders(chunk, policy, &decoders)
416}
417
418#[cfg(any(feature = "decode", test))]
419fn decoder_admission_sketch_with_decoders(
420 chunk: &keyhog_core::Chunk,
421 policy: &super::super::policy::CompiledDecodeTransformPolicy,
422 decoders: &[RegisteredDecoder],
423) -> DecodeAdmissionSketch {
424 super::extractor::clear_shared_candidates();
425 super::extractor::prime_shared_candidates(&chunk.data);
426
427 let mut aggregate = DecodeAdmissionSketch::NONE;
428 for decoder in decoders {
429 aggregate.merge(decoder.admission_sketch(chunk, policy));
430 }
431
432 super::extractor::clear_shared_candidates();
433 aggregate
434}
435
436fn decoder_registry() -> &'static RwLock<DecoderRegistryState> {
437 DECODERS.get_or_init(|| {
438 RwLock::new(DecoderRegistryState {
439 decoders: Arc::new(default_decoders()),
440 compatibility_failure: None,
441 })
442 })
443}
444
445#[cfg(not(test))]
446pub(super) fn active_decoders() -> Arc<Vec<RegisteredDecoder>> {
447 Arc::clone(&decoder_registry().read().decoders)
451}
452
453#[cfg(test)]
454pub(super) fn active_decoders() -> Arc<Vec<RegisteredDecoder>> {
455 let base = Arc::clone(&decoder_registry().read().decoders);
456 THREAD_DECODERS.with(|thread_decoders| {
457 let thread = thread_decoders.borrow();
458 if thread.is_empty() {
459 base
462 } else {
463 let mut combined = (*base).clone();
464 combined.extend(thread.iter().cloned().map(RegisteredDecoder::Shared));
465 Arc::new(combined)
466 }
467 })
468}
469
470#[cfg(not(test))]
471fn snapshot_decoders() -> Result<Arc<Vec<RegisteredDecoder>>, DecoderRegistrationError> {
472 let registry = decoder_registry().read();
473 if let Some(error) = registry.compatibility_failure.clone() {
474 Err(error)
475 } else {
476 Ok(Arc::clone(®istry.decoders))
477 }
478}
479
480#[cfg(test)]
481fn snapshot_decoders() -> Result<Arc<Vec<RegisteredDecoder>>, DecoderRegistrationError> {
482 let base = {
483 let registry = decoder_registry().read();
484 if let Some(error) = registry.compatibility_failure.clone() {
485 return Err(error);
486 }
487 Arc::clone(®istry.decoders)
488 };
489 THREAD_DECODERS.with(|thread_decoders| {
490 let thread = thread_decoders.borrow();
491 if thread.is_empty() {
492 Ok(base)
493 } else {
494 let mut combined = (*base).clone();
495 combined.extend(thread.iter().cloned().map(RegisteredDecoder::Shared));
496 Ok(Arc::new(combined))
497 }
498 })
499}
500
501pub fn register_decoder(decoder: Box<dyn Decoder>) {
508 if let Err(error) = register_decoder_inner(decoder, true) {
509 tracing::error!(%error, "decoder registration failed; later scanner compilation will fail");
510 }
511}
512
513pub fn try_register_decoder(decoder: Box<dyn Decoder>) -> Result<(), DecoderRegistrationError> {
519 register_decoder_inner(decoder, false)
520}
521
522fn register_decoder_inner(
523 decoder: Box<dyn Decoder>,
524 record_failure: bool,
525) -> Result<(), DecoderRegistrationError> {
526 let decoder_name = decoder.name();
527 let mut guard = decoder_registry().write();
528 if let Some(error) = guard.compatibility_failure.clone() {
529 return Err(error);
530 }
531 let result = validate_descriptor(decoder_name, decoder.version()).and_then(|()| {
532 if guard
533 .decoders
534 .iter()
535 .any(|existing| existing.name() == decoder_name)
536 {
537 Err(DecoderRegistrationError::DuplicateName(decoder_name))
538 } else {
539 Ok(())
540 }
541 });
542 if let Err(error) = result {
543 if record_failure && guard.compatibility_failure.is_none() {
544 guard.compatibility_failure = Some(error.clone());
545 }
546 return Err(error);
547 }
548 let mut next = (*guard.decoders).clone();
553 next.push(RegisteredDecoder::Shared(Arc::from(decoder)));
554 guard.decoders = Arc::new(next);
555 Ok(())
556}
557
558#[cfg(test)]
559pub(crate) struct ScopedDecoderRegistration {
560 name: &'static str,
561 active: bool,
562}
563
564#[cfg(test)]
565impl Drop for ScopedDecoderRegistration {
566 fn drop(&mut self) {
567 if !self.active {
568 return;
569 }
570 THREAD_DECODERS.with(|thread_decoders| {
571 let mut decoders = thread_decoders.borrow_mut();
572 if let Some(index) = decoders
573 .iter()
574 .rposition(|decoder| decoder.name() == self.name)
575 {
576 decoders.remove(index);
577 }
578 });
579 }
580}
581
582#[cfg(test)]
583pub(crate) fn register_thread_decoder(decoder: Box<dyn Decoder>) -> ScopedDecoderRegistration {
584 let decoder_name = decoder.name();
585 let has_global_duplicate = decoder_registry()
586 .read()
587 .decoders
588 .iter()
589 .any(|existing| existing.name() == decoder_name);
590 if has_global_duplicate {
591 tracing::warn!(
592 decoder = decoder_name,
593 "register_thread_decoder called with a duplicate global decoder name; decoder ignored"
594 );
595 return ScopedDecoderRegistration {
596 name: decoder_name,
597 active: false,
598 };
599 }
600
601 let mut inserted = false;
602 let decoder = Arc::from(decoder);
603 THREAD_DECODERS.with(|thread_decoders| {
604 let mut decoders = thread_decoders.borrow_mut();
605 if decoders.iter().any(|existing| existing.name() == decoder_name) {
606 tracing::warn!(
607 decoder = decoder_name,
608 "register_thread_decoder called with a duplicate thread decoder name; decoder ignored"
609 );
610 return;
611 }
612 decoders.push(decoder);
613 inserted = true;
614 });
615 ScopedDecoderRegistration {
616 name: decoder_name,
617 active: inserted,
618 }
619}
620
621#[cfg(test)]
622#[path = "../../../tests/unit/decode_admission_soundness.rs"]
623mod admission_soundness_tests;