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

This commit is contained in:
pitboss 2026-05-22 17:17:50 -05:00
parent e360a1db58
commit 727bbbde7e
9 changed files with 1164 additions and 8 deletions

View file

@ -0,0 +1,184 @@
//! Java [`super::super::FrameworkAdapter`] matching weak-crypto
//! sink constructions (`java.util.Random.nextBytes`,
//! `MessageDigest.getInstance("MD5"|"SHA-1")`,
//! `Cipher.getInstance("DES"|"RC4"|"AES/ECB")`,
//! `KeyGenerator.getInstance("DES")`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Java weak-crypto entry points and the
//! surrounding source imports the matching `java.util.Random` /
//! `java.security.*` / `javax.crypto.*` module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoJavaAdapter;
const ADAPTER_NAME: &str = "crypto-java";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"nextBytes" | "nextInt" | "nextLong" | "nextFloat" | "nextDouble" | "getInstance"
) || matches!(
name,
"java.util.Random.nextBytes"
| "Random.nextBytes"
| "MessageDigest.getInstance"
| "Cipher.getInstance"
| "KeyGenerator.getInstance"
| "Mac.getInstance"
)
}
fn source_imports_java_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"java.util.Random",
b"java.security.MessageDigest",
b"javax.crypto.Cipher",
b"javax.crypto.KeyGenerator",
b"javax.crypto.Mac",
];
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 (`SecureRandom`,
/// `MessageDigest.getInstance("SHA-256")` or stronger,
/// `Cipher.getInstance("AES/GCM/...")`).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"java.security.SecureRandom",
b"SecureRandom.getInstanceStrong",
b"new SecureRandom",
b"\"SHA-256\"",
b"\"SHA-384\"",
b"\"SHA-512\"",
b"\"SHA3-256\"",
b"\"AES/GCM/",
b"\"AES/CBC/PKCS5Padding\"",
b"\"ChaCha20-Poly1305\"",
b"\"HmacSHA256\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoJavaAdapter {
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 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_java_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_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_util_random_nextbytes() {
let src: &[u8] = b"import java.util.Random;\n\
public class Vuln {\n public static byte[] run(String v) {\n Random r = new Random(0L);\n byte[] key = new byte[2];\n r.nextBytes(key);\n return key;\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("nextBytes")],
..Default::default()
};
assert!(
CryptoJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_message_digest_md5() {
let src: &[u8] = b"import java.security.MessageDigest;\n\
public class Vuln {\n public static byte[] sign(byte[] v) throws Exception {\n MessageDigest md = MessageDigest.getInstance(\"MD5\");\n return md.digest(v);\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("MessageDigest.getInstance")],
..Default::default()
};
assert!(
CryptoJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_secure_random() {
let src: &[u8] = b"import java.util.Random;\nimport java.security.SecureRandom;\n\
public class Vuln {\n public static byte[] run(String v) {\n if (v.contains(\"STRONG\")) { byte[] k = new byte[32]; new SecureRandom().nextBytes(k); return k; }\n Random r = new Random(0L);\n byte[] k = new byte[2];\n r.nextBytes(k);\n return k;\n }\n}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("nextBytes")],
..Default::default()
};
assert!(
CryptoJavaAdapter
.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!(
CryptoJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,188 @@
//! JavaScript [`super::super::FrameworkAdapter`] matching weak-crypto
//! sink constructions (`Math.random` for key material,
//! `crypto.createHash('md5'|'sha1')`, `crypto.createCipheriv('des'|'rc4')`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Node weak-crypto entry points and the
//! surrounding source imports the matching `crypto` module (or uses
//! `Math.random` for key material).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoJsAdapter;
const ADAPTER_NAME: &str = "crypto-js";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"random" | "createHash" | "createCipheriv" | "createCipher" | "pseudoRandomBytes"
) || matches!(
name,
"Math.random"
| "crypto.createHash"
| "crypto.createCipher"
| "crypto.createCipheriv"
| "crypto.pseudoRandomBytes"
)
}
fn source_imports_js_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require('crypto')",
b"require(\"crypto\")",
b"from 'crypto'",
b"from \"crypto\"",
b"import crypto",
b"Math.random(",
b"createHash('md5'",
b"createHash(\"md5\"",
b"createHash('sha1'",
b"createHash(\"sha1\"",
];
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.randomBytes` / `crypto.randomUUID` /
/// `createHash('sha256'+)`, `createCipheriv('aes-256-gcm')`).
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"crypto.randomBytes",
b"crypto.randomUUID",
b"crypto.randomInt",
b"crypto.webcrypto.getRandomValues",
b"createHash('sha256'",
b"createHash(\"sha256\"",
b"createHash('sha384'",
b"createHash(\"sha384\"",
b"createHash('sha512'",
b"createHash(\"sha512\"",
b"createCipheriv('aes-256-gcm'",
b"createCipheriv(\"aes-256-gcm\"",
b"createCipheriv('chacha20-poly1305'",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoJsAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
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_js_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_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_math_random_key() {
let src: &[u8] = b"function run(value) { return Math.random(); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("Math.random")],
..Default::default()
};
assert!(
CryptoJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_create_hash_md5() {
let src: &[u8] = b"const crypto = require('crypto');\nfunction sign(value) { return crypto.createHash('md5').update(value).digest('hex'); }\nmodule.exports = { sign };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("crypto.createHash")],
..Default::default()
};
assert!(
CryptoJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_random_bytes() {
let src: &[u8] = b"const crypto = require('crypto');\nfunction run(value) { if (value === 'STRONG') return crypto.randomBytes(32); return Math.random(); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("Math.random"),
crate::summary::CalleeSite::bare("crypto.randomBytes"),
],
..Default::default()
};
assert!(
CryptoJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"function add(a, b) { return a + b; }\nmodule.exports = { add };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
CryptoJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,202 @@
//! Python [`super::super::FrameworkAdapter`] matching weak-crypto
//! sink constructions (`random.randint` / `random.random` for key
//! material, `hashlib.md5` / `hashlib.sha1` used without
//! `usedforsecurity=False`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Python weak-crypto entry points and the
//! surrounding source imports the matching stdlib module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct CryptoPythonAdapter;
const ADAPTER_NAME: &str = "crypto-python";
fn callee_is_weak_crypto(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"randint" | "random" | "uniform" | "choice" | "seed" | "md5" | "sha1" | "new"
) || matches!(
name,
"random.randint"
| "random.random"
| "random.uniform"
| "random.choice"
| "random.seed"
| "hashlib.md5"
| "hashlib.sha1"
| "Crypto.Hash.MD5.new"
| "Crypto.Hash.SHA1.new"
)
}
fn source_imports_python_crypto(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"import random",
b"from random ",
b"import hashlib",
b"from hashlib ",
b"from Crypto.Hash",
b"from Cryptodome.Hash",
];
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 CSPRNG / hardened path (`secrets.*`,
/// `os.urandom`, or hashlib called with `usedforsecurity=False`).
fn source_routed_through_csprng(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"secrets.token_bytes",
b"secrets.token_hex",
b"secrets.token_urlsafe",
b"secrets.randbits",
b"secrets.choice",
b"secrets.SystemRandom",
b"os.urandom(",
b"usedforsecurity=False",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for CryptoPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if source_routed_through_csprng(file_bytes) {
return None;
}
let matches_call = super::any_callee_matches(summary, callee_is_weak_crypto);
let matches_source = source_imports_python_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_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_random_randint() {
let src: &[u8] = b"import random\n\
def run(value):\n return random.randint(0, 0xFFFF)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("random.randint")],
..Default::default()
};
assert!(
CryptoPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_hashlib_md5() {
let src: &[u8] = b"import hashlib\n\
def sign(value):\n return hashlib.md5(value).hexdigest()\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "sign".into(),
callees: vec![crate::summary::CalleeSite::bare("hashlib.md5")],
..Default::default()
};
assert!(
CryptoPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_source_routes_through_secrets() {
let src: &[u8] = b"import random\nimport secrets\n\
def run(value):\n if 'STRONG' in value:\n return secrets.token_bytes(32)\n return random.randint(0, 0xFFFF)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![
crate::summary::CalleeSite::bare("random.randint"),
crate::summary::CalleeSite::bare("secrets.token_bytes"),
],
..Default::default()
};
assert!(
CryptoPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_when_md5_used_for_non_security() {
let src: &[u8] = b"import hashlib\n\
def cache_key(value):\n return hashlib.md5(value, usedforsecurity=False).hexdigest()\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "cache_key".into(),
callees: vec![crate::summary::CalleeSite::bare("hashlib.md5")],
..Default::default()
};
assert!(
CryptoPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b):\n return a + b\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
CryptoPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,170 @@
//! Go [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`http.Get`, `http.Post`, `http.NewRequest`,
//! `http.DefaultClient.Do`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Go HTTP-client entry points and the
//! surrounding source imports `net/http`.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilGoAdapter;
const ADAPTER_NAME: &str = "data-exfil-go";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"Get" | "Post" | "PostForm" | "Head" | "Do" | "NewRequest" | "NewRequestWithContext"
) || matches!(
name,
"http.Get"
| "http.Post"
| "http.PostForm"
| "http.Head"
| "http.NewRequest"
| "http.NewRequestWithContext"
| "http.DefaultClient.Do"
| "http.Client.Do"
)
}
fn source_imports_go_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"\"net/http\"",
b"net/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"allowlist",
b"AllowedHosts",
b"allowedHosts",
b"\"127.0.0.1\"",
b"\"localhost\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilGoAdapter {
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 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_go_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_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_http_get() {
let src: &[u8] = b"package vuln\nimport \"net/http\"\nfunc Run(host string) {\n http.Get(\"http://\" + host + \"/exfil\")\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("http.Get")],
..Default::default()
};
assert!(
DataExfilGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_http_post() {
let src: &[u8] = b"package vuln\nimport (\n \"net/http\"\n \"strings\"\n)\nfunc Run(host string) {\n http.Post(\"http://\" + host + \"/exfil\", \"application/json\", strings.NewReader(\"{}\"))\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("http.Post")],
..Default::default()
};
assert!(
DataExfilGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_in_allowlist_literal() {
let src: &[u8] = b"package vuln\nimport \"net/http\"\nfunc Run(host string) {\n if host != \"127.0.0.1\" { return }\n http.Get(\"http://\" + host + \"/exfil\")\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("http.Get")],
..Default::default()
};
assert!(
DataExfilGoAdapter
.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!(
DataExfilGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,192 @@
//! JavaScript [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`http.request`, `https.request`, `fetch`,
//! `axios.{get,post,put}`, `node-fetch`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Node HTTP-client entry points and the
//! surrounding source imports the matching client module (or uses
//! the global `fetch` API).
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilJsAdapter;
const ADAPTER_NAME: &str = "data-exfil-js";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"request" | "get" | "post" | "put" | "patch" | "delete" | "fetch" | "send"
) || matches!(
name,
"http.request"
| "https.request"
| "http.get"
| "https.get"
| "axios.get"
| "axios.post"
| "axios.put"
| "axios.patch"
| "axios.delete"
| "axios.request"
| "fetch"
)
}
fn source_imports_js_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require('http')",
b"require(\"http\")",
b"require('https')",
b"require(\"https\")",
b"require('axios')",
b"require(\"axios\")",
b"require('node-fetch')",
b"require(\"node-fetch\")",
b"from 'axios'",
b"from \"axios\"",
b"from 'node-fetch'",
b"from \"node-fetch\"",
b"from 'http'",
b"from \"http\"",
b"from 'https'",
b"from \"https\"",
b"fetch(",
b"globalThis.fetch",
];
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"allowedHosts",
b"['127.0.0.1'",
b"[\"127.0.0.1\"",
b"Set(['127.0.0.1'",
b"Set([\"127.0.0.1\"",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilJsAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
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_js_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_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_http_request() {
let src: &[u8] = b"const http = require('http');\nfunction run(host) { const req = http.request({ host, path: '/exfil', method: 'POST' }); req.end(); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("http.request")],
..Default::default()
};
assert!(
DataExfilJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_axios_post() {
let src: &[u8] = b"const axios = require('axios');\nasync function run(host) { await axios.post(`http://${host}/exfil`, { token: 'x' }); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("axios.post")],
..Default::default()
};
assert!(
DataExfilJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_routed_through_allowlist() {
let src: &[u8] = b"const http = require('http');\nconst ALLOWLIST = new Set(['127.0.0.1', 'localhost']);\nfunction run(host) { if (!ALLOWLIST.has(host)) return; const req = http.request({ host, path: '/exfil' }); req.end(); }\nmodule.exports = { run };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("http.request")],
..Default::default()
};
assert!(
DataExfilJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"function add(a, b) { return a + b; }\nmodule.exports = { add };\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -0,0 +1,194 @@
//! Python [`super::super::FrameworkAdapter`] matching outbound-HTTP
//! sink constructions (`urllib.request.urlopen`, `requests.{get,post,put}`,
//! `httpx.{get,post}`, `aiohttp.ClientSession.post`).
//!
//! Phase 11 (Track L.9). Fires when the function body invokes one
//! of the canonical Python HTTP-client entry points and the
//! surrounding source imports the matching client module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct DataExfilPythonAdapter;
const ADAPTER_NAME: &str = "data-exfil-python";
fn callee_is_outbound_http(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"urlopen"
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "request"
| "Request"
| "send"
) || matches!(
name,
"urllib.request.urlopen"
| "requests.get"
| "requests.post"
| "requests.put"
| "requests.patch"
| "requests.delete"
| "requests.request"
| "httpx.get"
| "httpx.post"
| "httpx.AsyncClient.post"
| "aiohttp.ClientSession.post"
)
}
fn source_imports_python_http_client(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"import urllib.request",
b"from urllib.request",
b"import requests",
b"from requests",
b"import httpx",
b"from httpx",
b"import aiohttp",
b"from aiohttp",
];
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"in {'127.0.0.1'",
b"in (\"127.0.0.1\"",
b"in {\"127.0.0.1\"",
b"if host == 'localhost'",
b"netloc in ",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for DataExfilPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
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_python_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_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_urlopen() {
let src: &[u8] = b"import urllib.request\n\
def run(host):\n urllib.request.urlopen(f\"http://{host}/exfil\")\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("urllib.request.urlopen")],
..Default::default()
};
assert!(
DataExfilPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn fires_on_requests_post() {
let src: &[u8] = b"import requests\n\
def run(host):\n requests.post(f\"http://{host}/exfil\", data={'token': 'x'})\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("requests.post")],
..Default::default()
};
assert!(
DataExfilPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some()
);
}
#[test]
fn skips_when_host_routed_through_allowlist() {
let src: &[u8] = b"import requests\n\
ALLOWLIST = {'127.0.0.1', 'localhost'}\n\
def run(host):\n if host not in ALLOWLIST:\n return\n requests.post(f\"http://{host}/exfil\")\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("requests.post")],
..Default::default()
};
assert!(
DataExfilPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b):\n return a + b\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(
DataExfilPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none()
);
}
}

View file

@ -11,6 +11,12 @@
//! the route / framework adapters; the per-cap sink adapters live
//! here so the per-language verticals can ship independently.
pub mod crypto_java;
pub mod crypto_js;
pub mod crypto_python;
pub mod data_exfil_go;
pub mod data_exfil_js;
pub mod data_exfil_python;
pub mod go_chi;
pub mod go_echo;
pub mod go_fiber;
@ -122,6 +128,12 @@ pub mod xxe_php;
pub mod xxe_python;
pub mod xxe_ruby;
pub use crypto_java::CryptoJavaAdapter;
pub use crypto_js::CryptoJsAdapter;
pub use crypto_python::CryptoPythonAdapter;
pub use data_exfil_go::DataExfilGoAdapter;
pub use data_exfil_js::DataExfilJsAdapter;
pub use data_exfil_python::DataExfilPythonAdapter;
pub use go_chi::GoChiAdapter;
pub use go_echo::GoEchoAdapter;
pub use go_fiber::GoFiberAdapter;