use super::pipeline::{push_decoded_text_chunk, with_extracted_value_spans};
use super::{DecodeAdmissionSketch, Decoder};
use keyhog_core::Chunk;
use std::sync::LazyLock;
pub(crate) struct CaesarDecoder;
pub(crate) const MIN_CAESAR_LEN: usize = super::util::MIN_EVASION_DECODE_LEN;
const MIN_ALNUM_RUN: usize = 8;
const MIN_ENCODED_PRIVATE_KEY_B64_LEN: usize = 128;
const MIN_PRIVATE_KEY_B64_LINE_LEN: usize = 16;
pub(crate) const ALPHABET_LEN: u8 = 26;
#[derive(serde::Deserialize)]
struct ProgramSourceCodeExtensions {
extensions: Vec<String>,
}
fn parse_program_source_extensions(raw: &str) -> Result<Vec<String>, String> {
toml::from_str::<ProgramSourceCodeExtensions>(raw)
.map(|parsed| parsed.extensions)
.map_err(|error| error.to_string())
}
static PROGRAM_SOURCE_CODE_EXTENSIONS: LazyLock<Vec<String>> = LazyLock::new(|| {
match parse_program_source_extensions(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/program-source-extensions.toml"
))) {
Ok(extensions) => extensions,
Err(error) => panic!(
"rules/program-source-extensions.toml is invalid: {error}. \
Fix the bundled Tier-B metadata file list."
),
}
});
#[derive(serde::Deserialize)]
struct CaesarNoiseLists {
source_code_filenames: Vec<String>,
text_noise_extensions: Vec<String>,
}
fn parse_caesar_noise_lists(raw: &str) -> Result<CaesarNoiseLists, String> {
toml::from_str::<CaesarNoiseLists>(raw).map_err(|error| error.to_string())
}
static CAESAR_NOISE_LISTS: LazyLock<CaesarNoiseLists> = LazyLock::new(|| {
match parse_caesar_noise_lists(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/caesar-noise-lists.toml"
))) {
Ok(lists) => lists,
Err(error) => panic!(
"rules/caesar-noise-lists.toml is invalid: {error}. \
Fix the bundled Tier-B metadata file list."
),
}
});
static SOURCE_CODE_FILENAMES: LazyLock<Vec<String>> =
LazyLock::new(|| CAESAR_NOISE_LISTS.source_code_filenames.clone());
static CAESAR_TEXT_NOISE_EXTENSIONS: LazyLock<Vec<String>> =
LazyLock::new(|| CAESAR_NOISE_LISTS.text_noise_extensions.clone());
fn source_path_matches<S: AsRef<str>, F: AsRef<str>>(
path: &str,
extensions: &[S],
filenames: &[F],
) -> bool {
use crate::ascii_ci::ends_with_ignore_ascii_case;
let bytes = path.as_bytes();
if !filenames.is_empty() {
let base = crate::platform_compat::path_basename_bytes(bytes);
if filenames
.iter()
.any(|name| base.eq_ignore_ascii_case(name.as_ref().as_bytes()))
{
return true;
}
}
extensions
.iter()
.any(|ext| ends_with_ignore_ascii_case(bytes, ext.as_ref().as_bytes()))
}
pub(crate) fn is_program_source_code_path(path: Option<&str>) -> bool {
let Some(p) = path else { return false };
source_path_matches(p, &*PROGRAM_SOURCE_CODE_EXTENSIONS, &*SOURCE_CODE_FILENAMES)
}
pub(crate) fn is_source_code_path(path: Option<&str>) -> bool {
is_program_source_code_path(path)
|| path
.is_some_and(|p| source_path_matches(p, &*CAESAR_TEXT_NOISE_EXTENSIONS, &[] as &[&str]))
}
pub(crate) fn line_has_credential_url(line: &str) -> bool {
let Some(scheme_end) = line.find("://") else {
return false;
};
let scheme_bytes = &line.as_bytes()[..scheme_end];
let scheme_ok = scheme_bytes.len() >= 2
&& scheme_bytes
.iter()
.rev()
.take_while(|b| b.is_ascii_alphabetic() || **b == b'+')
.count()
>= 2;
if !scheme_ok {
return false;
}
let rest = &line[scheme_end + 3..];
let userinfo_end = rest
.find(|c: char| c == '/' || c == '?' || c == '#' || c.is_ascii_whitespace())
.unwrap_or(rest.len()); let userinfo = &rest[..userinfo_end];
let Some(at_pos) = userinfo.find('@') else {
return false;
};
userinfo[..at_pos].contains(':')
}
fn credential_url_line_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut line_start = 0usize;
for line in text.split_inclusive('\n') {
let line_body = line.trim_end_matches(['\r', '\n']);
if line_has_credential_url(line_body) {
spans.push((line_start, line_start + line_body.len()));
}
line_start += line.len();
}
spans
}
fn private_key_material_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = private_key_block_spans(text);
spans.extend(encoded_private_key_payload_spans(text));
spans
}
fn private_key_block_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut search_from = 0usize;
while let Some(rel_begin) = text[search_from..].find("-----BEGIN ") {
let begin = search_from + rel_begin;
let header_end = match text[begin..].find('\n') {
Some(rel) => begin + rel,
None => text.len(),
};
if !text[begin..header_end].contains("PRIVATE KEY") {
search_from = begin + "-----BEGIN ".len();
continue;
}
let Some(rel_end) = text[header_end..].find("-----END ") else {
break;
};
let end_start = header_end + rel_end;
let end_line = match text[end_start..].find('\n') {
Some(rel) => end_start + rel + 1,
None => text.len(),
};
if text[end_start..end_line].contains("PRIVATE KEY") {
spans.push((begin, end_line));
search_from = end_line;
} else {
search_from = end_start + "-----END ".len();
}
}
spans
}
fn encoded_private_key_payload_spans(text: &str) -> Vec<(usize, usize)> {
struct Run {
start: usize,
end: usize,
encoded: String,
}
fn flush(run: &mut Option<Run>, spans: &mut Vec<(usize, usize)>) {
let Some(run) = run.take() else {
return;
};
if run.encoded.len() < MIN_ENCODED_PRIVATE_KEY_B64_LEN {
return;
}
let Ok(decoded) = super::base64_decode(&run.encoded) else {
return;
};
let Ok(decoded_text) = String::from_utf8(decoded) else {
return;
};
if decoded_text.contains("-----BEGIN ")
&& decoded_text.contains("PRIVATE KEY")
&& decoded_text.contains("-----END ")
{
spans.push((run.start, run.end));
}
}
let mut spans = Vec::new();
let mut run: Option<Run> = None;
let mut line_start = 0usize;
for line in text.split_inclusive('\n') {
let line_body = line.trim_end_matches(['\r', '\n']);
let absolute_line_end = line_start + line_body.len();
let (value_start, value) = base64ish_line_value(line_body, line_start);
if value.len() >= MIN_PRIVATE_KEY_B64_LINE_LEN
&& value.bytes().all(super::is_standard_base64_byte)
{
match &mut run {
Some(active) => {
active.end = absolute_line_end;
active.encoded.push_str(value);
}
None => {
run = Some(Run {
start: value_start,
end: absolute_line_end,
encoded: value.to_string(),
});
}
}
} else {
flush(&mut run, &mut spans);
}
line_start += line.len();
}
flush(&mut run, &mut spans);
spans
}
fn base64ish_line_value(line: &str, line_start: usize) -> (usize, &str) {
let mut start = 0usize;
let mut end = line.len();
if let Some(colon) = line.find(':') {
start = colon + 1;
}
while start < end && line.as_bytes()[start].is_ascii_whitespace() {
start += 1;
}
while end > start && line.as_bytes()[end - 1].is_ascii_whitespace() {
end -= 1;
}
(line_start + start, &line[start..end])
}
fn candidate_inside_spans((start, end): (usize, usize), spans: &[(usize, usize)]) -> bool {
spans
.iter()
.any(|(span_start, span_end)| *span_start <= start && end <= *span_end)
}
impl CaesarDecoder {
fn name(&self) -> &'static str {
"caesar"
}
pub(super) fn admission_sketch_with_policy(
&self,
chunk: &Chunk,
policy: &super::policy::CompiledDecodeTransformPolicy,
) -> DecodeAdmissionSketch {
if chunk.metadata.source_type.contains("/caesar")
|| is_source_code_path(chunk.metadata.path.as_deref())
{
return DecodeAdmissionSketch::NONE;
}
with_extracted_value_spans(&chunk.data, |candidates| {
let mut count = 0usize;
let mut bytes = 0usize;
for candidate in candidates {
let Some(shifts) = candidate_caesar_shifts(&candidate.value, policy) else {
continue;
};
let shift_count = shifts.iter().filter(|matched| **matched).count();
count = count.saturating_add(shift_count);
bytes = bytes.saturating_add(candidate.value.len().saturating_mul(shift_count));
}
if count == 0 {
DecodeAdmissionSketch::NONE
} else {
DecodeAdmissionSketch::possible(DecodeAdmissionSketch::CAESAR, count, bytes)
}
})
}
pub(super) fn decode_chunk_with_policy(
&self,
chunk: &Chunk,
policy: &super::policy::CompiledDecodeTransformPolicy,
) -> Vec<Chunk> {
if chunk.metadata.source_type.contains("/caesar") {
return Vec::new();
}
if is_source_code_path(chunk.metadata.path.as_deref()) {
return Vec::new();
}
let mut out = Vec::new();
let credential_url_line_spans = credential_url_line_spans(&chunk.data);
let private_key_spans = private_key_material_spans(&chunk.data);
with_extracted_value_spans(&chunk.data, |candidates| {
for candidate in candidates {
if candidate_inside_spans(candidate.span(), &credential_url_line_spans) {
continue;
}
if candidate_inside_spans(candidate.span(), &private_key_spans) {
continue;
}
let candidate = candidate.value.as_str();
let Some(try_shift) = candidate_caesar_shifts(candidate, policy) else {
continue;
};
for shift in 1..=25u8 {
if !try_shift[shift as usize] {
continue;
}
let decoded = caesar_shift(candidate, shift);
if !contains_known_prefix_with_policy(&decoded, policy) {
continue;
}
push_decoded_text_chunk(&mut out, chunk, decoded, self.name());
}
}
});
out
}
}
impl Decoder for CaesarDecoder {
fn name(&self) -> &'static str {
"caesar"
}
fn admission_sketch(&self, chunk: &Chunk) -> DecodeAdmissionSketch {
self.admission_sketch_with_policy(chunk, super::policy::bundled_compat_policy())
}
fn decode_chunk(&self, chunk: &Chunk) -> Vec<Chunk> {
self.decode_chunk_with_policy(chunk, super::policy::bundled_compat_policy())
}
}
fn candidate_caesar_shifts(
candidate: &str,
policy: &super::policy::CompiledDecodeTransformPolicy,
) -> Option<[bool; 26]> {
if candidate.len() < MIN_CAESAR_LEN || !candidate_shape_invariant(candidate) {
return None;
}
let shifts = policy.matched_caesar_shifts(candidate);
shifts.iter().any(|matched| *matched).then_some(shifts)
}
pub(crate) fn matched_caesar_shifts(candidate: &str) -> [bool; 26] {
super::policy::bundled_compat_policy().matched_caesar_shifts(candidate)
}
pub(crate) fn candidate_shape_invariant(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.iter().any(|b| b.is_ascii_alphabetic()) && has_digit_and_long_alnum_run(bytes)
}
fn has_digit_and_long_alnum_run(bytes: &[u8]) -> bool {
if !bytes.iter().any(|b| b.is_ascii_digit()) {
return false;
}
let mut run = 0usize;
for &b in bytes {
if b.is_ascii_alphanumeric() {
run += 1;
if run >= MIN_ALNUM_RUN {
return true;
}
} else {
run = 0;
}
}
false
}
pub(crate) fn caesar_shift(input: &str, shift: u8) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
let shifted = match ch {
'A'..='Z' => {
let base = b'A';
let off = (ch as u8 - base + shift) % ALPHABET_LEN;
(base + off) as char
}
'a'..='z' => {
let base = b'a';
let off = (ch as u8 - base + shift) % ALPHABET_LEN;
(base + off) as char
}
_ => ch,
};
out.push(shifted);
}
out
}
pub(crate) fn contains_known_prefix(s: &str) -> bool {
contains_known_prefix_with_policy(s, super::policy::bundled_compat_policy())
}
fn contains_known_prefix_with_policy(
candidate: &str,
policy: &super::policy::CompiledDecodeTransformPolicy,
) -> bool {
policy.caesar_matches_plaintext(candidate)
}