use crate::pclntab::Function;
pub const MIN_SAMPLE: usize = 50;
#[derive(Debug, Clone, Default)]
pub struct GarbleVerdict {
pub is_garbled: bool,
pub confidence: f32,
pub evidence: Vec<String>,
}
pub fn detect(functions: &[Function]) -> GarbleVerdict {
let user: Vec<&str> = functions
.iter()
.map(|f| f.name.as_str())
.filter(|n| is_user_space(n))
.collect();
if user.len() < MIN_SAMPLE {
return GarbleVerdict {
is_garbled: false,
confidence: 0.0,
evidence: vec![format!(
"insufficient sample: {} user-space functions (need {})",
user.len(),
MIN_SAMPLE
)],
};
}
let total = user.len() as f32;
let dotless: Vec<&str> = user.iter().copied().filter(|n| !n.contains('.')).collect();
let dotless_ratio = dotless.len() as f32 / total;
let h1 = dotless_ratio >= 0.50;
let lens: Vec<f32> = dotless.iter().map(|n| n.len() as f32).collect();
let (mean_len, stdev) = mean_stdev(&lens);
let h2 = !dotless.is_empty() && (3.0..=9.0).contains(&mean_len) && stdev < 2.5;
let short_hits = dotless
.iter()
.filter(|n| looks_like_garbled_identifier(n))
.count();
let short_ratio = if dotless.is_empty() {
0.0
} else {
short_hits as f32 / dotless.len() as f32
};
let h3 = short_ratio >= 0.60;
let mut evidence = Vec::new();
evidence.push(format!(
"dotless user-name ratio: {:.2} ({}/{})",
dotless_ratio,
dotless.len(),
user.len()
));
evidence.push(format!(
"dotless name length: mean {:.2}, stdev {:.2}",
mean_len, stdev
));
evidence.push(format!(
"short identifier ratio: {:.2} ({}/{})",
short_ratio,
short_hits,
dotless.len().max(1)
));
let mut is_garbled = false;
let confidence: f32;
if h1 && h2 {
is_garbled = true;
let mut c = 0.9_f32;
if h3 {
c += 0.05;
evidence.push("charset signal (H3) present".into());
}
confidence = c;
} else {
confidence = (dotless_ratio * 0.5).clamp(0.0, 0.49);
if !h1 {
evidence.push("H1 dotless ratio below 0.50".into());
}
if !h2 {
evidence.push("H2 uniform length signal absent".into());
}
}
GarbleVerdict {
is_garbled,
confidence: confidence.min(1.0),
evidence,
}
}
fn is_user_space(name: &str) -> bool {
if name.starts_with("runtime.")
|| name.starts_with("reflect.")
|| name.starts_with("type:")
|| name.starts_with("type..")
|| name.starts_with("go:")
|| name.starts_with("go.")
|| name.starts_with("gcWriteBarrier")
|| name.starts_with("$f64.")
|| name.starts_with("$f32.")
{
return false;
}
if name.contains('/') || name.contains("..") {
return false;
}
true
}
fn looks_like_garbled_identifier(name: &str) -> bool {
let len = name.len();
if !(3..=9).contains(&len) {
return false;
}
let mut chars = name.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
let body_ok = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !body_ok {
return false;
}
let has_underscore = name.contains('_');
let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
has_underscore || (has_lower && has_upper)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Signal {
pub id: &'static str,
pub fired: bool,
pub detail: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GarbleAssessment {
pub verdict: Verdict,
pub confidence: f32,
pub signals: Vec<Signal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Garbled,
Clean,
Indeterminate,
}
impl GarbleAssessment {
pub fn exit_code(&self) -> i32 {
match self.verdict {
Verdict::Garbled => 0,
Verdict::Clean => 1,
Verdict::Indeterminate => 2,
}
}
pub fn is_garbled(&self) -> bool {
matches!(self.verdict, Verdict::Garbled)
}
}
pub fn assess(
functions: &[Function],
go_version: Option<&str>,
magic_is_official: bool,
) -> GarbleAssessment {
let structural = crate::output::detect_garble(functions, go_version, magic_is_official);
let shape = detect(functions);
let mut signals = Vec::with_capacity(4);
signals.push(Signal {
id: "pclntab.magic",
fired: structural.magic_rewritten,
detail: if structural.magic_rewritten {
"pclntab magic is not 0xfffffff1 / 0xfffffff0 (garble rewrites it)".into()
} else {
"pclntab magic matches an official Go release".into()
},
});
signals.push(Signal {
id: "runtime.build_version",
fired: structural.version_overwritten,
detail: if structural.version_overwritten {
"runtime.buildVersion missing or non-standard (garble overwrites it)".into()
} else {
"runtime.buildVersion looks like a real Go release".into()
},
});
let names_ratio_fired = structural
.hashed_names
.map(|(h, t)| t > 0 && (h * 5) > (t * 2))
.unwrap_or(false);
signals.push(Signal {
id: "names.hashed_ratio",
fired: names_ratio_fired,
detail: match structural.hashed_names {
Some((h, t)) => {
format!("{h}/{t} user-package function names look hashed (threshold > 40%)")
}
None => "fewer than 20 user-space functions to evaluate".into(),
},
});
signals.push(Signal {
id: "names.shape_heuristic",
fired: shape.is_garbled,
detail: if shape.evidence.is_empty() {
format!(
"dotless-name shape heuristic (confidence {:.2})",
shape.confidence
)
} else {
let mut joined = shape
.evidence
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join("; ");
joined.push_str(&format!(
"; H1+H2 verdict: {} (confidence {:.2})",
shape.is_garbled, shape.confidence
));
joined
},
});
if structural.magic_rewritten {
return GarbleAssessment {
verdict: Verdict::Garbled,
confidence: 1.0,
signals,
};
}
let fired = signals.iter().filter(|s| s.fired).count();
let evaluable = signals
.iter()
.filter(|s| {
!(s.id == "names.hashed_ratio" && s.detail.starts_with("fewer than 20"))
})
.count();
if fired >= 2 {
let confidence = match fired {
2 => 0.75,
3 => 0.9,
_ => 0.95,
};
GarbleAssessment {
verdict: Verdict::Garbled,
confidence,
signals,
}
} else if evaluable < 2 {
GarbleAssessment {
verdict: Verdict::Indeterminate,
confidence: 0.0,
signals,
}
} else {
GarbleAssessment {
verdict: Verdict::Clean,
confidence: 1.0 - (fired as f32) * 0.2,
signals,
}
}
}
fn mean_stdev(xs: &[f32]) -> (f32, f32) {
if xs.is_empty() {
return (0.0, 0.0);
}
let n = xs.len() as f32;
let mean = xs.iter().sum::<f32>() / n;
let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / n;
(mean, var.sqrt())
}
#[cfg(test)]
mod tests {
use super::*;
fn func(name: &str) -> Function {
Function {
address: 0,
name: name.to_string(),
file: None,
start_line: None,
}
}
fn synthetic_normal(n: usize) -> Vec<Function> {
let mut out = Vec::new();
for i in 0..n {
out.push(func(&format!("main.handleRequest{i}")));
out.push(func(&format!("github.com/acme/proj/api.Server{i}.Serve")));
out.push(func(&format!("runtime.gcMark{i}")));
}
out
}
fn synthetic_garbled(n: usize) -> Vec<Function> {
let samples = [
"_xUmF", "KbZxz_a", "_qq3w", "Vn4Hp", "_aA1bC", "kkLmN", "_z0pQr", "Ww8eR_", "_b3LpQ",
"Tt9_kX",
];
let mut out = Vec::new();
for i in 0..n {
out.push(func(samples[i % samples.len()]));
}
for i in 0..20 {
out.push(func(&format!("runtime.gcMark{i}")));
}
out
}
#[test]
fn declines_below_sample_threshold() {
let funcs = synthetic_normal(5);
let v = detect(&funcs);
assert!(!v.is_garbled);
assert_eq!(v.confidence, 0.0);
assert!(v.evidence.iter().any(|e| e.contains("insufficient sample")));
}
#[test]
fn normal_binary_does_not_trip() {
let funcs = synthetic_normal(60);
let v = detect(&funcs);
assert!(!v.is_garbled, "verdict: {v:?}");
assert!(v.confidence < 0.5, "confidence: {v:?}");
}
#[test]
fn garbled_binary_trips_with_high_confidence() {
let funcs = synthetic_garbled(80);
let v = detect(&funcs);
assert!(v.is_garbled, "verdict: {v:?}");
assert!(v.confidence >= 0.9, "confidence: {v:?}");
assert!(v.evidence.iter().any(|e| e.contains("dotless")));
}
#[test]
fn runtime_only_is_insufficient_sample() {
let funcs: Vec<Function> = (0..100)
.map(|i| func(&format!("runtime.gcMark{i}")))
.collect();
let v = detect(&funcs);
assert!(!v.is_garbled);
assert!(v.evidence[0].contains("insufficient sample"));
}
#[test]
fn import_paths_are_filtered_from_user_space() {
assert!(!is_user_space("github.com/foo/bar.Baz"));
assert!(!is_user_space("type:.eq.[3]int"));
assert!(is_user_space("main.run"));
assert!(is_user_space("_xUmF"));
}
#[test]
fn charset_check_rejects_plain_lowercase_words() {
assert!(!looks_like_garbled_identifier("hello"));
assert!(!looks_like_garbled_identifier("server"));
assert!(looks_like_garbled_identifier("_xUmF"));
assert!(looks_like_garbled_identifier("KbZxz_a"));
}
#[test]
fn assess_magic_alone_is_conclusive() {
let funcs = synthetic_normal(60);
let a = assess(&funcs, Some("go1.22.2"), false);
assert_eq!(a.verdict, Verdict::Garbled);
assert_eq!(a.confidence, 1.0);
assert_eq!(a.exit_code(), 0);
let magic = a.signals.iter().find(|s| s.id == "pclntab.magic").unwrap();
assert!(magic.fired, "magic signal must have fired");
}
#[test]
fn assess_clean_binary_is_clean() {
let funcs = synthetic_normal(60);
let a = assess(&funcs, Some("go1.22.2"), true);
assert_eq!(a.verdict, Verdict::Clean);
assert_eq!(a.exit_code(), 1);
}
#[test]
fn assess_majority_of_evaluable_signals_fires() {
let funcs = synthetic_garbled(80);
let a = assess(&funcs, None, true);
assert_eq!(a.verdict, Verdict::Garbled);
assert!(a.confidence >= 0.75);
assert_eq!(a.exit_code(), 0);
}
#[test]
fn assess_signal_count_matches_emitted_signals() {
let funcs = synthetic_normal(60);
let a = assess(&funcs, Some("go1.22.2"), true);
let ids: Vec<&str> = a.signals.iter().map(|s| s.id).collect();
assert_eq!(
ids,
vec![
"pclntab.magic",
"runtime.build_version",
"names.hashed_ratio",
"names.shape_heuristic",
]
);
}
#[test]
fn assess_resolves_field_test_self_contradiction() {
let mut funcs = Vec::new();
for i in 0..120 {
funcs.push(func(&format!("Hx7m{}.Method", i)));
}
let a = assess(&funcs, None, false);
assert_eq!(
a.verdict,
Verdict::Garbled,
"structural magic rewrite must dominate even when name-shape disagrees"
);
let shape = a
.signals
.iter()
.find(|s| s.id == "names.shape_heuristic")
.unwrap();
assert!(
!shape.fired,
"name-shape signal must not fire on dotted obfuscated names"
);
}
}