[pitboss/grind] deferred session-0017 (20260522T163126Z-7d60)

This commit is contained in:
pitboss 2026-05-22 18:30:16 -05:00
parent 5cb8056250
commit 1e5f27f56d
8 changed files with 1231 additions and 8 deletions

View file

@ -0,0 +1,247 @@
//! Go [`super::super::FrameworkAdapter`] matching weak-crypto sink
//! constructions (`math/rand.Int*` non-CSPRNG randomness used for
//! key material, `crypto/md5.Sum` / `crypto/sha1.Sum` /
//! `crypto/des.NewCipher` / `crypto/rc4.NewCipher`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Go weak-crypto entry points and the surrounding
//! source imports the matching stdlib module.
//!
//! See sibling adapters [`super::crypto_python::CryptoPythonAdapter`],
//! [`super::crypto_java::CryptoJavaAdapter`], and
//! [`super::crypto_ruby::CryptoRubyAdapter`] for the same shape on
//! other languages.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoGoAdapter;
const ADAPTER_NAME: &str = "crypto-go";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"Int"
| "Intn"
| "Int31"
| "Int31n"
| "Int63"
| "Int63n"
| "Uint32"
| "Uint64"
| "Float32"
| "Float64"
| "Read"
| "Sum"
| "New"
| "NewCipher"
) || matches!(
name,
"rand.Int"
| "rand.Intn"
| "rand.Int31"
| "rand.Int31n"
| "rand.Int63"
| "rand.Int63n"
| "rand.Uint32"
| "rand.Uint64"
| "rand.Float32"
| "rand.Float64"
| "rand.Read"
| "md5.Sum"
| "md5.New"
| "sha1.Sum"
| "sha1.New"
| "des.NewCipher"
| "des.NewTripleDESCipher"
| "rc4.NewCipher"
)
}
fn source_imports_go_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"\"math/rand\"",
b"math/rand\"",
b"\"crypto/md5\"",
b"crypto/md5\"",
b"\"crypto/sha1\"",
b"crypto/sha1\"",
b"\"crypto/des\"",
b"crypto/des\"",
b"\"crypto/rc4\"",
b"crypto/rc4\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// crypto call through a hardened path (`crypto/rand` CSPRNG,
/// `crypto/sha256` or stronger, `crypto/aes` paired with `GCM`,
/// `golang.org/x/crypto/chacha20poly1305`).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"\"crypto/rand\"",
b"crypto/rand\"",
b"\"crypto/sha256\"",
b"crypto/sha256\"",
b"\"crypto/sha512\"",
b"crypto/sha512\"",
b"sha3.New",
b"chacha20poly1305",
b"cipher.NewGCM",
b"argon2.Key",
b"argon2.IDKey",
b"bcrypt.GenerateFromPassword",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoGoAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if source_routed_through_strong_path(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto);
let matches_source = source_imports_go_crypto(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_math_rand_intn() {
let src: &[u8] = b"package vuln\nimport \"math/rand\"\nfunc Run() int {\n return rand.Intn(1000)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("rand.Intn")],
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_md5_sum() {
let src: &[u8] = b"package vuln\nimport \"crypto/md5\"\nfunc Sign(b []byte) [16]byte {\n return md5.Sum(b)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Sign".into(),
callees: vec![crate::summary::CalleeSite::bare("md5.Sum")],
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_des_newcipher() {
let src: &[u8] = b"package vuln\nimport \"crypto/des\"\nimport \"crypto/cipher\"\nfunc Enc(key []byte) (cipher.Block, error) {\n return des.NewCipher(key)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Enc".into(),
callees: vec![crate::summary::CalleeSite::bare("des.NewCipher")],
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_crypto_rand() {
let src: &[u8] = b"package vuln\nimport \"math/rand\"\nimport \"crypto/rand\"\nfunc Run() ([]byte, error) {\n key := make([]byte, 32)\n if _, err := rand.Read(key); err != nil { return nil, err }\n return key, nil\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("rand.Read")],
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_when_sha256_in_source() {
let src: &[u8] = b"package vuln\nimport \"crypto/sha256\"\nfunc Sign(b []byte) [32]byte {\n return sha256.Sum256(b)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Sign".into(),
callees: vec![crate::summary::CalleeSite::bare("sha256.Sum256")],
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"package vuln\nfunc Add(a, b int) int { return a + b }\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Add".into(),
..Default::default()
};
assert!(
CryptoGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,254 @@
//! Rust [`super::super::FrameworkAdapter`] matching weak-crypto sink
//! constructions (`md5::compute` / `Md5::digest`, `sha1::Sha1::digest`,
//! `rand::random` / non-CSPRNG `rand::Rng::gen_*`, `crypto::des` DES /
//! `crypto::rc4` RC4 ciphers).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Rust weak-crypto entry points and the surrounding
//! source imports the matching crate.
//!
//! See sibling adapters [`super::crypto_python::CryptoPythonAdapter`],
//! [`super::crypto_java::CryptoJavaAdapter`],
//! [`super::crypto_ruby::CryptoRubyAdapter`], and
//! [`super::crypto_go::CryptoGoAdapter`] for the same shape on other
//! languages.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoRustAdapter;
const ADAPTER_NAME: &str = "crypto-rust";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"compute"
| "digest"
| "finalize"
| "random"
| "gen"
| "gen_range"
| "gen_bool"
| "thread_rng"
| "new_unkeyed"
) || matches!(
name,
"md5::compute"
| "Md5::digest"
| "Md5::new"
| "md_5::Md5::digest"
| "md_5::Md5::new"
| "sha1::Sha1::digest"
| "sha1::Sha1::new"
| "Sha1::digest"
| "Sha1::new"
| "rand::random"
| "rand::thread_rng"
| "rand::Rng::gen"
| "rand::Rng::gen_range"
| "rand::rngs::ThreadRng::gen"
| "Des::new"
| "TdesEde3::new"
| "Rc4::new"
)
}
fn source_imports_rust_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"use md5",
b"use md_5",
b"use sha1",
b"use sha_1",
b"use rand",
b"md5::",
b"md_5::Md5",
b"sha1::Sha1",
b"sha_1::Sha1",
b"rand::random",
b"rand::thread_rng",
b"rand::Rng",
b"des::Des",
b"rc4::Rc4",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// crypto call through a hardened path (CSPRNG via `getrandom` /
/// `OsRng`, SHA-256+ digests, AES-GCM / ChaCha20-Poly1305 / Argon2
/// authenticated encryption + KDF, `ring` constants).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"getrandom::getrandom",
b"rand::rngs::OsRng",
b"OsRng",
b"sha2::Sha256",
b"sha2::Sha384",
b"sha2::Sha512",
b"sha3::Sha3_256",
b"sha3::Sha3_512",
b"ring::digest::SHA256",
b"ring::digest::SHA384",
b"ring::digest::SHA512",
b"aes_gcm",
b"AesGcm",
b"chacha20poly1305",
b"ChaCha20Poly1305",
b"argon2::Argon2",
b"argon2::PasswordHash",
b"bcrypt::hash",
b"ed25519_dalek",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoRustAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if source_routed_through_strong_path(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto);
let matches_source = source_imports_rust_crypto(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_md5_compute() {
let src: &[u8] = b"use md5;\npub fn sign(value: &[u8]) -> md5::Digest {\n md5::compute(value)\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("md5::compute")],
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_sha1_digest() {
let src: &[u8] = b"use sha1::Sha1;\nuse sha1::Digest;\npub fn sign(value: &[u8]) -> Vec<u8> {\n let mut h = Sha1::new();\n h.update(value);\n h.finalize().to_vec()\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("Sha1::new")],
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_rand_random() {
let src: &[u8] = b"use rand;\npub fn token() -> u64 {\n rand::random::<u64>()\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "token".into(),
callees: vec![crate::summary::CalleeSite::bare("rand::random")],
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_osrng() {
let src: &[u8] = b"use rand;\nuse rand::rngs::OsRng;\nuse rand::RngCore;\npub fn token() -> [u8; 32] {\n let mut buf = [0u8; 32];\n OsRng.fill_bytes(&mut buf);\n buf\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "token".into(),
callees: vec![crate::summary::CalleeSite::bare("rand::random")],
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_when_sha256_in_source() {
let src: &[u8] = b"use sha2::Sha256;\nuse sha2::Digest;\npub fn sign(value: &[u8]) -> Vec<u8> {\n let mut h = Sha256::new();\n h.update(value);\n h.finalize().to_vec()\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("Sha256::new")],
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"pub fn add(a: i64, b: i64) -> i64 { a + b }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
CryptoRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,228 @@
//! Java [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`java.net.HttpURLConnection`, the modern
//! `java.net.http.HttpClient`, OkHttp, Apache HttpClient).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Java HTTP-client entry points and the surrounding
//! source imports the matching stdlib / third-party module.
//!
//! See sibling adapters
//! [`super::data_exfil_python::DataExfilPythonAdapter`],
//! [`super::data_exfil_js::DataExfilJsAdapter`],
//! [`super::data_exfil_go::DataExfilGoAdapter`], and
//! [`super::data_exfil_ruby::DataExfilRubyAdapter`] for the same
//! shape on other languages.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilJavaAdapter;
const ADAPTER_NAME: &str = "data-exfil-java";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"openConnection"
| "openStream"
| "send"
| "sendAsync"
| "execute"
| "newCall"
| "newBuilder"
| "build"
| "connect"
) || matches!(
name,
"java.net.URL.openConnection"
| "java.net.URL.openStream"
| "URL.openConnection"
| "URL.openStream"
| "HttpClient.send"
| "HttpClient.sendAsync"
| "HttpClient.newHttpClient"
| "HttpRequest.newBuilder"
| "OkHttpClient.newCall"
| "Call.execute"
| "HttpClients.createDefault"
| "CloseableHttpClient.execute"
| "Request.Builder.url"
)
}
fn source_imports_java_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"java.net.HttpURLConnection",
b"java.net.URL",
b"java.net.http.HttpClient",
b"java.net.http.HttpRequest",
b"okhttp3.OkHttpClient",
b"okhttp3.Request",
b"okhttp3.Call",
b"org.apache.http.client.HttpClient",
b"org.apache.http.impl.client.HttpClients",
b"org.apache.http.impl.client.CloseableHttpClient",
b"org.apache.hc.client5.http",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// outbound URL through a host-allowlist / network-policy gate.
fn host_routed_through_allowlist(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"ALLOWLIST",
b"ALLOWED_HOSTS",
b"allowedHosts",
b"allowlist",
b"\"127.0.0.1\"",
b"\"localhost\"",
b".equals(\"localhost\")",
b".contains(host)",
b".containsKey(host)",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilJavaAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Java
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if host_routed_through_allowlist(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_outbound_http);
let matches_source = source_imports_java_http_client(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_url_open_connection() {
let src: &[u8] = b"import java.net.HttpURLConnection;\nimport java.net.URL;\n\
public class Vuln {\n public static void run(String host) throws Exception {\n URL u = new URL(\"http://\" + host + \"/exfil\");\n HttpURLConnection conn = (HttpURLConnection) u.openConnection();\n conn.connect();\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("URL.openConnection")],
..Default::default()
};
assert!(
DataExfilJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_httpclient_send() {
let src: &[u8] = b"import java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.URI;\n\
public class Vuln {\n public static void run(String host) throws Exception {\n HttpClient c = HttpClient.newHttpClient();\n HttpRequest r = HttpRequest.newBuilder(URI.create(\"http://\" + host)).build();\n c.send(r, java.net.http.HttpResponse.BodyHandlers.discarding());\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("HttpRequest.newBuilder"),
crate::summary::CalleeSite::bare("HttpClient.send"),
],
..Default::default()
};
assert!(
DataExfilJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_okhttp_newcall_execute() {
let src: &[u8] = b"import okhttp3.OkHttpClient;\nimport okhttp3.Request;\n\
public class Vuln {\n public static void run(String host) throws Exception {\n OkHttpClient c = new OkHttpClient();\n Request r = new Request.Builder().url(\"http://\" + host).build();\n c.newCall(r).execute();\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("OkHttpClient.newCall"),
crate::summary::CalleeSite::bare("Call.execute"),
],
..Default::default()
};
assert!(
DataExfilJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_in_allowlist_literal() {
let src: &[u8] = b"import java.net.HttpURLConnection;\nimport java.net.URL;\n\
public class Vuln {\n public static void run(String host) throws Exception {\n if (!host.equals(\"127.0.0.1\")) { return; }\n URL u = new URL(\"http://\" + host + \"/exfil\");\n ((HttpURLConnection) u.openConnection()).connect();\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("URL.openConnection")],
..Default::default()
};
assert!(
DataExfilJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_method() {
let src: &[u8] = b"public class Plain { public static int add(int a, int b) { return a + b; } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,234 @@
//! PHP [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`curl_init` / `curl_exec`, `file_get_contents`
//! against a remote URL, `fopen`/`fsockopen`/`stream_socket_client`,
//! Guzzle).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical PHP HTTP-client entry points and the surrounding
//! source carries the `<?php` script-tag opener (PHP files always
//! open with a literal `<?php` tag in production code, mirroring the
//! [`super::crypto_php::CryptoPhpAdapter`] source-signal pattern).
//!
//! See sibling adapters
//! [`super::data_exfil_python::DataExfilPythonAdapter`],
//! [`super::data_exfil_js::DataExfilJsAdapter`],
//! [`super::data_exfil_go::DataExfilGoAdapter`],
//! [`super::data_exfil_ruby::DataExfilRubyAdapter`], and
//! [`super::data_exfil_java::DataExfilJavaAdapter`] for the same
//! shape on other languages.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilPhpAdapter;
const ADAPTER_NAME: &str = "data-exfil-php";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('\\').map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once("::").map(|(_, s)| s).unwrap_or(last);
let last = last.rsplit_once("->").map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"curl_init"
| "curl_exec"
| "curl_setopt"
| "curl_multi_exec"
| "file_get_contents"
| "fopen"
| "fsockopen"
| "stream_socket_client"
| "stream_context_create"
| "get"
| "post"
| "put"
| "delete"
| "request"
| "sendRequest"
| "send"
) || matches!(
name,
"curl_init"
| "curl_exec"
| "file_get_contents"
| "fopen"
| "fsockopen"
| "stream_socket_client"
| "GuzzleHttp\\Client.get"
| "GuzzleHttp\\Client.post"
| "GuzzleHttp\\Client.request"
| "GuzzleHttp\\Client.send"
| "Symfony\\Component\\HttpClient\\HttpClient.create"
| "Symfony\\Contracts\\HttpClient\\HttpClientInterface.request"
)
}
fn source_imports_php_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"<?php",
b"<?=",
b"GuzzleHttp\\Client",
b"GuzzleHttp\\Psr7",
b"Symfony\\Component\\HttpClient",
b"Symfony\\Contracts\\HttpClient",
b"curl_init(",
b"curl_exec(",
b"file_get_contents(",
b"fsockopen(",
b"stream_socket_client(",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// outbound URL through a host-allowlist / network-policy gate.
fn host_routed_through_allowlist(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"ALLOWLIST",
b"allowlist",
b"ALLOWED_HOSTS",
b"allowed_hosts",
b"'127.0.0.1'",
b"\"127.0.0.1\"",
b"'localhost'",
b"\"localhost\"",
b"in_array($host",
b"isset($allow",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilPhpAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Php
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if host_routed_through_allowlist(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_outbound_http);
let matches_source = source_imports_php_http_client(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_curl_init_exec() {
let src: &[u8] = b"<?php\nfunction run($host) {\n $ch = curl_init('http://' . $host . '/exfil');\n curl_exec($ch);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("curl_init"),
crate::summary::CalleeSite::bare("curl_exec"),
],
..Default::default()
};
assert!(
DataExfilPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_file_get_contents_remote() {
let src: &[u8] = b"<?php\nfunction run($host) {\n return file_get_contents('http://' . $host . '/exfil');\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("file_get_contents")],
..Default::default()
};
assert!(
DataExfilPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_guzzle_request() {
let src: &[u8] = b"<?php\nuse GuzzleHttp\\Client;\nfunction run($host) {\n $c = new Client();\n $c->request('GET', 'http://' . $host);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("request")],
..Default::default()
};
assert!(
DataExfilPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_in_allowlist_literal() {
let src: &[u8] = b"<?php\nfunction run($host) {\n if ($host !== '127.0.0.1') { return; }\n $ch = curl_init('http://' . $host);\n curl_exec($ch);\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("curl_init")],
..Default::default()
};
assert!(
DataExfilPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,239 @@
//! Rust [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`reqwest::get`, `reqwest::blocking::get`,
//! `reqwest::Client::*`, `hyper::Client::request`, `ureq::get`,
//! `surf::get`, `isahc::get`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Rust HTTP-client entry points and the surrounding
//! source imports the matching crate.
//!
//! See sibling adapters
//! [`super::data_exfil_python::DataExfilPythonAdapter`],
//! [`super::data_exfil_js::DataExfilJsAdapter`],
//! [`super::data_exfil_go::DataExfilGoAdapter`],
//! [`super::data_exfil_ruby::DataExfilRubyAdapter`],
//! [`super::data_exfil_java::DataExfilJavaAdapter`], and
//! [`super::data_exfil_php::DataExfilPhpAdapter`] for the same shape
//! on other languages.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilRustAdapter;
const ADAPTER_NAME: &str = "data-exfil-rust";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"get"
| "post"
| "put"
| "patch"
| "delete"
| "head"
| "send"
| "send_async"
| "execute"
| "fetch"
| "request"
| "call"
) || matches!(
name,
"reqwest::get"
| "reqwest::blocking::get"
| "reqwest::Client::get"
| "reqwest::Client::post"
| "reqwest::Client::execute"
| "reqwest::blocking::Client::get"
| "reqwest::blocking::Client::post"
| "reqwest::blocking::Client::execute"
| "reqwest::RequestBuilder::send"
| "reqwest::blocking::RequestBuilder::send"
| "hyper::Client::request"
| "hyper::Client::get"
| "ureq::get"
| "ureq::post"
| "ureq::request"
| "surf::get"
| "surf::post"
| "isahc::get"
| "isahc::post"
| "isahc::send"
)
}
fn source_imports_rust_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"use reqwest",
b"reqwest::",
b"use hyper",
b"hyper::Client",
b"use ureq",
b"ureq::",
b"use surf",
b"surf::",
b"use isahc",
b"isahc::",
b"use awc",
b"awc::Client",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
/// Returns `true` when the surrounding source visibly routes the
/// outbound URL through a host-allowlist / network-policy gate.
fn host_routed_through_allowlist(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"ALLOWLIST",
b"allowlist",
b"ALLOWED_HOSTS",
b"allowed_hosts",
b"\"127.0.0.1\"",
b"\"localhost\"",
b".contains(host)",
b".contains(&host)",
b".contains(\"localhost\")",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilRustAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if host_routed_through_allowlist(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_outbound_http);
let matches_source = source_imports_rust_http_client(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_reqwest_blocking_get() {
let src: &[u8] = b"use reqwest;\npub fn run(host: &str) -> Result<(), Box<dyn std::error::Error>> {\n let url = format!(\"http://{}/exfil\", host);\n let _ = reqwest::blocking::get(&url)?;\n Ok(())\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("reqwest::blocking::get")],
..Default::default()
};
assert!(
DataExfilRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_reqwest_client_post() {
let src: &[u8] = b"use reqwest::Client;\npub async fn run(host: &str) -> Result<(), Box<dyn std::error::Error>> {\n let c = Client::new();\n let _ = c.post(format!(\"http://{}/exfil\", host)).send().await?;\n Ok(())\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("reqwest::Client::post"),
crate::summary::CalleeSite::bare("reqwest::RequestBuilder::send"),
],
..Default::default()
};
assert!(
DataExfilRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_ureq_get() {
let src: &[u8] = b"use ureq;\npub fn run(host: &str) -> Result<(), ureq::Error> {\n let _ = ureq::get(&format!(\"http://{}/exfil\", host)).call()?;\n Ok(())\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("ureq::get"),
crate::summary::CalleeSite::bare("call"),
],
..Default::default()
};
assert!(
DataExfilRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_in_allowlist_literal() {
let src: &[u8] = b"use reqwest;\npub fn run(host: &str) -> Result<(), Box<dyn std::error::Error>> {\n if host != \"127.0.0.1\" { return Ok(()); }\n let _ = reqwest::blocking::get(format!(\"http://{}/\", host))?;\n Ok(())\n}\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("reqwest::blocking::get")],
..Default::default()
};
assert!(
DataExfilRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"pub fn add(a: i64, b: i64) -> i64 { a + b }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -11,15 +11,20 @@
//! the route / framework adapters; the per-cap sink adapters live
//! here so the per-language verticals can ship independently.
pub mod crypto_go;
pub mod crypto_java;
pub mod crypto_js;
pub mod crypto_php;
pub mod crypto_python;
pub mod crypto_ruby;
pub mod crypto_rust;
pub mod data_exfil_go;
pub mod data_exfil_java;
pub mod data_exfil_js;
pub mod data_exfil_php;
pub mod data_exfil_python;
pub mod data_exfil_ruby;
pub mod data_exfil_rust;
pub mod go_chi;
pub mod go_echo;
pub mod go_fiber;
@ -131,15 +136,20 @@ pub mod xxe_php;
pub mod xxe_python;
pub mod xxe_ruby;
pub use crypto_go::CryptoGoAdapter;
pub use crypto_java::CryptoJavaAdapter;
pub use crypto_js::CryptoJsAdapter;
pub use crypto_php::CryptoPhpAdapter;
pub use crypto_python::CryptoPythonAdapter;
pub use crypto_ruby::CryptoRubyAdapter;
pub use crypto_rust::CryptoRustAdapter;
pub use data_exfil_go::DataExfilGoAdapter;
pub use data_exfil_java::DataExfilJavaAdapter;
pub use data_exfil_js::DataExfilJsAdapter;
pub use data_exfil_php::DataExfilPhpAdapter;
pub use data_exfil_python::DataExfilPythonAdapter;
pub use data_exfil_ruby::DataExfilRubyAdapter;
pub use data_exfil_rust::DataExfilRustAdapter;
pub use go_chi::GoChiAdapter;
pub use go_echo::GoEchoAdapter;
pub use go_fiber::GoFiberAdapter;

View file

@ -291,11 +291,17 @@ mod tests {
// CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby.
// Php: +1 (CryptoPhp) 12 → 13
// Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14
// Track L.9 closing slice (session-0017 of run 7d60):
// CRYPTO × {Go, Rust} + DATA_EXFIL × {Java, Php, Rust}.
// Go: +1 (CryptoGo) 12 → 13
// Java: +1 (DataExfilJava) 19 → 20
// Php: +1 (DataExfilPhp) 13 → 14
// Rust: +2 (CryptoRust, DataExfilRust) 8 → 10
let java_registered = registry::adapters_for(Lang::Java);
assert_eq!(
java_registered.len(),
19,
"Java must have Phase 21 baseline (18) + Track L.9 CryptoJava (1)",
20,
"Java must have Phase 21 baseline (18) + Track L.9 (CryptoJava, DataExfilJava)",
);
for adapter in java_registered {
assert_eq!(adapter.lang(), Lang::Java);
@ -303,8 +309,8 @@ mod tests {
let php_registered = registry::adapters_for(Lang::Php);
assert_eq!(
php_registered.len(),
13,
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)",
14,
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 (CryptoPhp, DataExfilPhp)",
);
for adapter in php_registered {
assert_eq!(adapter.lang(), Lang::Php);
@ -348,8 +354,8 @@ mod tests {
let go_registered = registry::adapters_for(Lang::Go);
assert_eq!(
go_registered.len(),
12,
"Go must have Phase 21 baseline (11) + Track L.9 DataExfilGo (1)",
13,
"Go must have Phase 21 baseline (11) + Track L.9 (CryptoGo, DataExfilGo)",
);
for adapter in go_registered {
assert_eq!(adapter.lang(), Lang::Go);
@ -357,8 +363,8 @@ mod tests {
let rust_registered = registry::adapters_for(Lang::Rust);
assert_eq!(
rust_registered.len(),
8,
"Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1)",
10,
"Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1) + Track L.9 (CryptoRust, DataExfilRust)",
);
for adapter in rust_registered {
assert_eq!(adapter.lang(), Lang::Rust);

View file

@ -45,6 +45,8 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
// later phase that appends a new adapter cannot silently re-order
// the existing first-match.
static RUST: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoRustAdapter,
&super::adapters::DataExfilRustAdapter,
&super::adapters::GraphqlJuniperAdapter,
&super::adapters::HeaderRustAdapter,
&super::adapters::MigrationRefineryAdapter,
@ -58,6 +60,7 @@ static C: &[&dyn FrameworkAdapter] = &[];
static CPP: &[&dyn FrameworkAdapter] = &[];
static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoJavaAdapter,
&super::adapters::DataExfilJavaAdapter,
&super::adapters::HeaderJavaAdapter,
&super::adapters::JavaDeserializeAdapter,
&super::adapters::JavaMicronautAdapter,
@ -78,6 +81,7 @@ static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxeJavaAdapter,
];
static GO: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoGoAdapter,
&super::adapters::DataExfilGoAdapter,
&super::adapters::GoChiAdapter,
&super::adapters::GoEchoAdapter,
@ -93,6 +97,7 @@ static GO: &[&dyn FrameworkAdapter] = &[
];
static PHP: &[&dyn FrameworkAdapter] = &[
&super::adapters::CryptoPhpAdapter,
&super::adapters::DataExfilPhpAdapter,
&super::adapters::HeaderPhpAdapter,
&super::adapters::LdapPhpAdapter,
&super::adapters::MiddlewareLaravelAdapter,