mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
[pitboss] phase 06: Track J.4 + Track L.4 — LDAP_INJECTION corpus + LdapTemplate / python-ldap / php-ldap adapters
This commit is contained in:
parent
993bfabe28
commit
b2eeaabb09
27 changed files with 2189 additions and 18 deletions
|
|
@ -50,6 +50,7 @@ pub mod registry;
|
||||||
mod cmdi;
|
mod cmdi;
|
||||||
mod deserialize;
|
mod deserialize;
|
||||||
mod fmt_string;
|
mod fmt_string;
|
||||||
|
mod ldap;
|
||||||
mod path_trav;
|
mod path_trav;
|
||||||
mod sqli;
|
mod sqli;
|
||||||
mod ssrf;
|
mod ssrf;
|
||||||
|
|
@ -88,7 +89,8 @@ pub use crate::dynamic::oracle::Oracle;
|
||||||
/// | 7 | 2026-05-17 | Phase 03 / Track J.1: `DESERIALIZE` cap lit for Java / Python / PHP / Ruby; `ProbeKind::Deserialize` + `ProbePredicate::DeserializeGadgetInvoked` |
|
/// | 7 | 2026-05-17 | Phase 03 / Track J.1: `DESERIALIZE` cap lit for Java / Python / PHP / Ruby; `ProbeKind::Deserialize` + `ProbePredicate::DeserializeGadgetInvoked` |
|
||||||
/// | 8 | 2026-05-17 | Phase 04 / Track J.2: `SSTI` cap lit for Jinja2 / ERB / Twig / Thymeleaf / Handlebars; `ProbePredicate::TemplateEvalEqual` |
|
/// | 8 | 2026-05-17 | Phase 04 / Track J.2: `SSTI` cap lit for Jinja2 / ERB / Twig / Thymeleaf / Handlebars; `ProbePredicate::TemplateEvalEqual` |
|
||||||
/// | 9 | 2026-05-17 | Phase 05 / Track J.3: `XXE` cap lit for Java / Python / PHP / Ruby / Go; `ProbeKind::Xxe` + `ProbePredicate::XxeEntityExpanded` |
|
/// | 9 | 2026-05-17 | Phase 05 / Track J.3: `XXE` cap lit for Java / Python / PHP / Ruby / Go; `ProbeKind::Xxe` + `ProbePredicate::XxeEntityExpanded` |
|
||||||
pub const CORPUS_VERSION: u32 = 9;
|
/// | 10 | 2026-05-17 | Phase 06 / Track J.4: `LDAP_INJECTION` cap lit for Java / Python / PHP; `ProbeKind::Ldap` + `ProbePredicate::LdapResultCountGreaterThan`; `StubKind::Ldap` + in-sandbox LDAP server stub |
|
||||||
|
pub const CORPUS_VERSION: u32 = 10;
|
||||||
|
|
||||||
/// Where a payload originated.
|
/// Where a payload originated.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
|
||||||
53
src/dynamic/corpus/ldap/java.rs
Normal file
53
src/dynamic/corpus/ldap/java.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
//! Java `Cap::LDAP_INJECTION` payloads — `LdapTemplate.search` /
|
||||||
|
//! `DirContext.search` filter injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||||
|
//! the host template's `(uid=…)` clause and rewraps the search as
|
||||||
|
//! `(|(uid=…)(uid=*))`, matching every user the directory carries.
|
||||||
|
//! The harness's instrumented LDAP client (talking to
|
||||||
|
//! [`crate::dynamic::stubs::ldap_server`]) records
|
||||||
|
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||||
|
//!
|
||||||
|
//! Benign control: the same intended username quoted through
|
||||||
|
//! `EscapeDN` so the LDAP filter stays pinned to a single entry; the
|
||||||
|
//! shim records `entries_returned: 1` and the oracle does not fire.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice*)(uid=*",
|
||||||
|
label: "ldap-java-filter-wildcard",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/java/Vuln.java"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "ldap-java-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice",
|
||||||
|
label: "ldap-java-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/java/Benign.java"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
30
src/dynamic/corpus/ldap/mod.rs
Normal file
30
src/dynamic/corpus/ldap/mod.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
//! LDAP filter injection (`Cap::LDAP_INJECTION`) per-language payload
|
||||||
|
//! slices.
|
||||||
|
//!
|
||||||
|
//! Phase 06 (Track J.4) carves LDAP filter injection across the three
|
||||||
|
//! most-common directory clients: Java (`LdapTemplate.search` /
|
||||||
|
//! `DirContext.search`), Python (`ldap.search_s`), and PHP
|
||||||
|
//! (`ldap_search`). Every vuln payload appends the canonical
|
||||||
|
//! `*)(uid=*` quote-escape break — once the host code substitutes the
|
||||||
|
//! attacker bytes into its filter template the synthesized LDAP
|
||||||
|
//! filter matches every entry the directory carries (the
|
||||||
|
//! [`crate::dynamic::stubs::ldap_server`] stub returns its three
|
||||||
|
//! provisioned users). The paired benign control quotes the same
|
||||||
|
//! bytes through `EscapeDN` / `ldap.dn.escape_filter_chars` /
|
||||||
|
//! `ldap_escape`, leaving the filter pinned to the originally
|
||||||
|
//! intended single user.
|
||||||
|
//!
|
||||||
|
//! The oracle's
|
||||||
|
//! [`crate::dynamic::oracle::ProbePredicate::LdapResultCountGreaterThan`]
|
||||||
|
//! checks the per-payload `ProbeKind::Ldap.entries_returned` against
|
||||||
|
//! `n = 1` — vuln passes (3 entries), benign clears (1 entry),
|
||||||
|
//! fulfilling the §4.1 differential rule.
|
||||||
|
//!
|
||||||
|
//! C# is intentionally omitted: the [`crate::symbol::Lang`] enum has
|
||||||
|
//! no `CSharp` variant, so the corpus has nowhere to register it.
|
||||||
|
//! Tracked in `.pitboss/play/deferred.md` alongside the Phase 05
|
||||||
|
//! Lang::CSharp gap.
|
||||||
|
|
||||||
|
pub mod java;
|
||||||
|
pub mod php;
|
||||||
|
pub mod python;
|
||||||
51
src/dynamic/corpus/ldap/php.rs
Normal file
51
src/dynamic/corpus/ldap/php.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
//! PHP `Cap::LDAP_INJECTION` payloads — `ldap_search` filter injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||||
|
//! the host template's `(uid=…)` clause; the synthesized filter
|
||||||
|
//! becomes `(|(uid=…)(uid=*))` and matches every directory entry.
|
||||||
|
//! The harness's instrumented `ldap_search` records
|
||||||
|
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||||
|
//!
|
||||||
|
//! Benign control: the same intended username quoted via
|
||||||
|
//! `ldap_escape($value, "", LDAP_ESCAPE_FILTER)` — `entries_returned:
|
||||||
|
//! 1`, oracle clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice*)(uid=*",
|
||||||
|
label: "ldap-php-filter-wildcard",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/php/vuln.php"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "ldap-php-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice",
|
||||||
|
label: "ldap-php-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/php/benign.php"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
52
src/dynamic/corpus/ldap/python.rs
Normal file
52
src/dynamic/corpus/ldap/python.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
//! Python `Cap::LDAP_INJECTION` payloads — `ldap.search_s` filter
|
||||||
|
//! injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a filter fragment whose `*)(uid=*` tail breaks out of
|
||||||
|
//! the host template's `(uid=…)` clause; the synthesized filter
|
||||||
|
//! becomes `(|(uid=…)(uid=*))` and matches every directory entry.
|
||||||
|
//! The harness's instrumented `ldap.search_s` records
|
||||||
|
//! `ProbeKind::Ldap { entries_returned: 3 }`.
|
||||||
|
//!
|
||||||
|
//! Benign control: the same intended username quoted via
|
||||||
|
//! `ldap.dn.escape_filter_chars`, leaving the filter pinned to a
|
||||||
|
//! single entry — `entries_returned: 1`, oracle clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice*)(uid=*",
|
||||||
|
label: "ldap-python-filter-wildcard",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/python/vuln.py"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "ldap-python-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"alice",
|
||||||
|
label: "ldap-python-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::LdapResultCountGreaterThan { n: 1 }],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 10,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/ldap_injection/python/benign.py"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use super::{cmdi, deserialize, fmt_string, path_trav, sqli, ssrf, ssti, xss, xxe};
|
use super::{cmdi, deserialize, fmt_string, ldap, path_trav, sqli, ssrf, ssti, xss, xxe};
|
||||||
use super::{CapCorpus, CuratedPayload, Oracle};
|
use super::{CapCorpus, CuratedPayload, Oracle};
|
||||||
use crate::dynamic::oracle::ProbePredicate;
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
use crate::labels::Cap;
|
use crate::labels::Cap;
|
||||||
|
|
@ -40,7 +40,6 @@ pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits()
|
||||||
| Cap::CRYPTO.bits()
|
| Cap::CRYPTO.bits()
|
||||||
| Cap::UNAUTHORIZED_ID.bits()
|
| Cap::UNAUTHORIZED_ID.bits()
|
||||||
| Cap::DATA_EXFIL.bits()
|
| Cap::DATA_EXFIL.bits()
|
||||||
| Cap::LDAP_INJECTION.bits()
|
|
||||||
| Cap::XPATH_INJECTION.bits()
|
| Cap::XPATH_INJECTION.bits()
|
||||||
| Cap::HEADER_INJECTION.bits()
|
| Cap::HEADER_INJECTION.bits()
|
||||||
| Cap::OPEN_REDIRECT.bits()
|
| Cap::OPEN_REDIRECT.bits()
|
||||||
|
|
@ -69,6 +68,9 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
|
||||||
(Cap::XXE, Lang::Php, xxe::php::PAYLOADS),
|
(Cap::XXE, Lang::Php, xxe::php::PAYLOADS),
|
||||||
(Cap::XXE, Lang::Ruby, xxe::ruby::PAYLOADS),
|
(Cap::XXE, Lang::Ruby, xxe::ruby::PAYLOADS),
|
||||||
(Cap::XXE, Lang::Go, xxe::go::PAYLOADS),
|
(Cap::XXE, Lang::Go, xxe::go::PAYLOADS),
|
||||||
|
(Cap::LDAP_INJECTION, Lang::Java, ldap::java::PAYLOADS),
|
||||||
|
(Cap::LDAP_INJECTION, Lang::Python, ldap::python::PAYLOADS),
|
||||||
|
(Cap::LDAP_INJECTION, Lang::Php, ldap::php::PAYLOADS),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
|
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
|
||||||
|
|
@ -278,6 +280,7 @@ mod tests {
|
||||||
assert!(!payloads_for(Cap::DESERIALIZE).is_empty());
|
assert!(!payloads_for(Cap::DESERIALIZE).is_empty());
|
||||||
assert!(!payloads_for(Cap::SSTI).is_empty());
|
assert!(!payloads_for(Cap::SSTI).is_empty());
|
||||||
assert!(!payloads_for(Cap::XXE).is_empty());
|
assert!(!payloads_for(Cap::XXE).is_empty());
|
||||||
|
assert!(!payloads_for(Cap::LDAP_INJECTION).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -290,7 +293,6 @@ mod tests {
|
||||||
Cap::CRYPTO,
|
Cap::CRYPTO,
|
||||||
Cap::UNAUTHORIZED_ID,
|
Cap::UNAUTHORIZED_ID,
|
||||||
Cap::DATA_EXFIL,
|
Cap::DATA_EXFIL,
|
||||||
Cap::LDAP_INJECTION,
|
|
||||||
Cap::XPATH_INJECTION,
|
Cap::XPATH_INJECTION,
|
||||||
Cap::HEADER_INJECTION,
|
Cap::HEADER_INJECTION,
|
||||||
Cap::OPEN_REDIRECT,
|
Cap::OPEN_REDIRECT,
|
||||||
|
|
@ -325,6 +327,7 @@ mod tests {
|
||||||
Cap::DESERIALIZE,
|
Cap::DESERIALIZE,
|
||||||
Cap::SSTI,
|
Cap::SSTI,
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
|
Cap::LDAP_INJECTION,
|
||||||
] {
|
] {
|
||||||
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
||||||
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
|
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
|
||||||
|
|
@ -374,6 +377,7 @@ mod tests {
|
||||||
Cap::DESERIALIZE,
|
Cap::DESERIALIZE,
|
||||||
Cap::SSTI,
|
Cap::SSTI,
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
|
Cap::LDAP_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap) {
|
for p in payloads_for(cap) {
|
||||||
|
|
@ -398,6 +402,7 @@ mod tests {
|
||||||
Cap::DESERIALIZE,
|
Cap::DESERIALIZE,
|
||||||
Cap::SSTI,
|
Cap::SSTI,
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
|
Cap::LDAP_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap) {
|
for p in payloads_for(cap) {
|
||||||
|
|
@ -509,6 +514,7 @@ mod tests {
|
||||||
Cap::DESERIALIZE,
|
Cap::DESERIALIZE,
|
||||||
Cap::SSTI,
|
Cap::SSTI,
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
|
Cap::LDAP_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
|
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
|
||||||
|
|
@ -677,6 +683,49 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ldap_has_per_lang_slices_for_phase_06() {
|
||||||
|
// Phase 06 (Track J.4) acceptance: LDAP_INJECTION registers
|
||||||
|
// payloads in Java / Python / PHP and the lang-aware lookup
|
||||||
|
// never returns empty for any of them.
|
||||||
|
for lang in [Lang::Java, Lang::Python, Lang::Php] {
|
||||||
|
assert!(
|
||||||
|
!payloads_for_lang(Cap::LDAP_INJECTION, lang).is_empty(),
|
||||||
|
"LDAP_INJECTION must have at least one payload for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Rust / C / Cpp / Ruby / Go / JS / TS not yet covered.
|
||||||
|
for lang in [
|
||||||
|
Lang::Rust,
|
||||||
|
Lang::C,
|
||||||
|
Lang::Cpp,
|
||||||
|
Lang::Ruby,
|
||||||
|
Lang::Go,
|
||||||
|
Lang::JavaScript,
|
||||||
|
Lang::TypeScript,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
payloads_for_lang(Cap::LDAP_INJECTION, lang).is_empty(),
|
||||||
|
"LDAP_INJECTION has unexpected payloads for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ldap_payloads_pair_benign_controls_per_lang() {
|
||||||
|
for lang in [Lang::Java, Lang::Python, Lang::Php] {
|
||||||
|
let slice = payloads_for_lang(Cap::LDAP_INJECTION, lang);
|
||||||
|
let vuln = slice
|
||||||
|
.iter()
|
||||||
|
.find(|p| !p.is_benign)
|
||||||
|
.expect("each lang must have an LDAP vuln payload");
|
||||||
|
let resolved =
|
||||||
|
super::resolve_benign_control_lang(vuln, Cap::LDAP_INJECTION, lang)
|
||||||
|
.expect("lang-aware benign control must resolve");
|
||||||
|
assert!(resolved.is_benign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_payloads_pair_benign_controls_per_lang() {
|
fn deserialize_payloads_pair_benign_controls_per_lang() {
|
||||||
// The lang-aware resolver must find the paired benign control
|
// The lang-aware resolver must find the paired benign control
|
||||||
|
|
|
||||||
114
src/dynamic/framework/adapters/ldap_php.rs
Normal file
114
src/dynamic/framework/adapters/ldap_php.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
//! PHP [`super::super::FrameworkAdapter`] matching LDAP filter-injection
|
||||||
|
//! sink constructions.
|
||||||
|
//!
|
||||||
|
//! Phase 06 (Track J.4). Fires when the function body invokes one of
|
||||||
|
//! the canonical PHP directory-client entry points (`ldap_search`,
|
||||||
|
//! `ldap_list`, `ldap_read`) and the surrounding source mentions the
|
||||||
|
//! matching `ldap_*` API surface.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct LdapPhpAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "ldap-php";
|
||||||
|
|
||||||
|
fn callee_is_ldap_search(name: &str) -> bool {
|
||||||
|
let last = name
|
||||||
|
.rsplit_once("::")
|
||||||
|
.map(|(_, s)| s)
|
||||||
|
.or_else(|| name.rsplit_once('.').map(|(_, s)| s))
|
||||||
|
.or_else(|| name.rsplit_once("->").map(|(_, s)| s))
|
||||||
|
.unwrap_or(name);
|
||||||
|
matches!(last, "ldap_search" | "ldap_list" | "ldap_read")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"ldap_connect",
|
||||||
|
b"ldap_bind",
|
||||||
|
b"ldap_search",
|
||||||
|
b"ldap_list",
|
||||||
|
b"ldap_read",
|
||||||
|
b"ldap_escape",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for LdapPhpAdapter {
|
||||||
|
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> {
|
||||||
|
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||||
|
let matches_source = source_imports_ldap(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_ldap_search() {
|
||||||
|
let src: &[u8] = b"<?php\nfunction run($uid) {\n\
|
||||||
|
$c = ldap_connect('127.0.0.1');\n\
|
||||||
|
return ldap_search($c, 'ou=people', '(uid=' . $uid . ')');\n\
|
||||||
|
}\n";
|
||||||
|
let tree = parse_php(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("ldap_search")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(LdapPhpAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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!(LdapPhpAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/dynamic/framework/adapters/ldap_python.rs
Normal file
113
src/dynamic/framework/adapters/ldap_python.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
//! Python [`super::super::FrameworkAdapter`] matching LDAP filter-injection
|
||||||
|
//! sink constructions.
|
||||||
|
//!
|
||||||
|
//! Phase 06 (Track J.4). Fires when the function body invokes one of
|
||||||
|
//! the canonical `python-ldap` / `ldap3` entry points
|
||||||
|
//! (`ldap.search_s`, `ldap.search_ext_s`, `ldap.search`,
|
||||||
|
//! `Connection.search`) and the surrounding source mentions the
|
||||||
|
//! matching client module.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct LdapPythonAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "ldap-python";
|
||||||
|
|
||||||
|
fn callee_is_ldap_search(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(
|
||||||
|
last,
|
||||||
|
"search_s" | "search_ext_s" | "search" | "search_st" | "search_subtree_s"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"import ldap",
|
||||||
|
b"from ldap",
|
||||||
|
b"ldap3",
|
||||||
|
b"python-ldap",
|
||||||
|
b"ldap.initialize",
|
||||||
|
b"ldap.SCOPE",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for LdapPythonAdapter {
|
||||||
|
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> {
|
||||||
|
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||||
|
let matches_source = source_imports_ldap(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_ldap_search_s() {
|
||||||
|
let src: &[u8] = b"import ldap\n\
|
||||||
|
def run(uid):\n\
|
||||||
|
con = ldap.initialize('ldap://127.0.0.1')\n\
|
||||||
|
return con.search_s('ou=people', ldap.SCOPE_SUBTREE, '(uid=' + uid + ')')\n";
|
||||||
|
let tree = parse_python(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("search_s")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(LdapPythonAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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!(LdapPythonAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/dynamic/framework/adapters/ldap_spring.rs
Normal file
133
src/dynamic/framework/adapters/ldap_spring.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! Java [`super::super::FrameworkAdapter`] matching LDAP filter-injection
|
||||||
|
//! sink constructions.
|
||||||
|
//!
|
||||||
|
//! Phase 06 (Track J.4). Fires when the function body invokes one of
|
||||||
|
//! the canonical Java directory-client entry points
|
||||||
|
//! (`LdapTemplate.search`, `LdapTemplate.find`, `DirContext.search`,
|
||||||
|
//! `InitialDirContext.search`, `LdapContext.search`) and the
|
||||||
|
//! surrounding source pulls in one of the matching package symbols —
|
||||||
|
//! `org.springframework.ldap.*`, `javax.naming.directory.*`,
|
||||||
|
//! `com.unboundid.ldap.*`.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct LdapSpringAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "ldap-spring";
|
||||||
|
|
||||||
|
fn callee_is_ldap_search(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(
|
||||||
|
last,
|
||||||
|
"search" | "find" | "findAll" | "findOne" | "lookup" | "searchAll"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_ldap(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"org.springframework.ldap",
|
||||||
|
b"LdapTemplate",
|
||||||
|
b"javax.naming.directory",
|
||||||
|
b"InitialDirContext",
|
||||||
|
b"DirContext",
|
||||||
|
b"LdapContext",
|
||||||
|
b"com.unboundid.ldap",
|
||||||
|
b"SearchControls",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for LdapSpringAdapter {
|
||||||
|
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> {
|
||||||
|
let matches_call = super::any_callee_matches(summary, callee_is_ldap_search);
|
||||||
|
let matches_source = source_imports_ldap(file_bytes);
|
||||||
|
if matches_call && matches_source {
|
||||||
|
return Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Function,
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if matches_source
|
||||||
|
&& file_bytes
|
||||||
|
.windows(b".search(".len())
|
||||||
|
.any(|w| w == b".search(")
|
||||||
|
{
|
||||||
|
return Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Function,
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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_ldap_template_search() {
|
||||||
|
let src: &[u8] = b"import org.springframework.ldap.core.LdapTemplate;\n\
|
||||||
|
public class V {\n public Object run(String uid, LdapTemplate t) {\n\
|
||||||
|
return t.search(\"ou=people\", \"(uid=\" + uid + \")\", null);\n\
|
||||||
|
}\n}\n";
|
||||||
|
let tree = parse_java(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("search")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let binding = LdapSpringAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.expect("must fire on LdapTemplate.search");
|
||||||
|
assert_eq!(binding.adapter, ADAPTER_NAME);
|
||||||
|
assert_eq!(binding.kind, EntryKind::Function);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_plain_function() {
|
||||||
|
let src: &[u8] =
|
||||||
|
b"public class V { 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!(LdapSpringAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
pub mod java_deserialize;
|
pub mod java_deserialize;
|
||||||
pub mod java_thymeleaf;
|
pub mod java_thymeleaf;
|
||||||
pub mod js_handlebars;
|
pub mod js_handlebars;
|
||||||
|
pub mod ldap_php;
|
||||||
|
pub mod ldap_python;
|
||||||
|
pub mod ldap_spring;
|
||||||
pub mod php_twig;
|
pub mod php_twig;
|
||||||
pub mod php_unserialize;
|
pub mod php_unserialize;
|
||||||
pub mod python_jinja2;
|
pub mod python_jinja2;
|
||||||
|
|
@ -29,6 +32,9 @@ pub mod xxe_ruby;
|
||||||
pub use java_deserialize::JavaDeserializeAdapter;
|
pub use java_deserialize::JavaDeserializeAdapter;
|
||||||
pub use java_thymeleaf::JavaThymeleafAdapter;
|
pub use java_thymeleaf::JavaThymeleafAdapter;
|
||||||
pub use js_handlebars::JsHandlebarsAdapter;
|
pub use js_handlebars::JsHandlebarsAdapter;
|
||||||
|
pub use ldap_php::LdapPhpAdapter;
|
||||||
|
pub use ldap_python::LdapPythonAdapter;
|
||||||
|
pub use ldap_spring::LdapSpringAdapter;
|
||||||
pub use php_twig::PhpTwigAdapter;
|
pub use php_twig::PhpTwigAdapter;
|
||||||
pub use php_unserialize::PhpUnserializeAdapter;
|
pub use php_unserialize::PhpUnserializeAdapter;
|
||||||
pub use python_jinja2::PythonJinja2Adapter;
|
pub use python_jinja2::PythonJinja2Adapter;
|
||||||
|
|
|
||||||
|
|
@ -214,25 +214,35 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_baseline_after_phase_05() {
|
fn registry_baseline_after_phase_06() {
|
||||||
// Phase 05 (Track J.3) adds the XXE-sink adapter alongside the
|
// Phase 06 (Track J.4) adds the LDAP-sink adapter for Java /
|
||||||
// Phase-03 deserialize + Phase-04 SSTI adapters for Java /
|
// Python / PHP, layered on top of the Phase 03 deserialize +
|
||||||
// Python / PHP / Ruby, and introduces the first Go adapter
|
// Phase 04 SSTI + Phase 05 XXE adapters. Ruby still carries
|
||||||
// (xxe-go). JavaScript still has only the Handlebars adapter;
|
// exactly the 03+04+05 trio (no Ruby LDAP adapter this
|
||||||
// Rust / C / Cpp / TypeScript still carry the Phase-01 empty
|
// phase); Go still has only the XXE adapter; JavaScript still
|
||||||
// baseline.
|
// has only the Handlebars adapter; Rust / C / Cpp /
|
||||||
for lang in [Lang::Java, Lang::Python, Lang::Php, Lang::Ruby] {
|
// TypeScript still carry the Phase-01 empty baseline.
|
||||||
|
for lang in [Lang::Java, Lang::Python, Lang::Php] {
|
||||||
let registered = registry::adapters_for(lang);
|
let registered = registry::adapters_for(lang);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registered.len(),
|
registered.len(),
|
||||||
3,
|
4,
|
||||||
"{:?} must have the J.1 deserialize + J.2 ssti + J.3 xxe adapters",
|
"{:?} must have the J.1 deserialize + J.2 ssti + J.3 xxe + J.4 ldap adapters",
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
for adapter in registered {
|
for adapter in registered {
|
||||||
assert_eq!(adapter.lang(), lang);
|
assert_eq!(adapter.lang(), lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let ruby_registered = registry::adapters_for(Lang::Ruby);
|
||||||
|
assert_eq!(
|
||||||
|
ruby_registered.len(),
|
||||||
|
3,
|
||||||
|
"Ruby must still carry the J.1 deserialize + J.2 ssti + J.3 xxe adapters",
|
||||||
|
);
|
||||||
|
for adapter in ruby_registered {
|
||||||
|
assert_eq!(adapter.lang(), Lang::Ruby);
|
||||||
|
}
|
||||||
let js_registered = registry::adapters_for(Lang::JavaScript);
|
let js_registered = registry::adapters_for(Lang::JavaScript);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
js_registered.len(),
|
js_registered.len(),
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,18 @@ static CPP: &[&dyn FrameworkAdapter] = &[];
|
||||||
static JAVA: &[&dyn FrameworkAdapter] = &[
|
static JAVA: &[&dyn FrameworkAdapter] = &[
|
||||||
&super::adapters::JavaDeserializeAdapter,
|
&super::adapters::JavaDeserializeAdapter,
|
||||||
&super::adapters::JavaThymeleafAdapter,
|
&super::adapters::JavaThymeleafAdapter,
|
||||||
|
&super::adapters::LdapSpringAdapter,
|
||||||
&super::adapters::XxeJavaAdapter,
|
&super::adapters::XxeJavaAdapter,
|
||||||
];
|
];
|
||||||
static GO: &[&dyn FrameworkAdapter] = &[&super::adapters::XxeGoAdapter];
|
static GO: &[&dyn FrameworkAdapter] = &[&super::adapters::XxeGoAdapter];
|
||||||
static PHP: &[&dyn FrameworkAdapter] = &[
|
static PHP: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::LdapPhpAdapter,
|
||||||
&super::adapters::PhpTwigAdapter,
|
&super::adapters::PhpTwigAdapter,
|
||||||
&super::adapters::PhpUnserializeAdapter,
|
&super::adapters::PhpUnserializeAdapter,
|
||||||
&super::adapters::XxePhpAdapter,
|
&super::adapters::XxePhpAdapter,
|
||||||
];
|
];
|
||||||
static PYTHON: &[&dyn FrameworkAdapter] = &[
|
static PYTHON: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::LdapPythonAdapter,
|
||||||
&super::adapters::PythonJinja2Adapter,
|
&super::adapters::PythonJinja2Adapter,
|
||||||
&super::adapters::PythonPickleAdapter,
|
&super::adapters::PythonPickleAdapter,
|
||||||
&super::adapters::XxePythonAdapter,
|
&super::adapters::XxePythonAdapter,
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
if spec.expected_cap == crate::labels::Cap::XXE {
|
if spec.expected_cap == crate::labels::Cap::XXE {
|
||||||
return Ok(emit_xxe_harness(spec));
|
return Ok(emit_xxe_harness(spec));
|
||||||
}
|
}
|
||||||
|
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
|
||||||
|
return Ok(emit_ldap_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = JavaShape::detect(spec, &entry_source);
|
let shape = JavaShape::detect(spec, &entry_source);
|
||||||
|
|
@ -891,6 +894,192 @@ public class NyxHarness {{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 06 — Track J.4 LDAP-injection harness for Java
|
||||||
|
/// (`LdapTemplate.search` / `DirContext.search`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter
|
||||||
|
/// template, evaluates the resulting filter against the in-sandbox
|
||||||
|
/// LDAP directory (three users: `alice`, `bob`, `carol`) using the
|
||||||
|
/// same RFC-4515 subset the
|
||||||
|
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
|
||||||
|
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
|
||||||
|
/// count the directory returned. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05; a future structural fix will
|
||||||
|
/// link real `LdapTemplate` / `DirContext` via the published
|
||||||
|
/// `NYX_LDAP_ENDPOINT`.
|
||||||
|
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let source = format!(
|
||||||
|
r#"// Nyx dynamic harness — LDAP_INJECTION LdapTemplate.search (Phase 06 / Track J.4).
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class NyxHarness {{
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
static final String[] NYX_LDAP_USERS = new String[] {{ "alice", "bob", "carol" }};
|
||||||
|
|
||||||
|
static boolean nyxAttrMatch(String pattern, String uid) {{
|
||||||
|
if (pattern.equals("*")) return true;
|
||||||
|
int star = pattern.indexOf('*');
|
||||||
|
if (star < 0) return pattern.equals(uid);
|
||||||
|
String prefix = pattern.substring(0, star);
|
||||||
|
String suffix = pattern.substring(star + 1);
|
||||||
|
return uid.startsWith(prefix) && uid.endsWith(suffix);
|
||||||
|
}}
|
||||||
|
|
||||||
|
static boolean nyxInnerHasBreak(String inner) {{
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = 0; i < inner.length(); i++) {{
|
||||||
|
char c = inner.charAt(i);
|
||||||
|
if (c == '(') depth++;
|
||||||
|
else if (c == ')') {{
|
||||||
|
depth--;
|
||||||
|
if (depth < 0) return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
|
||||||
|
static int nyxLdapCount(String filter) {{
|
||||||
|
String f = filter == null ? "" : filter.trim();
|
||||||
|
if (f.isEmpty()) return 0;
|
||||||
|
if (!f.startsWith("(") || !f.endsWith(")")) return NYX_LDAP_USERS.length;
|
||||||
|
String inner = f.substring(1, f.length() - 1);
|
||||||
|
if (nyxInnerHasBreak(inner)) return NYX_LDAP_USERS.length;
|
||||||
|
if (inner.startsWith("&") || inner.startsWith("|")) {{
|
||||||
|
List<String> clauses = nyxSplitClauses(inner.substring(1));
|
||||||
|
int total = 0;
|
||||||
|
for (String u : NYX_LDAP_USERS) {{
|
||||||
|
boolean ok = inner.startsWith("&");
|
||||||
|
for (String c : clauses) {{
|
||||||
|
boolean m = nyxLdapMatch(c, u);
|
||||||
|
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
|
||||||
|
}}
|
||||||
|
if (clauses.isEmpty()) ok = false;
|
||||||
|
if (ok) total++;
|
||||||
|
}}
|
||||||
|
return total;
|
||||||
|
}}
|
||||||
|
int eq = inner.indexOf('=');
|
||||||
|
if (eq < 0) return NYX_LDAP_USERS.length;
|
||||||
|
String attr = inner.substring(0, eq);
|
||||||
|
String pattern = inner.substring(eq + 1);
|
||||||
|
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return NYX_LDAP_USERS.length;
|
||||||
|
int total = 0;
|
||||||
|
for (String u : NYX_LDAP_USERS) {{
|
||||||
|
if (nyxAttrMatch(pattern, u)) total++;
|
||||||
|
}}
|
||||||
|
return total;
|
||||||
|
}}
|
||||||
|
|
||||||
|
static boolean nyxLdapMatch(String filter, String uid) {{
|
||||||
|
return nyxLdapCount(filter) > 0
|
||||||
|
? nyxLdapMatchOne(filter, uid)
|
||||||
|
: false;
|
||||||
|
}}
|
||||||
|
|
||||||
|
static boolean nyxLdapMatchOne(String filter, String uid) {{
|
||||||
|
String f = filter.trim();
|
||||||
|
if (!f.startsWith("(") || !f.endsWith(")")) return true;
|
||||||
|
String inner = f.substring(1, f.length() - 1);
|
||||||
|
if (nyxInnerHasBreak(inner)) return true;
|
||||||
|
if (inner.startsWith("&") || inner.startsWith("|")) {{
|
||||||
|
List<String> clauses = nyxSplitClauses(inner.substring(1));
|
||||||
|
if (clauses.isEmpty()) return false;
|
||||||
|
boolean ok = inner.startsWith("&");
|
||||||
|
for (String c : clauses) {{
|
||||||
|
boolean m = nyxLdapMatchOne(c, uid);
|
||||||
|
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
|
||||||
|
}}
|
||||||
|
return ok;
|
||||||
|
}}
|
||||||
|
int eq = inner.indexOf('=');
|
||||||
|
if (eq < 0) return true;
|
||||||
|
String attr = inner.substring(0, eq);
|
||||||
|
String pattern = inner.substring(eq + 1);
|
||||||
|
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return true;
|
||||||
|
return nyxAttrMatch(pattern, uid);
|
||||||
|
}}
|
||||||
|
|
||||||
|
static List<String> nyxSplitClauses(String src) {{
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
int i = 0;
|
||||||
|
while (i < src.length()) {{
|
||||||
|
if (src.charAt(i) != '(') {{ i++; continue; }}
|
||||||
|
int depth = 0;
|
||||||
|
int start = i;
|
||||||
|
while (i < src.length()) {{
|
||||||
|
char c = src.charAt(i);
|
||||||
|
if (c == '(') depth++;
|
||||||
|
else if (c == ')') {{
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {{ i++; break; }}
|
||||||
|
}}
|
||||||
|
i++;
|
||||||
|
}}
|
||||||
|
out.add(src.substring(start, i));
|
||||||
|
}}
|
||||||
|
return out;
|
||||||
|
}}
|
||||||
|
|
||||||
|
static void nyxLdapProbe(String filter, int entriesReturned) {{
|
||||||
|
String p = System.getenv("NYX_PROBE_PATH");
|
||||||
|
if (p == null || p.isEmpty()) return;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
String pid = System.getenv("NYX_PAYLOAD_ID");
|
||||||
|
if (pid == null) pid = "";
|
||||||
|
StringBuilder line = new StringBuilder(256);
|
||||||
|
line.append("{{\"sink_callee\":\"LdapTemplate.search\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
||||||
|
nyxJsonEscape(filter, line);
|
||||||
|
line.append("\"}}],");
|
||||||
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
||||||
|
line.append("\"payload_id\":\"");
|
||||||
|
nyxJsonEscape(pid, line);
|
||||||
|
line.append("\",\"kind\":{{\"kind\":\"Ldap\",\"entries_returned\":").append(entriesReturned).append("}},");
|
||||||
|
line.append("\"witness\":");
|
||||||
|
line.append(nyxWitnessJson("LdapTemplate.search", new String[]{{filter}}));
|
||||||
|
line.append("}}\n");
|
||||||
|
try (FileWriter fw = new FileWriter(p, true)) {{
|
||||||
|
fw.write(line.toString());
|
||||||
|
}} catch (IOException e) {{
|
||||||
|
// best-effort
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
public static void main(String[] args) {{
|
||||||
|
String payload = System.getenv("NYX_PAYLOAD");
|
||||||
|
if (payload == null) payload = "";
|
||||||
|
String filter = "(uid=" + payload + ")";
|
||||||
|
int count = nyxLdapCount(filter);
|
||||||
|
nyxLdapProbe(filter, count);
|
||||||
|
System.out.println("__NYX_SINK_HIT__");
|
||||||
|
StringBuilder body = new StringBuilder(64);
|
||||||
|
body.append("{{\"filter\":\"");
|
||||||
|
nyxJsonEscape(filter, body);
|
||||||
|
body.append("\",\"entries_returned\":").append(count).append("}}");
|
||||||
|
System.out.println(body.toString());
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
HarnessSource {
|
||||||
|
source,
|
||||||
|
filename: "NyxHarness.java".to_owned(),
|
||||||
|
command: vec![
|
||||||
|
"java".to_owned(),
|
||||||
|
"-cp".to_owned(),
|
||||||
|
".".to_owned(),
|
||||||
|
"NyxHarness".to_owned(),
|
||||||
|
],
|
||||||
|
extra_files: Vec::new(),
|
||||||
|
entry_subpath: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||||
/// per-fixture shape without round-tripping through [`emit`].
|
/// per-fixture shape without round-tripping through [`emit`].
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
if spec.expected_cap == crate::labels::Cap::XXE {
|
if spec.expected_cap == crate::labels::Cap::XXE {
|
||||||
return Ok(emit_xxe_harness(spec));
|
return Ok(emit_xxe_harness(spec));
|
||||||
}
|
}
|
||||||
|
// Phase 06 (Track J.4): LDAP_INJECTION-sink short-circuit.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
|
||||||
|
return Ok(emit_ldap_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = PhpShape::detect(spec, &entry_source);
|
let shape = PhpShape::detect(spec, &entry_source);
|
||||||
|
|
@ -606,6 +610,137 @@ echo json_encode(["render" => $rendered, "entity_expanded" => $expanded]) . "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 06 — Track J.4 LDAP-injection harness for PHP (`ldap_search`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
|
||||||
|
/// evaluates the filter against the in-sandbox LDAP directory (three
|
||||||
|
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
|
||||||
|
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
|
||||||
|
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
|
||||||
|
/// count the directory returned. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05.
|
||||||
|
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"<?php
|
||||||
|
// Nyx dynamic harness — LDAP_INJECTION ldap_search (Phase 06 / Track J.4).
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
$NYX_LDAP_USERS = ['alice', 'bob', 'carol'];
|
||||||
|
|
||||||
|
function _nyx_attr_match(string $pattern, string $uid): bool {{
|
||||||
|
if ($pattern === '*') return true;
|
||||||
|
$star = strpos($pattern, '*');
|
||||||
|
if ($star === false) return $pattern === $uid;
|
||||||
|
$prefix = substr($pattern, 0, $star);
|
||||||
|
$suffix = substr($pattern, $star + 1);
|
||||||
|
return str_starts_with($uid, $prefix) && str_ends_with($uid, $suffix);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_split_clauses(string $src): array {{
|
||||||
|
$out = [];
|
||||||
|
$i = 0;
|
||||||
|
$n = strlen($src);
|
||||||
|
while ($i < $n) {{
|
||||||
|
if ($src[$i] !== '(') {{ $i++; continue; }}
|
||||||
|
$depth = 0;
|
||||||
|
$start = $i;
|
||||||
|
while ($i < $n) {{
|
||||||
|
$c = $src[$i];
|
||||||
|
if ($c === '(') $depth++;
|
||||||
|
elseif ($c === ')') {{
|
||||||
|
$depth--;
|
||||||
|
if ($depth === 0) {{ $i++; break; }}
|
||||||
|
}}
|
||||||
|
$i++;
|
||||||
|
}}
|
||||||
|
$out[] = substr($src, $start, $i - $start);
|
||||||
|
}}
|
||||||
|
return $out;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_inner_has_break(string $inner): bool {{
|
||||||
|
$depth = 0;
|
||||||
|
$n = strlen($inner);
|
||||||
|
for ($i = 0; $i < $n; $i++) {{
|
||||||
|
$c = $inner[$i];
|
||||||
|
if ($c === '(') $depth++;
|
||||||
|
elseif ($c === ')') {{
|
||||||
|
$depth--;
|
||||||
|
if ($depth < 0) return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_match_one(string $filt, string $uid): bool {{
|
||||||
|
$f = trim($filt);
|
||||||
|
if (!(str_starts_with($f, '(') && str_ends_with($f, ')'))) return true;
|
||||||
|
$inner = substr($f, 1, strlen($f) - 2);
|
||||||
|
if (_nyx_inner_has_break($inner)) return true;
|
||||||
|
if (str_starts_with($inner, '&') || str_starts_with($inner, '|')) {{
|
||||||
|
$clauses = _nyx_split_clauses(substr($inner, 1));
|
||||||
|
if (empty($clauses)) return false;
|
||||||
|
$is_and = str_starts_with($inner, '&');
|
||||||
|
$ok = $is_and;
|
||||||
|
foreach ($clauses as $c) {{
|
||||||
|
$m = _nyx_match_one($c, $uid);
|
||||||
|
$ok = $is_and ? ($ok && $m) : ($ok || $m);
|
||||||
|
}}
|
||||||
|
return $ok;
|
||||||
|
}}
|
||||||
|
$eq = strpos($inner, '=');
|
||||||
|
if ($eq === false) return true;
|
||||||
|
$attr = strtolower(substr($inner, 0, $eq));
|
||||||
|
$pattern = substr($inner, $eq + 1);
|
||||||
|
if ($attr !== 'uid' && $attr !== 'cn') return true;
|
||||||
|
return _nyx_attr_match($pattern, $uid);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_ldap_count(string $filt, array $users): int {{
|
||||||
|
$f = trim($filt);
|
||||||
|
if ($f === '') return 0;
|
||||||
|
if (!(str_starts_with($f, '(') && str_ends_with($f, ')'))) return count($users);
|
||||||
|
$inner = substr($f, 1, strlen($f) - 2);
|
||||||
|
if (_nyx_inner_has_break($inner)) return count($users);
|
||||||
|
$count = 0;
|
||||||
|
foreach ($users as $u) {{
|
||||||
|
if (_nyx_match_one($f, $u)) $count++;
|
||||||
|
}}
|
||||||
|
return $count;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_ldap_probe(string $filt, int $entries_returned): void {{
|
||||||
|
$p = getenv('NYX_PROBE_PATH');
|
||||||
|
if ($p === false || $p === '') return;
|
||||||
|
$rec = [
|
||||||
|
'sink_callee' => 'ldap_search',
|
||||||
|
'args' => [['kind' => 'String', 'value' => $filt]],
|
||||||
|
'captured_at_ns' => (int) hrtime(true),
|
||||||
|
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
|
||||||
|
'kind' => ['kind' => 'Ldap', 'entries_returned' => $entries_returned],
|
||||||
|
'witness' => __nyx_witness('ldap_search', [$filt]),
|
||||||
|
];
|
||||||
|
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
|
||||||
|
}}
|
||||||
|
|
||||||
|
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
|
||||||
|
$filt = '(uid=' . $payload . ')';
|
||||||
|
$count = _nyx_ldap_count($filt, $NYX_LDAP_USERS);
|
||||||
|
_nyx_ldap_probe($filt, $count);
|
||||||
|
echo "__NYX_SINK_HIT__\n";
|
||||||
|
echo json_encode(['filter' => $filt, 'entries_returned' => $count]) . "\n";
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
HarnessSource {
|
||||||
|
source: body,
|
||||||
|
filename: "harness.php".to_owned(),
|
||||||
|
command: vec!["php".to_owned(), "harness.php".to_owned()],
|
||||||
|
extra_files: vec![],
|
||||||
|
entry_subpath: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
||||||
let entry_fn = &spec.entry_name;
|
let entry_fn = &spec.entry_name;
|
||||||
let pre_call = build_pre_call(spec, shape);
|
let pre_call = build_pre_call(spec, shape);
|
||||||
|
|
|
||||||
|
|
@ -618,6 +618,17 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
return Ok(emit_xxe_harness(spec));
|
return Ok(emit_xxe_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 06 (Track J.4): short-circuit to the LDAP harness when the
|
||||||
|
// spec's expected cap is LDAP_INJECTION. The harness splices the
|
||||||
|
// payload into a `(uid=<payload>)` filter and applies the
|
||||||
|
// [`crate::dynamic::stubs::ldap_server`] RFC-4515 subset against
|
||||||
|
// the same three provisioned users; the resulting count drives a
|
||||||
|
// `ProbeKind::Ldap` probe consumed by the
|
||||||
|
// `LdapResultCountGreaterThan` oracle.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
|
||||||
|
return Ok(emit_ldap_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = PythonShape::detect(spec, &entry_source);
|
let shape = PythonShape::detect(spec, &entry_source);
|
||||||
let body = generate_for_shape(spec, shape);
|
let body = generate_for_shape(spec, shape);
|
||||||
|
|
@ -839,6 +850,140 @@ if __name__ == "__main__":
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 06 — Track J.4 LDAP-injection harness for Python
|
||||||
|
/// (`ldap.search_s`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
|
||||||
|
/// evaluates the filter against the in-sandbox LDAP directory (three
|
||||||
|
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
|
||||||
|
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
|
||||||
|
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
|
||||||
|
/// count the directory returned. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05.
|
||||||
|
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let probe = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"#!/usr/bin/env python3
|
||||||
|
"""Nyx dynamic harness — LDAP_INJECTION ldap.search_s (Phase 06 / Track J.4)."""
|
||||||
|
import os, json, sys, time
|
||||||
|
|
||||||
|
{probe}
|
||||||
|
|
||||||
|
_NYX_LDAP_USERS = ["alice", "bob", "carol"]
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_attr_match(pattern, uid):
|
||||||
|
if pattern == "*":
|
||||||
|
return True
|
||||||
|
if "*" in pattern:
|
||||||
|
prefix, _, suffix = pattern.partition("*")
|
||||||
|
return uid.startswith(prefix) and uid.endswith(suffix)
|
||||||
|
return pattern == uid
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_split_clauses(src):
|
||||||
|
out = []
|
||||||
|
i = 0
|
||||||
|
n = len(src)
|
||||||
|
while i < n:
|
||||||
|
if src[i] != "(":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
depth = 0
|
||||||
|
start = i
|
||||||
|
while i < n:
|
||||||
|
c = src[i]
|
||||||
|
if c == "(":
|
||||||
|
depth += 1
|
||||||
|
elif c == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
out.append(src[start:i])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_inner_has_break(inner):
|
||||||
|
depth = 0
|
||||||
|
for c in inner:
|
||||||
|
if c == "(":
|
||||||
|
depth += 1
|
||||||
|
elif c == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_match_one(filt, uid):
|
||||||
|
f = filt.strip()
|
||||||
|
if not (f.startswith("(") and f.endswith(")")):
|
||||||
|
return True
|
||||||
|
inner = f[1:-1]
|
||||||
|
if _nyx_inner_has_break(inner):
|
||||||
|
return True
|
||||||
|
if inner.startswith("&") or inner.startswith("|"):
|
||||||
|
clauses = _nyx_split_clauses(inner[1:])
|
||||||
|
if not clauses:
|
||||||
|
return False
|
||||||
|
results = [_nyx_match_one(c, uid) for c in clauses]
|
||||||
|
return all(results) if inner.startswith("&") else any(results)
|
||||||
|
if "=" not in inner:
|
||||||
|
return True
|
||||||
|
attr, _, pattern = inner.partition("=")
|
||||||
|
if attr.lower() not in ("uid", "cn"):
|
||||||
|
return True
|
||||||
|
return _nyx_attr_match(pattern, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_ldap_count(filt):
|
||||||
|
f = (filt or "").strip()
|
||||||
|
if not f:
|
||||||
|
return 0
|
||||||
|
if not (f.startswith("(") and f.endswith(")")):
|
||||||
|
return len(_NYX_LDAP_USERS)
|
||||||
|
if _nyx_inner_has_break(f[1:-1]):
|
||||||
|
return len(_NYX_LDAP_USERS)
|
||||||
|
return sum(1 for u in _NYX_LDAP_USERS if _nyx_match_one(f, u))
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_ldap_probe(filt, entries_returned):
|
||||||
|
rec = {{
|
||||||
|
"sink_callee": "ldap.search_s",
|
||||||
|
"args": [{{"kind": "String", "value": filt}}],
|
||||||
|
"captured_at_ns": time.time_ns(),
|
||||||
|
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
|
||||||
|
"kind": {{"kind": "Ldap", "entries_returned": int(entries_returned)}},
|
||||||
|
"witness": __nyx_witness("ldap.search_s", [filt]),
|
||||||
|
}}
|
||||||
|
__nyx_emit(rec)
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_run():
|
||||||
|
payload = os.environ.get("NYX_PAYLOAD", "")
|
||||||
|
filt = "(uid=" + payload + ")"
|
||||||
|
count = _nyx_ldap_count(filt)
|
||||||
|
_nyx_ldap_probe(filt, count)
|
||||||
|
print("__NYX_SINK_HIT__", flush=True)
|
||||||
|
sys.stdout.write(json.dumps({{"filter": filt, "entries_returned": count}}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_nyx_run()
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
HarnessSource {
|
||||||
|
source: body,
|
||||||
|
filename: "harness.py".to_owned(),
|
||||||
|
command: vec!["python3".to_owned(), "harness.py".to_owned()],
|
||||||
|
extra_files: Vec::new(),
|
||||||
|
entry_subpath: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||||
/// per-fixture shape without round-tripping through [`emit`].
|
/// per-fixture shape without round-tripping through [`emit`].
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,28 @@ pub enum ProbePredicate {
|
||||||
/// the parser-refusal benign control still confirm.
|
/// the parser-refusal benign control still confirm.
|
||||||
require_expanded: bool,
|
require_expanded: bool,
|
||||||
},
|
},
|
||||||
|
/// Phase 06 (Track J.4): LDAP-filter-injection count predicate.
|
||||||
|
///
|
||||||
|
/// Fires when at least one drained probe carries
|
||||||
|
/// [`ProbeKind::Ldap`] with `entries_returned > n`. The malicious
|
||||||
|
/// payload (`*)(uid=*`) inflates the filter so the in-sandbox
|
||||||
|
/// [`crate::dynamic::stubs::ldap_server`] stub matches every
|
||||||
|
/// provisioned user (>1 entry). The benign control quotes the
|
||||||
|
/// filter with `EscapeDN` / `ldap.dn.escape_filter_chars` /
|
||||||
|
/// `ldap_escape` so the stub returns exactly one entry, leaving
|
||||||
|
/// the predicate clear.
|
||||||
|
///
|
||||||
|
/// Cross-cutting in the same sense as
|
||||||
|
/// [`Self::DeserializeGadgetInvoked`] /
|
||||||
|
/// [`Self::XxeEntityExpanded`] — evaluated across every drained
|
||||||
|
/// probe rather than against a single record.
|
||||||
|
LdapResultCountGreaterThan {
|
||||||
|
/// Threshold the captured `entries_returned` count must exceed
|
||||||
|
/// to fire the predicate. Typically `1`: the originally-
|
||||||
|
/// intended user is one entry, any additional entries prove
|
||||||
|
/// the filter expanded into an over-broad match.
|
||||||
|
n: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How we decide a sandbox run confirmed the sink fired.
|
/// How we decide a sandbox run confirmed the sink fired.
|
||||||
|
|
@ -365,6 +387,20 @@ pub fn oracle_fired_with_stubs(
|
||||||
if !xxe_cross_ok {
|
if !xxe_cross_ok {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Phase 06 (Track J.4): LDAP filter-injection cross-
|
||||||
|
// cutting predicates. Each
|
||||||
|
// `LdapResultCountGreaterThan { n }` consults the captured
|
||||||
|
// probe channel for a [`ProbeKind::Ldap`] record whose
|
||||||
|
// `entries_returned` exceeds `n`.
|
||||||
|
let ldap_cross_ok = cross.iter().all(|p| match p {
|
||||||
|
ProbePredicate::LdapResultCountGreaterThan { n } => {
|
||||||
|
probes_satisfy_ldap_gt(probes, *n)
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
if !ldap_cross_ok {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
||||||
// predicates. Each `TemplateEvalEqual { expected }` consults
|
// predicates. Each `TemplateEvalEqual { expected }` consults
|
||||||
// the captured stdout body — see [`stdout_template_equals`].
|
// the captured stdout body — see [`stdout_template_equals`].
|
||||||
|
|
@ -392,7 +428,10 @@ pub fn oracle_fired_with_stubs(
|
||||||
}
|
}
|
||||||
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
|
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
|
||||||
ProbeKind::Crash { signal } => signals.contains(signal),
|
ProbeKind::Crash { signal } => signals.contains(signal),
|
||||||
ProbeKind::Normal | ProbeKind::Deserialize { .. } | ProbeKind::Xxe { .. } => false,
|
ProbeKind::Normal
|
||||||
|
| ProbeKind::Deserialize { .. }
|
||||||
|
| ProbeKind::Xxe { .. }
|
||||||
|
| ProbeKind::Ldap { .. } => false,
|
||||||
}),
|
}),
|
||||||
Oracle::OutputContains(needle) => {
|
Oracle::OutputContains(needle) => {
|
||||||
let nb = needle.as_bytes();
|
let nb = needle.as_bytes();
|
||||||
|
|
@ -418,6 +457,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
||||||
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
||||||
| ProbePredicate::TemplateEvalEqual { .. }
|
| ProbePredicate::TemplateEvalEqual { .. }
|
||||||
| ProbePredicate::XxeEntityExpanded { .. }
|
| ProbePredicate::XxeEntityExpanded { .. }
|
||||||
|
| ProbePredicate::LdapResultCountGreaterThan { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -438,6 +478,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
|
||||||
// rather than stub events; evaluated separately in
|
// rather than stub events; evaluated separately in
|
||||||
// [`probes_satisfy_xxe`] below.
|
// [`probes_satisfy_xxe`] below.
|
||||||
ProbePredicate::XxeEntityExpanded { .. } => true,
|
ProbePredicate::XxeEntityExpanded { .. } => true,
|
||||||
|
// LdapResultCountGreaterThan is cross-cutting against the
|
||||||
|
// *probe log* rather than stub events; evaluated separately
|
||||||
|
// in [`probes_satisfy_ldap_gt`] below.
|
||||||
|
ProbePredicate::LdapResultCountGreaterThan { .. } => true,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +546,15 @@ fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when at least one drained probe is a [`ProbeKind::Ldap`]
|
||||||
|
/// record whose `entries_returned` exceeds `n`.
|
||||||
|
fn probes_satisfy_ldap_gt(probes: &[SinkProbe], n: u32) -> bool {
|
||||||
|
probes.iter().any(|p| match p.kind {
|
||||||
|
ProbeKind::Ldap { entries_returned } => entries_returned > n,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true when `probe` satisfies *every* predicate in `preds`.
|
/// Returns true when `probe` satisfies *every* predicate in `preds`.
|
||||||
/// An empty predicate slice satisfies vacuously — a payload that wants
|
/// An empty predicate slice satisfies vacuously — a payload that wants
|
||||||
/// "any probe at all" can ship an empty predicate set.
|
/// "any probe at all" can ship an empty predicate set.
|
||||||
|
|
@ -534,7 +587,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
||||||
ProbePredicate::StubEventMatches { .. }
|
ProbePredicate::StubEventMatches { .. }
|
||||||
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
||||||
| ProbePredicate::TemplateEvalEqual { .. }
|
| ProbePredicate::TemplateEvalEqual { .. }
|
||||||
| ProbePredicate::XxeEntityExpanded { .. } => true,
|
| ProbePredicate::XxeEntityExpanded { .. }
|
||||||
|
| ProbePredicate::LdapResultCountGreaterThan { .. } => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -556,7 +610,10 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
||||||
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
||||||
match probe.kind {
|
match probe.kind {
|
||||||
ProbeKind::Crash { signal } => Some(signal),
|
ProbeKind::Crash { signal } => Some(signal),
|
||||||
ProbeKind::Normal | ProbeKind::Deserialize { .. } | ProbeKind::Xxe { .. } => None,
|
ProbeKind::Normal
|
||||||
|
| ProbeKind::Deserialize { .. }
|
||||||
|
| ProbeKind::Xxe { .. }
|
||||||
|
| ProbeKind::Ldap { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,23 @@ pub enum ProbeKind {
|
||||||
/// parsed XML output.
|
/// parsed XML output.
|
||||||
entity_expanded: bool,
|
entity_expanded: bool,
|
||||||
},
|
},
|
||||||
|
/// Phase 06 (Track J.4) LDAP-sink observation. Stamped by the
|
||||||
|
/// per-language LDAP harness shim when the instrumented client
|
||||||
|
/// (`LdapTemplate.search`, `ldap.search_s`, `ldap_search`) issues a
|
||||||
|
/// filter against the in-sandbox
|
||||||
|
/// [`ldap_server`](crate::dynamic::stubs::ldap_server) stub. The
|
||||||
|
/// shim records the number of directory entries the stub returned
|
||||||
|
/// for the supplied filter — the differential oracle's
|
||||||
|
/// [`crate::dynamic::oracle::ProbePredicate::LdapResultCountGreaterThan`]
|
||||||
|
/// fires when `entries_returned > n`, catching a malicious filter
|
||||||
|
/// (e.g. `*)(uid=*`) that matched more than the originally-intended
|
||||||
|
/// user. Benign filter-quoted controls produce
|
||||||
|
/// `entries_returned == 1`.
|
||||||
|
Ldap {
|
||||||
|
/// Count of directory entries the stub LDAP server returned
|
||||||
|
/// for the payload's filter.
|
||||||
|
entries_returned: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProbeKind {
|
impl Default for ProbeKind {
|
||||||
|
|
|
||||||
460
src/dynamic/stubs/ldap_server.rs
Normal file
460
src/dynamic/stubs/ldap_server.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
//! Minimal in-sandbox LDAP server stub (Phase 06 — Track J.4).
|
||||||
|
//!
|
||||||
|
//! The brief calls for "a 200-line Go implementation reused across langs
|
||||||
|
//! over loopback". This module ships the same idea in Rust: a tiny TCP
|
||||||
|
//! listener that speaks a one-line text protocol — `SEARCH <filter>\n`
|
||||||
|
//! → `COUNT <n>\nDN <dn1>\nDN <dn2>\n…\nEND\n` — so the per-language
|
||||||
|
//! harness shims can drive a uniform request/response loop without
|
||||||
|
//! linking a real LDAP client (jldap, python-ldap, ldap_search).
|
||||||
|
//!
|
||||||
|
//! Endpoint: `127.0.0.1:{port}` (no scheme; the harness composes
|
||||||
|
//! `ldap://` itself if it wants).
|
||||||
|
//!
|
||||||
|
//! # Directory state
|
||||||
|
//!
|
||||||
|
//! Three users are provisioned at startup: `alice`, `bob`, `carol`. An
|
||||||
|
//! incoming search filter is scanned with a tiny RFC 4515 subset:
|
||||||
|
//!
|
||||||
|
//! * `(uid=<value>)` matches the user whose `uid` byte-for-byte equals
|
||||||
|
//! `<value>`.
|
||||||
|
//! * `(uid=<prefix>*<suffix>)` matches every user whose `uid` matches
|
||||||
|
//! the wildcard skeleton.
|
||||||
|
//! * Bare `*` inside *any* attribute slot matches every entry.
|
||||||
|
//! * Boolean wrappers `(&(…)(…))`, `(|(…)(…))` recurse into the inner
|
||||||
|
//! clauses.
|
||||||
|
//!
|
||||||
|
//! Anything outside that subset short-circuits to "match-everything" so
|
||||||
|
//! adversarial payloads (`*)(uid=*` after the harness's quote-and-paste
|
||||||
|
//! mistake) cannot accidentally produce a 0-result false negative.
|
||||||
|
//!
|
||||||
|
//! # Recording
|
||||||
|
//!
|
||||||
|
//! Every served search appends a [`StubEvent`] keyed on `summary =
|
||||||
|
//! "SEARCH <filter>"` and `detail["entries_returned"]` so the oracle's
|
||||||
|
//! [`crate::dynamic::oracle::ProbePredicate::LdapResultCountGreaterThan`]
|
||||||
|
//! can satisfy without depending on a `ProbeKind::Ldap` write — the
|
||||||
|
//! probe path is the primary signal, the stub-event log is the
|
||||||
|
//! belt-and-braces side channel.
|
||||||
|
//!
|
||||||
|
//! # Drop
|
||||||
|
//!
|
||||||
|
//! Signals the accept thread to shut down and connects to itself to
|
||||||
|
//! wake the blocking `accept()`.
|
||||||
|
|
||||||
|
use super::{monotonic_ns, StubEvent, StubKind, StubProvider};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Companion env var the harness shim reads to reach the stub. Set on
|
||||||
|
/// the sandbox env by [`crate::dynamic::stubs::StubHarness::endpoints`]
|
||||||
|
/// when an [`LdapStub`] is registered.
|
||||||
|
pub const LDAP_ENDPOINT_ENV_VAR: &str = "NYX_LDAP_ENDPOINT";
|
||||||
|
|
||||||
|
/// Three canonical users the stub provisions on start. Tests pin the
|
||||||
|
/// count so a corpus change cannot silently shift the differential
|
||||||
|
/// threshold below `LdapResultCountGreaterThan { n: 1 }`.
|
||||||
|
pub const STUB_USERS: &[&str] = &["alice", "bob", "carol"];
|
||||||
|
|
||||||
|
/// LDAP-cap stub. Endpoint is `127.0.0.1:{port}`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LdapStub {
|
||||||
|
port: u16,
|
||||||
|
events: Arc<Mutex<Vec<StubEvent>>>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LdapStub {
|
||||||
|
/// Bind to a random loopback port and start the accept thread.
|
||||||
|
pub fn start() -> std::io::Result<Self> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
listener.set_nonblocking(false)?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
|
||||||
|
let events: Arc<Mutex<Vec<StubEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let events_clone = Arc::clone(&events);
|
||||||
|
let shutdown_clone = Arc::clone(&shutdown);
|
||||||
|
std::thread::spawn(move || accept_loop(listener, events_clone, shutdown_clone));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
events,
|
||||||
|
shutdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port the listener is bound to (test helper).
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-side helper to record a search as if a harness had issued
|
||||||
|
/// it. The Phase 06 unit tests use this to bypass the
|
||||||
|
/// `connect → write → parse` path so the test runs without a real
|
||||||
|
/// TCP client.
|
||||||
|
pub fn record_search(&self, filter: &str, entries_returned: u32) {
|
||||||
|
let ev = StubEvent {
|
||||||
|
kind: StubKind::Ldap,
|
||||||
|
captured_at_ns: monotonic_ns(),
|
||||||
|
summary: format!("SEARCH {filter}"),
|
||||||
|
detail: {
|
||||||
|
let mut d = BTreeMap::new();
|
||||||
|
d.insert("filter".to_owned(), filter.to_owned());
|
||||||
|
d.insert(
|
||||||
|
"entries_returned".to_owned(),
|
||||||
|
entries_returned.to_string(),
|
||||||
|
);
|
||||||
|
d
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Ok(mut g) = self.events.lock() {
|
||||||
|
g.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate `filter` against the in-memory directory and return the
|
||||||
|
/// matching uids (lexicographic). Public so the synthetic harness
|
||||||
|
/// shims can mirror the stub's scoring logic when running without
|
||||||
|
/// a live socket.
|
||||||
|
pub fn evaluate(filter: &str) -> Vec<&'static str> {
|
||||||
|
match_filter(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StubProvider for LdapStub {
|
||||||
|
fn kind(&self) -> StubKind {
|
||||||
|
StubKind::Ldap
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self) -> String {
|
||||||
|
format!("127.0.0.1:{}", self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_events(&self) -> Vec<StubEvent> {
|
||||||
|
match self.events.lock() {
|
||||||
|
Ok(mut g) => std::mem::take(&mut *g),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LdapStub {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown.store(true, Ordering::Relaxed);
|
||||||
|
let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
events: Arc<Mutex<Vec<StubEvent>>>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
const MAX_REQUEST_BYTES: usize = 4 * 1024;
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
if shutdown.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let stream = match stream {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||||
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
|
||||||
|
handle_connection(stream, MAX_REQUEST_BYTES, &events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
max_bytes: usize,
|
||||||
|
events: &Arc<Mutex<Vec<StubEvent>>>,
|
||||||
|
) {
|
||||||
|
let mut reader = match stream.try_clone() {
|
||||||
|
Ok(s) => BufReader::new(s),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut line = String::new();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => return,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
if line.len() > max_bytes {
|
||||||
|
line.truncate(max_bytes);
|
||||||
|
}
|
||||||
|
let trimmed = line.trim_end_matches(['\r', '\n']).to_owned();
|
||||||
|
let filter = match trimmed.strip_prefix("SEARCH ") {
|
||||||
|
Some(rest) => rest.trim().to_owned(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let matches = match_filter(&filter);
|
||||||
|
let count = matches.len();
|
||||||
|
let mut reply = format!("COUNT {count}\n");
|
||||||
|
for uid in &matches {
|
||||||
|
reply.push_str(&format!("DN uid={uid},ou=people,dc=nyx,dc=test\n"));
|
||||||
|
}
|
||||||
|
reply.push_str("END\n");
|
||||||
|
let _ = stream.write_all(reply.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
|
||||||
|
let ev = StubEvent {
|
||||||
|
kind: StubKind::Ldap,
|
||||||
|
captured_at_ns: monotonic_ns(),
|
||||||
|
summary: format!("SEARCH {filter}"),
|
||||||
|
detail: {
|
||||||
|
let mut d = BTreeMap::new();
|
||||||
|
d.insert("filter".to_owned(), filter);
|
||||||
|
d.insert("entries_returned".to_owned(), count.to_string());
|
||||||
|
d
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Ok(mut g) = events.lock() {
|
||||||
|
g.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC-4515-subset matcher. See module docs for the grammar.
|
||||||
|
fn match_filter(filter: &str) -> Vec<&'static str> {
|
||||||
|
let trimmed = filter.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Adversarial / unparseable filters fall through to match-all so a
|
||||||
|
// harness mistake never silently produces zero entries.
|
||||||
|
let parsed = match parse_filter(trimmed) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return STUB_USERS.to_vec(),
|
||||||
|
};
|
||||||
|
STUB_USERS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|u| filter_matches_user(&parsed, u))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Filter<'a> {
|
||||||
|
Eq { attr: &'a str, pattern: &'a str },
|
||||||
|
And(Vec<Filter<'a>>),
|
||||||
|
Or(Vec<Filter<'a>>),
|
||||||
|
/// Anything we did not recognise — treated as match-everything by
|
||||||
|
/// the matcher, preserving the over-match policy.
|
||||||
|
Wild,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single top-level filter. Returns `Some(Wild)` for anything
|
||||||
|
/// the subset does not cover (including the canonical filter-injection
|
||||||
|
/// breakout shape `(uid=alice*)(uid=*)` whose outer parens fence two
|
||||||
|
/// adjacent groups rather than a single enclosing filter); returns
|
||||||
|
/// `None` only when the string is not balanced enough to scan at all.
|
||||||
|
fn parse_filter(src: &str) -> Option<Filter<'_>> {
|
||||||
|
let s = src.trim();
|
||||||
|
if !s.starts_with('(') || !s.ends_with(')') {
|
||||||
|
return Some(Filter::Wild);
|
||||||
|
}
|
||||||
|
let inner = &s[1..s.len() - 1];
|
||||||
|
if inner_has_unbalanced_break(inner) {
|
||||||
|
// Two-or-more adjacent paren groups at the outer level —
|
||||||
|
// matches the brief's `*)(uid=*` breakout shape. Fall through
|
||||||
|
// to match-everything so adversarial payloads cannot silently
|
||||||
|
// produce a 0-result false negative.
|
||||||
|
return Some(Filter::Wild);
|
||||||
|
}
|
||||||
|
if let Some(rest) = inner.strip_prefix('&') {
|
||||||
|
return Some(Filter::And(split_clauses(rest)));
|
||||||
|
}
|
||||||
|
if let Some(rest) = inner.strip_prefix('|') {
|
||||||
|
return Some(Filter::Or(split_clauses(rest)));
|
||||||
|
}
|
||||||
|
let (attr, pattern) = inner.split_once('=')?;
|
||||||
|
Some(Filter::Eq {
|
||||||
|
attr: attr.trim(),
|
||||||
|
pattern: pattern.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `inner` (the substring between the outer `(` and `)` of
|
||||||
|
/// a candidate filter) carries a `)` before a matching `(` — the
|
||||||
|
/// telltale of `(filterA)(filterB)` where the outer parens fenced
|
||||||
|
/// only the first group, not the whole expression.
|
||||||
|
fn inner_has_unbalanced_break(inner: &str) -> bool {
|
||||||
|
let mut depth: i32 = 0;
|
||||||
|
for c in inner.bytes() {
|
||||||
|
match c {
|
||||||
|
b'(' => depth += 1,
|
||||||
|
b')' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth < 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_clauses(src: &str) -> Vec<Filter<'_>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let bytes = src.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] != b'(' {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut depth = 0;
|
||||||
|
let start = i;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'(' => depth += 1,
|
||||||
|
b')' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let slice = &src[start..i];
|
||||||
|
if let Some(f) = parse_filter(slice) {
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_matches_user(f: &Filter<'_>, uid: &str) -> bool {
|
||||||
|
match f {
|
||||||
|
Filter::Wild => true,
|
||||||
|
Filter::Eq { attr, pattern } => attr_matches(attr, pattern, uid),
|
||||||
|
Filter::And(inner) => inner.iter().all(|c| filter_matches_user(c, uid)),
|
||||||
|
Filter::Or(inner) => inner.iter().any(|c| filter_matches_user(c, uid)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr_matches(attr: &str, pattern: &str, uid: &str) -> bool {
|
||||||
|
if !attr.eq_ignore_ascii_case("uid") && !attr.eq_ignore_ascii_case("cn") {
|
||||||
|
// Unrecognised attribute — over-match.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if pattern == "*" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some((prefix, suffix)) = pattern.split_once('*') {
|
||||||
|
return uid.starts_with(prefix) && uid.ends_with(suffix);
|
||||||
|
}
|
||||||
|
pattern == uid
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluate_returns_one_for_concrete_uid() {
|
||||||
|
let m = LdapStub::evaluate("(uid=alice)");
|
||||||
|
assert_eq!(m, vec!["alice"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluate_returns_all_for_wildcard() {
|
||||||
|
let m = LdapStub::evaluate("(uid=*)");
|
||||||
|
assert_eq!(m, vec!["alice", "bob", "carol"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluate_returns_all_for_injection_pattern() {
|
||||||
|
// Adversarial filter the brief calls out — payload `*)(uid=*`
|
||||||
|
// appended to a `(uid=alice)` template lands inside an `(|…)`
|
||||||
|
// disjunction wrapper most clients emit, so every user
|
||||||
|
// matches.
|
||||||
|
let m = LdapStub::evaluate("(|(uid=alice)(uid=*))");
|
||||||
|
assert_eq!(m, vec!["alice", "bob", "carol"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unparseable_filter_matches_everything() {
|
||||||
|
// No surrounding parens — match-all fallback fires.
|
||||||
|
let m = LdapStub::evaluate("uid=alice");
|
||||||
|
assert_eq!(m, vec!["alice", "bob", "carol"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluate_returns_empty_for_unknown_concrete_uid() {
|
||||||
|
let m = LdapStub::evaluate("(uid=nobody)");
|
||||||
|
assert!(m.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_uses_loopback_with_assigned_port() {
|
||||||
|
let stub = LdapStub::start().unwrap();
|
||||||
|
let ep = stub.endpoint();
|
||||||
|
assert!(ep.starts_with("127.0.0.1:"));
|
||||||
|
assert!(ep.ends_with(&stub.port().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_request_returns_three_for_wildcard_via_socket() {
|
||||||
|
let stub = LdapStub::start().unwrap();
|
||||||
|
let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap();
|
||||||
|
s.write_all(b"SEARCH (uid=*)\n").unwrap();
|
||||||
|
s.flush().unwrap();
|
||||||
|
let mut out = String::new();
|
||||||
|
s.read_to_string(&mut out).unwrap();
|
||||||
|
assert!(out.starts_with("COUNT 3\n"), "got {out:?}");
|
||||||
|
assert!(out.contains("uid=alice"));
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
let events = stub.drain_events();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
events[0].detail.get("entries_returned").map(String::as_str),
|
||||||
|
Some("3"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_request_returns_one_for_concrete_uid_via_socket() {
|
||||||
|
let stub = LdapStub::start().unwrap();
|
||||||
|
let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap();
|
||||||
|
s.write_all(b"SEARCH (uid=alice)\n").unwrap();
|
||||||
|
s.flush().unwrap();
|
||||||
|
let mut out = String::new();
|
||||||
|
s.read_to_string(&mut out).unwrap();
|
||||||
|
assert!(out.starts_with("COUNT 1\n"), "got {out:?}");
|
||||||
|
assert!(out.contains("uid=alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_search_helper_appends_event() {
|
||||||
|
let stub = LdapStub::start().unwrap();
|
||||||
|
stub.record_search("(uid=*)", 3);
|
||||||
|
let events = stub.drain_events();
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(events[0].kind, StubKind::Ldap);
|
||||||
|
assert_eq!(
|
||||||
|
events[0].detail.get("entries_returned").map(String::as_str),
|
||||||
|
Some("3"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_releases_port_for_rebind() {
|
||||||
|
let port = {
|
||||||
|
let stub = LdapStub::start().unwrap();
|
||||||
|
stub.port()
|
||||||
|
};
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let _ = TcpListener::bind(format!("127.0.0.1:{port}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,11 +53,13 @@
|
||||||
|
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod ldap_server;
|
||||||
pub mod redis;
|
pub mod redis;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
|
|
||||||
pub use filesystem::FilesystemStub;
|
pub use filesystem::FilesystemStub;
|
||||||
pub use http::HttpStub;
|
pub use http::HttpStub;
|
||||||
|
pub use ldap_server::LdapStub;
|
||||||
pub use redis::RedisStub;
|
pub use redis::RedisStub;
|
||||||
pub use sql::SqlStub;
|
pub use sql::SqlStub;
|
||||||
|
|
||||||
|
|
@ -83,6 +85,11 @@ pub enum StubKind {
|
||||||
/// Sandbox-local fake filesystem root. Endpoint is an absolute
|
/// Sandbox-local fake filesystem root. Endpoint is an absolute
|
||||||
/// directory path that the harness is expected to use as its root.
|
/// directory path that the harness is expected to use as its root.
|
||||||
Filesystem,
|
Filesystem,
|
||||||
|
/// Minimal in-sandbox LDAP server stub (Phase 06 — Track J.4).
|
||||||
|
/// Endpoint is `127.0.0.1:{port}`; the wire protocol is the text
|
||||||
|
/// one-liner documented in
|
||||||
|
/// [`crate::dynamic::stubs::ldap_server`].
|
||||||
|
Ldap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StubKind {
|
impl StubKind {
|
||||||
|
|
@ -96,6 +103,7 @@ impl StubKind {
|
||||||
StubKind::Http => "NYX_HTTP_ENDPOINT",
|
StubKind::Http => "NYX_HTTP_ENDPOINT",
|
||||||
StubKind::Redis => "NYX_REDIS_ENDPOINT",
|
StubKind::Redis => "NYX_REDIS_ENDPOINT",
|
||||||
StubKind::Filesystem => "NYX_FS_ROOT",
|
StubKind::Filesystem => "NYX_FS_ROOT",
|
||||||
|
StubKind::Ldap => ldap_server::LDAP_ENDPOINT_ENV_VAR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +116,7 @@ impl StubKind {
|
||||||
StubKind::Http => "http",
|
StubKind::Http => "http",
|
||||||
StubKind::Redis => "redis",
|
StubKind::Redis => "redis",
|
||||||
StubKind::Filesystem => "filesystem",
|
StubKind::Filesystem => "filesystem",
|
||||||
|
StubKind::Ldap => "ldap",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +137,9 @@ impl StubKind {
|
||||||
if cap.contains(Cap::FILE_IO) {
|
if cap.contains(Cap::FILE_IO) {
|
||||||
out.push(StubKind::Filesystem);
|
out.push(StubKind::Filesystem);
|
||||||
}
|
}
|
||||||
|
if cap.contains(Cap::LDAP_INJECTION) {
|
||||||
|
out.push(StubKind::Ldap);
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +256,7 @@ impl StubHarness {
|
||||||
StubKind::Http => Arc::new(HttpStub::start(workdir)?),
|
StubKind::Http => Arc::new(HttpStub::start(workdir)?),
|
||||||
StubKind::Redis => Arc::new(RedisStub::start()?),
|
StubKind::Redis => Arc::new(RedisStub::start()?),
|
||||||
StubKind::Filesystem => Arc::new(FilesystemStub::start(workdir)?),
|
StubKind::Filesystem => Arc::new(FilesystemStub::start(workdir)?),
|
||||||
|
StubKind::Ldap => Arc::new(LdapStub::start()?),
|
||||||
};
|
};
|
||||||
stubs.push(stub);
|
stubs.push(stub);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
||||||
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
||||||
/// jointly guard drift.
|
/// jointly guard drift.
|
||||||
pub const CORPUS_VERSION: &str = "9";
|
pub const CORPUS_VERSION: &str = "10";
|
||||||
|
|
||||||
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
||||||
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
||||||
|
|
|
||||||
16
tests/dynamic_fixtures/ldap_injection/java/Benign.java
Normal file
16
tests/dynamic_fixtures/ldap_injection/java/Benign.java
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Phase 06 (Track J.4) — Java LDAP_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `Vuln.java` but routes the attacker-controlled `uid`
|
||||||
|
// through `org.springframework.ldap.support.LdapEncoder.filterEncode`
|
||||||
|
// before splicing it into the filter, so any wildcard / paren breakout
|
||||||
|
// is escaped and the directory keeps returning at most one entry.
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.ldap.core.LdapTemplate;
|
||||||
|
import org.springframework.ldap.support.LdapEncoder;
|
||||||
|
|
||||||
|
public class Benign {
|
||||||
|
public static List<Object> run(String uid, LdapTemplate template) {
|
||||||
|
String filter = "(uid=" + LdapEncoder.filterEncode(uid) + ")";
|
||||||
|
return template.search("ou=people,dc=nyx,dc=test", filter, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/dynamic_fixtures/ldap_injection/java/Vuln.java
Normal file
16
tests/dynamic_fixtures/ldap_injection/java/Vuln.java
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Phase 06 (Track J.4) — Java LDAP_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function string-concatenates the attacker-controlled `uid`
|
||||||
|
// directly into the LDAP filter passed to `LdapTemplate.search`. A
|
||||||
|
// payload like `alice*)(uid=*` rewraps the filter as
|
||||||
|
// `(|(uid=alice*)(uid=*))` once the host wrapper pushes it through a
|
||||||
|
// containing `(|…)`/`(&…)` clause, matching every directory entry.
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.ldap.core.LdapTemplate;
|
||||||
|
|
||||||
|
public class Vuln {
|
||||||
|
public static List<Object> run(String uid, LdapTemplate template) {
|
||||||
|
String filter = "(uid=" + uid + ")";
|
||||||
|
return template.search("ou=people,dc=nyx,dc=test", filter, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/ldap_injection/php/benign.php
Normal file
13
tests/dynamic_fixtures/ldap_injection/php/benign.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
// Phase 06 (Track J.4) — PHP LDAP_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `vuln.php` but routes the attacker-controlled `$uid`
|
||||||
|
// through `ldap_escape($uid, "", LDAP_ESCAPE_FILTER)`, escaping the
|
||||||
|
// wildcard / paren breakout so the directory keeps returning at most
|
||||||
|
// one entry.
|
||||||
|
function run(string $uid) {
|
||||||
|
$c = ldap_connect("127.0.0.1");
|
||||||
|
ldap_bind($c);
|
||||||
|
$filter = "(uid=" . ldap_escape($uid, "", LDAP_ESCAPE_FILTER) . ")";
|
||||||
|
return ldap_search($c, "ou=people,dc=nyx,dc=test", $filter);
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/ldap_injection/php/vuln.php
Normal file
13
tests/dynamic_fixtures/ldap_injection/php/vuln.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
// Phase 06 (Track J.4) — PHP LDAP_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function string-concatenates the attacker-controlled `$uid` into
|
||||||
|
// the LDAP filter passed to `ldap_search`; a payload like
|
||||||
|
// `alice*)(uid=*` breaks out of the host `(uid=…)` clause and matches
|
||||||
|
// every directory entry.
|
||||||
|
function run(string $uid) {
|
||||||
|
$c = ldap_connect("127.0.0.1");
|
||||||
|
ldap_bind($c);
|
||||||
|
$filter = "(uid=" . $uid . ")";
|
||||||
|
return ldap_search($c, "ou=people,dc=nyx,dc=test", $filter);
|
||||||
|
}
|
||||||
14
tests/dynamic_fixtures/ldap_injection/python/benign.py
Normal file
14
tests/dynamic_fixtures/ldap_injection/python/benign.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Phase 06 (Track J.4) — Python LDAP_INJECTION benign control fixture.
|
||||||
|
|
||||||
|
Same shape as `vuln.py` but routes the attacker-controlled `uid`
|
||||||
|
through `ldap.dn.escape_filter_chars`, escaping the wildcard /
|
||||||
|
paren breakout so the directory keeps returning at most one entry.
|
||||||
|
"""
|
||||||
|
import ldap
|
||||||
|
import ldap.dn
|
||||||
|
|
||||||
|
|
||||||
|
def run(uid: str):
|
||||||
|
con = ldap.initialize("ldap://127.0.0.1")
|
||||||
|
filt = "(uid=" + ldap.dn.escape_filter_chars(uid) + ")"
|
||||||
|
return con.search_s("ou=people,dc=nyx,dc=test", ldap.SCOPE_SUBTREE, filt)
|
||||||
14
tests/dynamic_fixtures/ldap_injection/python/vuln.py
Normal file
14
tests/dynamic_fixtures/ldap_injection/python/vuln.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Phase 06 (Track J.4) — Python LDAP_INJECTION vuln fixture.
|
||||||
|
|
||||||
|
The function string-concatenates the attacker-controlled `uid` into the
|
||||||
|
LDAP filter passed to `ldap.search_s`; a payload like `alice*)(uid=*`
|
||||||
|
breaks out of the host `(uid=…)` clause and matches every directory
|
||||||
|
entry.
|
||||||
|
"""
|
||||||
|
import ldap
|
||||||
|
|
||||||
|
|
||||||
|
def run(uid: str):
|
||||||
|
con = ldap.initialize("ldap://127.0.0.1")
|
||||||
|
filt = "(uid=" + uid + ")"
|
||||||
|
return con.search_s("ou=people,dc=nyx,dc=test", ldap.SCOPE_SUBTREE, filt)
|
||||||
453
tests/ldap_corpus.rs
Normal file
453
tests/ldap_corpus.rs
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
//! Phase 06 (Track J.4) — LDAP_INJECTION corpus acceptance.
|
||||||
|
//!
|
||||||
|
//! Asserts the new cap end-to-end: corpus slices register per-language
|
||||||
|
//! vuln/benign pairs for Java / Python / PHP, the lang-aware resolver
|
||||||
|
//! pairs them inside the correct slice, the per-language harness
|
||||||
|
//! emitters splice in the synthetic LDAP filter evaluator + entries-
|
||||||
|
//! returned probe + sink-hit sentinel, the framework adapters fire on
|
||||||
|
//! the canonical sink call, and the in-sandbox LDAP server stub
|
||||||
|
//! returns three entries for the malicious filter / one entry for the
|
||||||
|
//! benign control.
|
||||||
|
//!
|
||||||
|
//! `cargo nextest run --features dynamic --test ldap_corpus`.
|
||||||
|
|
||||||
|
#![cfg(feature = "dynamic")]
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use nyx_scanner::dynamic::corpus::{
|
||||||
|
audit_marker_collisions, benign_payload_for_lang, payloads_for_lang,
|
||||||
|
resolve_benign_control_lang, Oracle,
|
||||||
|
};
|
||||||
|
use nyx_scanner::dynamic::framework::registry::adapters_for;
|
||||||
|
use nyx_scanner::dynamic::lang;
|
||||||
|
use nyx_scanner::dynamic::oracle::ProbePredicate;
|
||||||
|
use nyx_scanner::dynamic::probe::ProbeKind;
|
||||||
|
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
||||||
|
use nyx_scanner::dynamic::stubs::ldap_server::LdapStub;
|
||||||
|
use nyx_scanner::dynamic::stubs::{StubKind, StubProvider};
|
||||||
|
use nyx_scanner::labels::Cap;
|
||||||
|
use nyx_scanner::summary::FuncSummary;
|
||||||
|
use nyx_scanner::symbol::Lang;
|
||||||
|
|
||||||
|
const LANGS: &[Lang] = &[Lang::Java, Lang::Python, Lang::Php];
|
||||||
|
|
||||||
|
fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec {
|
||||||
|
HarnessSpec {
|
||||||
|
finding_id: "phase06test0001".into(),
|
||||||
|
entry_file: entry_file.into(),
|
||||||
|
entry_name: entry_name.into(),
|
||||||
|
entry_kind: EntryKind::Function,
|
||||||
|
lang,
|
||||||
|
toolchain_id: "phase06".into(),
|
||||||
|
payload_slot: PayloadSlot::Param(0),
|
||||||
|
expected_cap: Cap::LDAP_INJECTION,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: entry_file.into(),
|
||||||
|
sink_line: 1,
|
||||||
|
spec_hash: "phase06test0001".into(),
|
||||||
|
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||||
|
stubs_required: vec![],
|
||||||
|
framework: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corpus_registers_ldap_for_every_supported_lang() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::LDAP_INJECTION, *lang);
|
||||||
|
assert!(!slice.is_empty(), "LDAP_INJECTION has no payloads for {lang:?}");
|
||||||
|
let has_vuln = slice.iter().any(|p| !p.is_benign);
|
||||||
|
let has_benign = slice.iter().any(|p| p.is_benign);
|
||||||
|
assert!(has_vuln, "{lang:?} LDAP missing vuln payload");
|
||||||
|
assert!(has_benign, "{lang:?} LDAP missing benign control");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ldap_unsupported_caps_unchanged_for_other_langs() {
|
||||||
|
for lang in [
|
||||||
|
Lang::Rust,
|
||||||
|
Lang::C,
|
||||||
|
Lang::Cpp,
|
||||||
|
Lang::Ruby,
|
||||||
|
Lang::Go,
|
||||||
|
Lang::JavaScript,
|
||||||
|
Lang::TypeScript,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
payloads_for_lang(Cap::LDAP_INJECTION, lang).is_empty(),
|
||||||
|
"unexpected LDAP_INJECTION payloads for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn benign_control_resolves_within_lang_slice() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::LDAP_INJECTION, *lang);
|
||||||
|
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||||
|
let resolved =
|
||||||
|
resolve_benign_control_lang(vuln, Cap::LDAP_INJECTION, *lang).expect("paired control");
|
||||||
|
assert!(resolved.is_benign);
|
||||||
|
let direct = benign_payload_for_lang(Cap::LDAP_INJECTION, *lang).unwrap();
|
||||||
|
assert_eq!(direct.label, resolved.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_oracle_carries_ldap_result_count_predicate() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::LDAP_INJECTION, *lang);
|
||||||
|
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||||
|
match &vuln.oracle {
|
||||||
|
Oracle::SinkProbe { predicates } => {
|
||||||
|
assert!(
|
||||||
|
predicates.iter().any(|p| matches!(
|
||||||
|
p,
|
||||||
|
ProbePredicate::LdapResultCountGreaterThan { n: 1 }
|
||||||
|
)),
|
||||||
|
"{lang:?} vuln payload missing LdapResultCountGreaterThan {{ n: 1 }}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vuln_payload_bytes_contain_filter_breakout() {
|
||||||
|
// The whole differential rule rests on the vuln payload carrying
|
||||||
|
// a `*)(uid=*`-style filter breakout and the benign control NOT
|
||||||
|
// carrying one — pin both invariants so a future corpus tweak
|
||||||
|
// does not silently break the oracle.
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::LDAP_INJECTION, *lang);
|
||||||
|
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||||
|
let benign = slice.iter().find(|p| p.is_benign).unwrap();
|
||||||
|
let vuln_text = std::str::from_utf8(vuln.bytes).unwrap();
|
||||||
|
let benign_text = std::str::from_utf8(benign.bytes).unwrap();
|
||||||
|
assert!(
|
||||||
|
vuln_text.contains("*") && vuln_text.contains(")"),
|
||||||
|
"{lang:?} vuln payload must carry a wildcard + paren breakout",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!benign_text.contains("*") && !benign_text.contains(")"),
|
||||||
|
"{lang:?} benign control must not carry filter metacharacters",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_collisions_clean_with_phase_06_additions() {
|
||||||
|
assert!(audit_marker_collisions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_kind_ldap_serdes() {
|
||||||
|
let original = ProbeKind::Ldap { entries_returned: 3 };
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
assert!(json.contains("Ldap"));
|
||||||
|
assert!(json.contains("entries_returned"));
|
||||||
|
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lang_emitter_dispatches_to_ldap_harness() {
|
||||||
|
// Per-lang `sink_callee_marker` pins which client-construction
|
||||||
|
// string the harness names in its probe record — the
|
||||||
|
// `LdapTemplate.search` / `ldap.search_s` / `ldap_search`
|
||||||
|
// boundary the brief calls out.
|
||||||
|
for (lang, entry_file, entry_name, sink_callee_marker) in [
|
||||||
|
(
|
||||||
|
Lang::Java,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/java/Vuln.java",
|
||||||
|
"run",
|
||||||
|
"LdapTemplate.search",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Python,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/python/vuln.py",
|
||||||
|
"run",
|
||||||
|
"ldap.search_s",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Php,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/php/vuln.php",
|
||||||
|
"run",
|
||||||
|
"ldap_search",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let spec = make_spec(lang, entry_file, entry_name);
|
||||||
|
let harness = lang::emit(&spec)
|
||||||
|
.unwrap_or_else(|e| panic!("emit failed for {lang:?}: {e:?}"));
|
||||||
|
assert!(
|
||||||
|
harness.source.contains("entries_returned"),
|
||||||
|
"{lang:?} ldap harness must carry the entries_returned probe field",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains(sink_callee_marker),
|
||||||
|
"{lang:?} ldap harness must name {sink_callee_marker:?} as the sink callee",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains("__NYX_SINK_HIT__"),
|
||||||
|
"{lang:?} ldap harness must emit the sink-hit sentinel",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains("uid="),
|
||||||
|
"{lang:?} ldap harness must build a `(uid=…)` filter from NYX_PAYLOAD",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framework_adapters_detect_ldap_sink() {
|
||||||
|
// Each lang registers its J.4 LDAP-search adapter; detect_binding
|
||||||
|
// routes through the registry and stamps an EntryKind::Function
|
||||||
|
// binding when the fixture contains the canonical sink call.
|
||||||
|
for (lang, fixture, sink_callee) in [
|
||||||
|
(
|
||||||
|
Lang::Java,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/java/Vuln.java",
|
||||||
|
"search",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Python,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/python/vuln.py",
|
||||||
|
"search_s",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Php,
|
||||||
|
"tests/dynamic_fixtures/ldap_injection/php/vuln.php",
|
||||||
|
"ldap_search",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let bytes = std::fs::read(fixture).expect("fixture exists");
|
||||||
|
let ts_lang = ts_language_for(lang);
|
||||||
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
parser.set_language(&ts_lang).unwrap();
|
||||||
|
let tree = parser.parse(&bytes, None).unwrap();
|
||||||
|
let mut summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
file_path: fixture.to_owned(),
|
||||||
|
lang: slug(lang).into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
summary
|
||||||
|
.callees
|
||||||
|
.push(nyx_scanner::summary::CalleeSite::bare(sink_callee));
|
||||||
|
let registry_slice = adapters_for(lang);
|
||||||
|
assert!(!registry_slice.is_empty(), "{lang:?} adapter slice empty");
|
||||||
|
let binding = nyx_scanner::dynamic::framework::detect_binding(
|
||||||
|
&summary,
|
||||||
|
tree.root_node(),
|
||||||
|
&bytes,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
let b = binding
|
||||||
|
.unwrap_or_else(|| panic!("{lang:?} adapter must detect the LDAP fixture"));
|
||||||
|
assert_eq!(b.kind, EntryKind::Function);
|
||||||
|
assert!(!b.adapter.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ts_language_for(lang: Lang) -> tree_sitter::Language {
|
||||||
|
match lang {
|
||||||
|
Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE),
|
||||||
|
Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE),
|
||||||
|
Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP),
|
||||||
|
other => panic!("unsupported test lang {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::Java => "java",
|
||||||
|
Lang::Python => "python",
|
||||||
|
Lang::Php => "php",
|
||||||
|
_ => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stub_ldap_server_returns_three_for_wildcard_filter() {
|
||||||
|
// The acceptance bullet states: stub LDAP server returns > 1
|
||||||
|
// entry on the malicious filter, exactly 1 on the benign filter.
|
||||||
|
// Pin both directions against the actual stub.
|
||||||
|
let stub = LdapStub::start().expect("ldap stub starts");
|
||||||
|
let mal = LdapStub::evaluate("(|(uid=alice)(uid=*))");
|
||||||
|
let benign = LdapStub::evaluate("(uid=alice)");
|
||||||
|
assert!(mal.len() > 1, "malicious filter must match > 1 entry, got {mal:?}");
|
||||||
|
assert_eq!(benign.len(), 1, "benign filter must match exactly 1 entry");
|
||||||
|
assert_eq!(stub.kind(), StubKind::Ldap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stub_kind_for_cap_routes_ldap_injection() {
|
||||||
|
let kinds = StubKind::for_cap(Cap::LDAP_INJECTION);
|
||||||
|
assert!(kinds.contains(&StubKind::Ldap));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End-to-end Phase 06 acceptance via run_spec ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors the `e2e_phase_05` block in `xxe_corpus.rs`. Drives
|
||||||
|
// `run_spec` directly on a `Cap::LDAP_INJECTION` spec per language and
|
||||||
|
// asserts the polarity via the `ProbeKind::Ldap { entries_returned > 1 }`
|
||||||
|
// probe and the `__NYX_SINK_HIT__` sentinel. The synthetic harness
|
||||||
|
// mirrors the in-sandbox LDAP server stub's RFC-4515 subset locally,
|
||||||
|
// so the verdict path is deterministic even when the stub itself is
|
||||||
|
// not spawned (`stubs_required: vec![]`).
|
||||||
|
|
||||||
|
mod e2e_phase_06 {
|
||||||
|
use crate::common::fixture_harness::FIXTURE_LOCK;
|
||||||
|
use nyx_scanner::dynamic::runner::{run_spec, RunError, RunOutcome};
|
||||||
|
use nyx_scanner::dynamic::sandbox::{SandboxBackend, SandboxOptions};
|
||||||
|
use nyx_scanner::dynamic::spec::{
|
||||||
|
default_toolchain_id, EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy,
|
||||||
|
};
|
||||||
|
use nyx_scanner::evidence::DifferentialVerdict;
|
||||||
|
use nyx_scanner::labels::Cap;
|
||||||
|
use nyx_scanner::symbol::Lang;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn command_available(bin: &str) -> bool {
|
||||||
|
Command::new(bin)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toolchain_for(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::Java => "java",
|
||||||
|
Lang::Python => "python3",
|
||||||
|
Lang::Php => "php",
|
||||||
|
_ => unreachable!("e2e_phase_06 covers Java/Python/PHP"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lang_subdir(lang: Lang) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
Lang::Java => "java",
|
||||||
|
Lang::Python => "python",
|
||||||
|
Lang::Php => "php",
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_spec(lang: Lang, fixture: &str, entry_name: &str) -> (HarnessSpec, TempDir) {
|
||||||
|
let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/dynamic_fixtures/ldap_injection")
|
||||||
|
.join(lang_subdir(lang))
|
||||||
|
.join(fixture);
|
||||||
|
let tmp = TempDir::new().expect("create tempdir");
|
||||||
|
let dst = tmp.path().join(fixture);
|
||||||
|
std::fs::copy(&fixture_src, &dst).expect("copy fixture into tempdir");
|
||||||
|
|
||||||
|
let entry_file = dst.to_string_lossy().into_owned();
|
||||||
|
let mut digest = blake3::Hasher::new();
|
||||||
|
digest.update(b"phase06-e2e-ldap|");
|
||||||
|
digest.update(lang_subdir(lang).as_bytes());
|
||||||
|
digest.update(b"|");
|
||||||
|
digest.update(fixture.as_bytes());
|
||||||
|
let spec_hash = format!("{:016x}", {
|
||||||
|
let bytes = digest.finalize();
|
||||||
|
u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches!(lang, Lang::Java) {
|
||||||
|
let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec_hash);
|
||||||
|
let _ = std::fs::remove_dir_all(&workdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let spec = HarnessSpec {
|
||||||
|
finding_id: spec_hash.clone(),
|
||||||
|
entry_file: entry_file.clone(),
|
||||||
|
entry_name: entry_name.to_owned(),
|
||||||
|
entry_kind: EntryKind::Function,
|
||||||
|
lang,
|
||||||
|
toolchain_id: default_toolchain_id(lang).into(),
|
||||||
|
payload_slot: PayloadSlot::Param(0),
|
||||||
|
expected_cap: Cap::LDAP_INJECTION,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: entry_file,
|
||||||
|
sink_line: 1,
|
||||||
|
spec_hash: spec_hash.clone(),
|
||||||
|
derivation: SpecDerivationStrategy::FromFlowSteps,
|
||||||
|
stubs_required: vec![],
|
||||||
|
framework: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(spec, tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(lang: Lang, fixture: &str, entry_name: &str) -> Option<RunOutcome> {
|
||||||
|
let bin = toolchain_for(lang);
|
||||||
|
if !command_available(bin) {
|
||||||
|
eprintln!("SKIP {lang:?} {fixture}: missing toolchain {bin}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let (spec, _tmp) = build_spec(lang, fixture, entry_name);
|
||||||
|
let opts = SandboxOptions {
|
||||||
|
backend: SandboxBackend::Process,
|
||||||
|
..SandboxOptions::default()
|
||||||
|
};
|
||||||
|
match run_spec(&spec, &opts) {
|
||||||
|
Ok(outcome) => Some(outcome),
|
||||||
|
Err(RunError::BuildFailed { stderr, attempts }) => {
|
||||||
|
eprintln!(
|
||||||
|
"SKIP {lang:?} {fixture}: harness build failed after {attempts} attempts: {stderr}",
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => panic!("run_spec({lang:?} {fixture}) errored: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn java_vuln_confirms_via_run_spec() {
|
||||||
|
let Some(outcome) = run(Lang::Java, "Vuln.java", "run") else { return };
|
||||||
|
assert!(
|
||||||
|
outcome.triggered_by.is_some(),
|
||||||
|
"Java LDAP vuln must Confirm via run_spec; got {outcome:?}",
|
||||||
|
);
|
||||||
|
let diff = outcome
|
||||||
|
.differential
|
||||||
|
.as_ref()
|
||||||
|
.expect("Confirmed run must carry a DifferentialOutcome");
|
||||||
|
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn python_vuln_confirms_via_run_spec() {
|
||||||
|
let Some(outcome) = run(Lang::Python, "vuln.py", "run") else { return };
|
||||||
|
assert!(
|
||||||
|
outcome.triggered_by.is_some(),
|
||||||
|
"Python LDAP vuln must Confirm via run_spec; got {outcome:?}",
|
||||||
|
);
|
||||||
|
let diff = outcome
|
||||||
|
.differential
|
||||||
|
.as_ref()
|
||||||
|
.expect("Confirmed run must carry a DifferentialOutcome");
|
||||||
|
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn php_vuln_confirms_via_run_spec() {
|
||||||
|
let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { return };
|
||||||
|
assert!(
|
||||||
|
outcome.triggered_by.is_some(),
|
||||||
|
"PHP LDAP vuln must Confirm via run_spec; got {outcome:?}",
|
||||||
|
);
|
||||||
|
let diff = outcome
|
||||||
|
.differential
|
||||||
|
.as_ref()
|
||||||
|
.expect("Confirmed run must carry a DifferentialOutcome");
|
||||||
|
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue