use gossan_core::Target;
use regex::Regex;
use secfinding::{Evidence, Finding, Severity};
use std::sync::OnceLock;
const MAX_WASM_BYTES: usize = 32 * 1024 * 1024;
fn wasm_url_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
Regex::new(r#"(?:src|href|fetch|import|load)\s*(?:=|\()\s*['"]?([^\s'"]+\.wasm)"#)
.unwrap_or_else(|e| {
tracing::error!("invalid wasm url regex: {e}");
Regex::new("$^").unwrap_or_else(|_| unreachable!())
})
})
}
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut results = Vec::new();
let mut current = Vec::new();
for &b in data {
if b.is_ascii_graphic() || b == b' ' {
current.push(b);
} else {
if current.len() >= 6 {
if let Ok(s) = std::str::from_utf8(¤t) {
results.push(s.to_string());
}
}
current.clear();
}
}
if current.len() >= 6 {
if let Ok(s) = std::str::from_utf8(¤t) {
results.push(s.to_string());
}
}
results
}
const WASM_SECRET_PATTERNS: &[(&str, &str, Severity)] = &[
(
r"AKIA[0-9A-Z]{16}",
"AWS Access Key in WASM",
Severity::Critical,
),
(
r"AIza[0-9A-Za-z\-_]{35}",
"GCP API Key in WASM",
Severity::High,
),
(
r"ghp_[a-zA-Z0-9]{36}",
"GitHub Token in WASM",
Severity::Critical,
),
(
r"sk-[a-zA-Z0-9]{48}",
"OpenAI API Key in WASM",
Severity::Critical,
),
(
r"sk_live_[0-9a-zA-Z]{24,}",
"Stripe Secret Key in WASM",
Severity::Critical,
),
(
r"xox[baprs]-[0-9a-zA-Z\-]{10,48}",
"Slack Token in WASM",
Severity::High,
),
(
r"SG\.[a-zA-Z0-9\-_]{22}\.[a-zA-Z0-9\-_]{43}",
"SendGrid Key in WASM",
Severity::High,
),
(r"npm_[a-zA-Z0-9]{36}", "NPM Token in WASM", Severity::High),
(
r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY",
"Private Key in WASM",
Severity::Critical,
),
(
r"(?:password|passwd|secret|api_?key)\s*=\s*[^\s]{8,}",
"Hardcoded credential in WASM",
Severity::Medium,
),
(
r"https?://(?:localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+)[:/]",
"Internal URL hardcoded in WASM",
Severity::High,
),
];
struct CompiledWasmRule {
re: Regex,
name: &'static str,
severity: Severity,
}
fn compiled_wasm_rules() -> &'static Vec<CompiledWasmRule> {
static COMPILED: OnceLock<Vec<CompiledWasmRule>> = OnceLock::new();
COMPILED.get_or_init(|| {
WASM_SECRET_PATTERNS
.iter()
.filter_map(|(pat, name, sev)| {
Regex::new(pat).ok().map(|re| CompiledWasmRule {
re,
name,
severity: *sev,
})
})
.collect()
})
}
pub async fn probe(
client: &reqwest::Client,
html: &str,
base: &url::Url,
target: &Target,
) -> Vec<Finding> {
let mut findings = Vec::new();
let wasm_urls: Vec<String> = wasm_url_re()
.captures_iter(html)
.filter_map(|cap| cap.get(1))
.filter_map(|m| base.join(m.as_str()).ok())
.filter(|u| u.scheme() == "http" || u.scheme() == "https")
.map(|u| u.to_string())
.collect();
if wasm_urls.is_empty() {
return findings;
}
tracing::debug!(count = wasm_urls.len(), "WASM files found");
for wasm_url in &wasm_urls {
let Ok(resp) = client.get(wasm_url).send().await else {
continue;
};
if resp.status().as_u16() != 200 {
continue;
}
let bytes = match gossan_core::read_response_limited(resp, MAX_WASM_BYTES).await {
Ok(b) => b,
Err(_) => continue,
};
if bytes.len() < 4 || &bytes[..4] != b"\x00asm" {
continue; }
let size_kb = bytes.len() / 1024;
let strings = extract_strings(&bytes);
let virtual_body = strings.join("\n");
let rules = compiled_wasm_rules();
let mut had_secret = false;
for rule in rules {
if let Some(m) = rule.re.find(&virtual_body) {
let matched = m.as_str();
let ctx = strings
.iter()
.find(|s| s.contains(matched))
.map(|s| s.chars().take(120).collect::<String>())
.unwrap_or_else(|| matched.chars().take(80).collect());
gossan_core::try_push_finding(crate::finding_builder(target, rule.severity,
format!("{} ({}KB)", rule.name, size_kb),
format!("WebAssembly binary at {} ({} KB, {} string literals extracted) \
contains what appears to be a hardcoded secret. WASM data sections \
are trivially readable — no decompiler needed, just `strings` or \
wasm-objdump.", wasm_url, size_kb, strings.len()))
.evidence(Evidence::JsSnippet {
url: std::sync::Arc::from(wasm_url.as_str()),
line: 0, snippet: std::sync::Arc::from(ctx.as_str()),
})
.tag("wasm").tag("secret").tag("exposure")
.exploit_hint(format!(
"# Extract all strings from WASM:\n\
curl -s '{}' | strings\n\
# Or with wasm-objdump:\n\
wasm-objdump -x -s {} | grep -A2 'Data'", wasm_url, wasm_url)), &mut findings);
had_secret = true;
}
}
if !had_secret {
gossan_core::try_push_finding(
crate::finding_builder(
target,
Severity::Info,
format!(
"WebAssembly binary: {} ({} KB)",
wasm_url.split('/').next_back().unwrap_or("?.wasm"),
size_kb
),
format!(
"{} — {} string literals readable without decompilation. \
Review for hardcoded secrets, internal endpoints, and business logic.",
wasm_url,
strings.len()
),
)
.tag("wasm")
.tag("exposure"),
&mut findings,
);
}
}
findings
}