mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0015 (20260522T163126Z-7d60)
This commit is contained in:
parent
727bbbde7e
commit
9070b1af22
12 changed files with 697 additions and 18 deletions
212
src/dynamic/framework/adapters/crypto_php.rs
Normal file
212
src/dynamic/framework/adapters/crypto_php.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! PHP [`super::super::FrameworkAdapter`] matching weak-crypto sink
|
||||
//! constructions (`md5()` / `sha1()` for message digests,
|
||||
//! `mt_rand()` / `rand()` for key material, `mcrypt_encrypt()` and
|
||||
//! `mcrypt_create_iv()` legacy primitives, `hash('md5'|'sha1', …)`).
|
||||
//!
|
||||
//! Phase 11 (Track L.9). Fires when the function body invokes one
|
||||
//! of the canonical PHP weak-crypto entry points and the surrounding
|
||||
//! source is plausibly a PHP script (starts with `<?php` or uses the
|
||||
//! short `<?` tag).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct CryptoPhpAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "crypto-php";
|
||||
|
||||
fn callee_is_weak_crypto(name: &str) -> bool {
|
||||
let last = name
|
||||
.rsplit_once("::")
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or(name)
|
||||
.rsplit_once('\\')
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(
|
||||
last,
|
||||
"md5"
|
||||
| "sha1"
|
||||
| "md5_file"
|
||||
| "sha1_file"
|
||||
| "mt_rand"
|
||||
| "rand"
|
||||
| "mt_srand"
|
||||
| "srand"
|
||||
| "crc32"
|
||||
| "mcrypt_create_iv"
|
||||
| "mcrypt_encrypt"
|
||||
| "mcrypt_decrypt"
|
||||
| "uniqid"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_is_php_script(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[b"<?php", b"<?=", b"<?\n"];
|
||||
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 (`random_bytes`,
|
||||
/// `random_int`, `openssl_random_pseudo_bytes`, `sodium_crypto_*`,
|
||||
/// `hash('sha256', …)` or stronger, `openssl_encrypt` with GCM).
|
||||
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"random_bytes(",
|
||||
b"random_int(",
|
||||
b"openssl_random_pseudo_bytes(",
|
||||
b"sodium_crypto_",
|
||||
b"hash('sha256'",
|
||||
b"hash(\"sha256\"",
|
||||
b"hash('sha384'",
|
||||
b"hash(\"sha384\"",
|
||||
b"hash('sha512'",
|
||||
b"hash(\"sha512\"",
|
||||
b"hash('sha3-256'",
|
||||
b"hash(\"sha3-256\"",
|
||||
b"'aes-256-gcm'",
|
||||
b"\"aes-256-gcm\"",
|
||||
b"'chacha20-poly1305'",
|
||||
b"\"chacha20-poly1305\"",
|
||||
b"password_hash(",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for CryptoPhpAdapter {
|
||||
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 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_is_php_script(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_md5() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nfunction sign($value) {\n return md5($value);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("md5")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_mt_rand() {
|
||||
let src: &[u8] = b"<?php\nfunction key_byte() {\n return mt_rand(0, 255);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "key_byte".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("mt_rand")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_source_routes_through_random_bytes() {
|
||||
let src: &[u8] = b"<?php\nfunction key() {\n if (function_exists('random_bytes')) {\n return random_bytes(32);\n }\n return md5(uniqid());\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "key".into(),
|
||||
callees: vec![
|
||||
crate::summary::CalleeSite::bare("md5"),
|
||||
crate::summary::CalleeSite::bare("random_bytes"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_sha256_hashing_present() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nfunction sign($value) {\n return hash('sha256', $value);\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("hash")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"<?php\nfunction add($a, $b) {\n return $a + $b;\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
221
src/dynamic/framework/adapters/crypto_ruby.rs
Normal file
221
src/dynamic/framework/adapters/crypto_ruby.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
//! Ruby [`super::super::FrameworkAdapter`] matching weak-crypto sink
|
||||
//! constructions (`Digest::MD5` / `Digest::SHA1` / `OpenSSL::HMAC`
|
||||
//! over `MD5`/`SHA1`, `rand` / `srand` / `Random.rand` used for key
|
||||
//! material).
|
||||
//!
|
||||
//! Phase 11 (Track L.9). Fires when the function body invokes one
|
||||
//! of the canonical Ruby weak-crypto entry points and the
|
||||
//! surrounding source requires the matching stdlib module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct CryptoRubyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "crypto-ruby";
|
||||
|
||||
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,
|
||||
"hexdigest" | "digest" | "base64digest" | "file" | "rand" | "srand"
|
||||
) || matches!(
|
||||
name,
|
||||
"Digest::MD5.hexdigest"
|
||||
| "Digest::MD5.digest"
|
||||
| "Digest::MD5.base64digest"
|
||||
| "Digest::MD5.new"
|
||||
| "Digest::SHA1.hexdigest"
|
||||
| "Digest::SHA1.digest"
|
||||
| "Digest::SHA1.base64digest"
|
||||
| "Digest::SHA1.new"
|
||||
| "OpenSSL::Digest.new"
|
||||
| "OpenSSL::Digest::MD5.new"
|
||||
| "OpenSSL::Digest::SHA1.new"
|
||||
| "OpenSSL::HMAC.digest"
|
||||
| "OpenSSL::HMAC.hexdigest"
|
||||
| "Random.rand"
|
||||
| "Kernel.rand"
|
||||
| "Kernel.srand"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_ruby_crypto(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require 'digest'",
|
||||
b"require \"digest\"",
|
||||
b"require 'digest/md5'",
|
||||
b"require \"digest/md5\"",
|
||||
b"require 'digest/sha1'",
|
||||
b"require \"digest/sha1\"",
|
||||
b"require 'openssl'",
|
||||
b"require \"openssl\"",
|
||||
b"Digest::MD5",
|
||||
b"Digest::SHA1",
|
||||
b"OpenSSL::Digest",
|
||||
b"OpenSSL::HMAC",
|
||||
];
|
||||
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`, SHA-256+,
|
||||
/// `OpenSSL::Cipher.new("AES-256-GCM")`, libsodium).
|
||||
fn source_routed_through_strong_path(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require 'securerandom'",
|
||||
b"require \"securerandom\"",
|
||||
b"SecureRandom.",
|
||||
b"Digest::SHA256",
|
||||
b"Digest::SHA384",
|
||||
b"Digest::SHA512",
|
||||
b"\"SHA256\"",
|
||||
b"'SHA256'",
|
||||
b"\"SHA-256\"",
|
||||
b"'SHA-256'",
|
||||
b"\"SHA384\"",
|
||||
b"\"SHA512\"",
|
||||
b"\"AES-256-GCM\"",
|
||||
b"'AES-256-GCM'",
|
||||
b"\"ChaCha20-Poly1305\"",
|
||||
b"RbNaCl::",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for CryptoRubyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Ruby
|
||||
}
|
||||
|
||||
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_ruby_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_ruby(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_digest_md5_hexdigest() {
|
||||
let src: &[u8] = b"require 'digest'\n\
|
||||
def sign(value)\n Digest::MD5.hexdigest(value)\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_openssl_hmac_md5() {
|
||||
let src: &[u8] = b"require 'openssl'\n\
|
||||
def sign(key, value)\n OpenSSL::HMAC.hexdigest('MD5', key, value)\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("OpenSSL::HMAC.hexdigest")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_source_routes_through_securerandom() {
|
||||
let src: &[u8] = b"require 'digest'\nrequire 'securerandom'\n\
|
||||
def run(value)\n if value.include?('STRONG')\n SecureRandom.hex(32)\n else\n Digest::MD5.hexdigest(value)\n end\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Digest::MD5.hexdigest")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_sha256_in_source() {
|
||||
let src: &[u8] = b"require 'digest'\n\
|
||||
def sign(value)\n Digest::SHA256.hexdigest(value)\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "sign".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Digest::SHA256.hexdigest")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
CryptoRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
225
src/dynamic/framework/adapters/data_exfil_ruby.rs
Normal file
225
src/dynamic/framework/adapters/data_exfil_ruby.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! Ruby [`super::super::FrameworkAdapter`] matching outbound-HTTP
|
||||
//! sink constructions (`Net::HTTP.{get,get_response,post_form,start}`,
|
||||
//! `RestClient.{get,post}`, `HTTParty.{get,post}`, `Faraday.get`,
|
||||
//! `open-uri`'s `open(...)`).
|
||||
//!
|
||||
//! Phase 11 (Track L.9). Fires when the function body invokes one
|
||||
//! of the canonical Ruby HTTP-client entry points and the
|
||||
//! surrounding source requires the matching client module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct DataExfilRubyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "data-exfil-ruby";
|
||||
|
||||
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_response" | "post_form" | "request" | "start"
|
||||
) || matches!(
|
||||
name,
|
||||
"Net::HTTP.get"
|
||||
| "Net::HTTP.get_response"
|
||||
| "Net::HTTP.post_form"
|
||||
| "Net::HTTP.start"
|
||||
| "Net::HTTP::Get.new"
|
||||
| "Net::HTTP::Post.new"
|
||||
| "RestClient.get"
|
||||
| "RestClient.post"
|
||||
| "RestClient.put"
|
||||
| "RestClient.delete"
|
||||
| "RestClient::Request.execute"
|
||||
| "HTTParty.get"
|
||||
| "HTTParty.post"
|
||||
| "HTTParty.put"
|
||||
| "HTTParty.delete"
|
||||
| "Faraday.get"
|
||||
| "Faraday.post"
|
||||
| "Faraday.new"
|
||||
| "Faraday::Connection.get"
|
||||
| "URI.open"
|
||||
| "Kernel.open"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_ruby_http_client(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require 'net/http'",
|
||||
b"require \"net/http\"",
|
||||
b"require 'open-uri'",
|
||||
b"require \"open-uri\"",
|
||||
b"require 'rest-client'",
|
||||
b"require \"rest-client\"",
|
||||
b"require 'rest_client'",
|
||||
b"require 'httparty'",
|
||||
b"require \"httparty\"",
|
||||
b"require 'faraday'",
|
||||
b"require \"faraday\"",
|
||||
b"require 'http'",
|
||||
b"require \"http\"",
|
||||
b"Net::HTTP",
|
||||
b"RestClient.",
|
||||
b"HTTParty.",
|
||||
b"Faraday.",
|
||||
];
|
||||
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"host == 'localhost'",
|
||||
b"host == \"localhost\"",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for DataExfilRubyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Ruby
|
||||
}
|
||||
|
||||
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_ruby_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_ruby(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_net_http_get() {
|
||||
let src: &[u8] = b"require 'net/http'\n\
|
||||
def run(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
DataExfilRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_restclient_post() {
|
||||
let src: &[u8] = b"require 'rest-client'\n\
|
||||
def run(host)\n RestClient.post(\"http://#{host}/exfil\", { token: 'x' })\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("RestClient.post")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
DataExfilRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_faraday_get() {
|
||||
let src: &[u8] = b"require 'faraday'\n\
|
||||
def run(host)\n Faraday.get(\"http://#{host}/exfil\")\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Faraday.get")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
DataExfilRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_host_routed_through_allowlist() {
|
||||
let src: &[u8] = b"require 'net/http'\n\
|
||||
ALLOWLIST = ['127.0.0.1', 'localhost'].freeze\n\
|
||||
def run(host)\n return unless ALLOWLIST.include?(host)\n Net::HTTP.get(URI(\"http://#{host}/exfil\"))\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Net::HTTP.get")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
DataExfilRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
DataExfilRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,10 +13,13 @@
|
|||
|
||||
pub mod crypto_java;
|
||||
pub mod crypto_js;
|
||||
pub mod crypto_php;
|
||||
pub mod crypto_python;
|
||||
pub mod crypto_ruby;
|
||||
pub mod data_exfil_go;
|
||||
pub mod data_exfil_js;
|
||||
pub mod data_exfil_python;
|
||||
pub mod data_exfil_ruby;
|
||||
pub mod go_chi;
|
||||
pub mod go_echo;
|
||||
pub mod go_fiber;
|
||||
|
|
@ -130,10 +133,13 @@ pub mod xxe_ruby;
|
|||
|
||||
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 data_exfil_go::DataExfilGoAdapter;
|
||||
pub use data_exfil_js::DataExfilJsAdapter;
|
||||
pub use data_exfil_python::DataExfilPythonAdapter;
|
||||
pub use data_exfil_ruby::DataExfilRubyAdapter;
|
||||
pub use go_chi::GoChiAdapter;
|
||||
pub use go_echo::GoEchoAdapter;
|
||||
pub use go_fiber::GoFiberAdapter;
|
||||
|
|
|
|||
|
|
@ -287,6 +287,10 @@ mod tests {
|
|||
// Python: +2 (CryptoPython, DataExfilPython) 22 → 24
|
||||
// JavaScript: +2 (CryptoJs, DataExfilJs) 20 → 22
|
||||
// Go: +1 (DataExfilGo) 11 → 12
|
||||
// Track L.9 follow-up slice (session-0015 of run 7d60):
|
||||
// CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby.
|
||||
// Php: +1 (CryptoPhp) 12 → 13
|
||||
// Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14
|
||||
let java_registered = registry::adapters_for(Lang::Java);
|
||||
assert_eq!(
|
||||
java_registered.len(),
|
||||
|
|
@ -299,8 +303,8 @@ mod tests {
|
|||
let php_registered = registry::adapters_for(Lang::Php);
|
||||
assert_eq!(
|
||||
php_registered.len(),
|
||||
12,
|
||||
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2)",
|
||||
13,
|
||||
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)",
|
||||
);
|
||||
for adapter in php_registered {
|
||||
assert_eq!(adapter.lang(), Lang::Php);
|
||||
|
|
@ -317,8 +321,8 @@ mod tests {
|
|||
let ruby_registered = registry::adapters_for(Lang::Ruby);
|
||||
assert_eq!(
|
||||
ruby_registered.len(),
|
||||
12,
|
||||
"Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4)",
|
||||
14,
|
||||
"Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4) + Track L.9 (CryptoRuby, DataExfilRuby)",
|
||||
);
|
||||
for adapter in ruby_registered {
|
||||
assert_eq!(adapter.lang(), Lang::Ruby);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ static GO: &[&dyn FrameworkAdapter] = &[
|
|||
&super::adapters::XxeGoAdapter,
|
||||
];
|
||||
static PHP: &[&dyn FrameworkAdapter] = &[
|
||||
&super::adapters::CryptoPhpAdapter,
|
||||
&super::adapters::HeaderPhpAdapter,
|
||||
&super::adapters::LdapPhpAdapter,
|
||||
&super::adapters::MiddlewareLaravelAdapter,
|
||||
|
|
@ -132,6 +133,8 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[
|
|||
&super::adapters::XxePythonAdapter,
|
||||
];
|
||||
static RUBY: &[&dyn FrameworkAdapter] = &[
|
||||
&super::adapters::CryptoRubyAdapter,
|
||||
&super::adapters::DataExfilRubyAdapter,
|
||||
&super::adapters::HeaderRubyAdapter,
|
||||
&super::adapters::MiddlewareRailsAdapter,
|
||||
&super::adapters::MigrationRailsAdapter,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
|
||||
func RunPing(host string) {
|
||||
fmt.Print("__NYX_SINK_HIT__\n")
|
||||
cmd := exec.Command("sh", "-c", "echo hello "+host)
|
||||
cmd := exec.Command("/bin/sh", "-c", "/bin/echo hello "+host)
|
||||
out, _ := cmd.CombinedOutput()
|
||||
fmt.Print(string(out))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
<?php
|
||||
// Command injection — negative fixture.
|
||||
// Safe: uses escapeshellarg() to prevent shell injection.
|
||||
// Safe: uses escapeshellarg() and discards the captured output.
|
||||
// Entry: runPing($host) Cap: CODE_EXEC
|
||||
// Expected verdict: NotConfirmed
|
||||
//
|
||||
// Pairs with cmdi_positive.php for differential confirmation. The
|
||||
// captured output is intentionally discarded so a literal payload such
|
||||
// as "; echo NYX_PWN_CMDI" (which escapeshellarg already neutralises
|
||||
// against the shell parser) cannot leak through stdout and trip the
|
||||
// OutputContains oracle.
|
||||
|
||||
function runPing($host) {
|
||||
// Sink-reachability probe: we did reach the shell_exec call site.
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
// escapeshellarg wraps in single quotes and escapes internal quotes.
|
||||
$safe = escapeshellarg($host);
|
||||
// Capture but discard: command runs under the sanitiser, output is
|
||||
// not surfaced to the oracle.
|
||||
$output = shell_exec('echo hello ' . $safe);
|
||||
if ($output !== null) {
|
||||
echo $output;
|
||||
}
|
||||
unset($output);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
// Prints "root:" unconditionally without reading any file
|
||||
// and without emitting __NYX_SINK_HIT__.
|
||||
// Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
// Entry: readFile($userPath) Cap: FILE_IO
|
||||
// Entry: runReadFile($userPath) Cap: FILE_IO
|
||||
|
||||
function readFile($userPath) {
|
||||
function runReadFile($userPath) {
|
||||
// Coincidental oracle match — not a file read sink.
|
||||
echo "root: present\n";
|
||||
$x = strlen($userPath);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<?php
|
||||
// File I/O — negative fixture.
|
||||
// Safe: realpath + prefix validation prevents directory traversal.
|
||||
// Entry: readFile($userPath) Cap: FILE_IO
|
||||
// Entry: runReadFile($userPath) Cap: FILE_IO
|
||||
// Expected verdict: NotConfirmed
|
||||
|
||||
function readFile($userPath) {
|
||||
function runReadFile($userPath) {
|
||||
$baseDir = '/var/data';
|
||||
$filePath = realpath($baseDir . '/' . $userPath);
|
||||
if ($filePath === false || strpos($filePath, $baseDir . DIRECTORY_SEPARATOR) !== 0) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<?php
|
||||
// File I/O — positive fixture.
|
||||
// Vulnerable: reads file at user-controlled path without sanitization.
|
||||
// Entry: readFile($userPath) Cap: FILE_IO
|
||||
// Entry: runReadFile($userPath) Cap: FILE_IO
|
||||
// Expected verdict: Confirmed (../../../../etc/passwd → "root:" in output)
|
||||
|
||||
function readFile($userPath) {
|
||||
function runReadFile($userPath) {
|
||||
$filePath = '/var/data/' . $userPath;
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$content = @file_get_contents($filePath);
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ mod php_fixture_tests {
|
|||
|
||||
#[test]
|
||||
fn php_fileio_positive_is_confirmed() {
|
||||
let result = run_fixture("fileio_positive.php", "readFile", Cap::FILE_IO, 9);
|
||||
let result = run_fixture("fileio_positive.php", "runReadFile", Cap::FILE_IO, 9);
|
||||
if result.status == VerifyStatus::Unsupported
|
||||
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
|
||||
{
|
||||
|
|
@ -231,7 +231,7 @@ mod php_fixture_tests {
|
|||
|
||||
#[test]
|
||||
fn php_fileio_negative_is_not_confirmed() {
|
||||
let result = run_fixture("fileio_negative.php", "readFile", Cap::FILE_IO, 14);
|
||||
let result = run_fixture("fileio_negative.php", "runReadFile", Cap::FILE_IO, 14);
|
||||
if result.status == VerifyStatus::Unsupported
|
||||
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
|
||||
{
|
||||
|
|
@ -247,7 +247,7 @@ mod php_fixture_tests {
|
|||
|
||||
#[test]
|
||||
fn php_fileio_adversarial_is_oracle_collision() {
|
||||
let result = run_fixture("fileio_adversarial.php", "readFile", Cap::FILE_IO, 999);
|
||||
let result = run_fixture("fileio_adversarial.php", "runReadFile", Cap::FILE_IO, 999);
|
||||
if result.status == VerifyStatus::Unsupported
|
||||
&& result.reason == Some(UnsupportedReason::BackendUnavailable)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue