From 6784d73e256ca69584d5b1cc94bc52035bec4c39 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 09:37:37 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2011:=20Track=20J.9=20+=20Tra?= =?UTF-8?q?ck=20L.9=20=E2=80=94=20`CRYPTO`,=20`JSON=5FPARSE`,=20`UNAUTHORI?= =?UTF-8?q?ZED=5FID`,=20`DATA=5FEXFIL`=20corpora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/corpus.rs | 7 +- src/dynamic/corpus/crypto/go.rs | 44 +++++ src/dynamic/corpus/crypto/java.rs | 55 ++++++ src/dynamic/corpus/crypto/mod.rs | 26 +++ src/dynamic/corpus/crypto/php.rs | 43 +++++ src/dynamic/corpus/crypto/python.rs | 53 ++++++ src/dynamic/corpus/crypto/rust.rs | 44 +++++ src/dynamic/corpus/data_exfil/go.rs | 43 +++++ src/dynamic/corpus/data_exfil/java.rs | 43 +++++ src/dynamic/corpus/data_exfil/js.rs | 43 +++++ src/dynamic/corpus/data_exfil/mod.rs | 22 +++ src/dynamic/corpus/data_exfil/php.rs | 43 +++++ src/dynamic/corpus/data_exfil/python.rs | 43 +++++ src/dynamic/corpus/data_exfil/ruby.rs | 43 +++++ src/dynamic/corpus/data_exfil/rust.rs | 43 +++++ src/dynamic/corpus/json_parse/javascript.rs | 51 ++++++ src/dynamic/corpus/json_parse/mod.rs | 21 +++ src/dynamic/corpus/json_parse/python.rs | 45 +++++ src/dynamic/corpus/json_parse/ruby.rs | 44 +++++ src/dynamic/corpus/registry.rs | 159 ++++++++++++++++-- src/dynamic/corpus/unauthorized_id/go.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/java.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/js.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/mod.rs | 23 +++ src/dynamic/corpus/unauthorized_id/php.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/python.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/ruby.rs | 41 +++++ src/dynamic/corpus/unauthorized_id/rust.rs | 41 +++++ src/dynamic/oracle.rs | 154 ++++++++++++++++- src/dynamic/probe.rs | 43 +++++ src/dynamic/runner.rs | 25 +++ src/dynamic/telemetry.rs | 2 +- src/dynamic/verify.rs | 14 ++ src/evidence.rs | 22 +++ src/fmt.rs | 7 + tests/crypto_corpus.rs | 128 ++++++++++++++ tests/data_exfil_corpus.rs | 111 ++++++++++++ tests/dynamic_fixtures/crypto/go/benign.go | 12 ++ tests/dynamic_fixtures/crypto/go/vuln.go | 12 ++ .../dynamic_fixtures/crypto/java/benign.java | 14 ++ tests/dynamic_fixtures/crypto/java/vuln.java | 16 ++ tests/dynamic_fixtures/crypto/php/benign.php | 7 + tests/dynamic_fixtures/crypto/php/vuln.php | 7 + .../dynamic_fixtures/crypto/python/benign.py | 9 + tests/dynamic_fixtures/crypto/python/vuln.py | 10 ++ tests/dynamic_fixtures/crypto/rust/benign.rs | 11 ++ tests/dynamic_fixtures/crypto/rust/vuln.rs | 9 + .../dynamic_fixtures/data_exfil/go/benign.go | 19 +++ tests/dynamic_fixtures/data_exfil/go/vuln.go | 14 ++ .../data_exfil/java/benign.java | 16 ++ .../data_exfil/java/vuln.java | 13 ++ .../dynamic_fixtures/data_exfil/js/benign.js | 17 ++ tests/dynamic_fixtures/data_exfil/js/vuln.js | 14 ++ .../data_exfil/php/benign.php | 8 + .../dynamic_fixtures/data_exfil/php/vuln.php | 7 + .../data_exfil/python/benign.py | 15 ++ .../data_exfil/python/vuln.py | 12 ++ .../data_exfil/ruby/benign.rb | 12 ++ .../dynamic_fixtures/data_exfil/ruby/vuln.rb | 9 + .../data_exfil/rust/benign.rs | 11 ++ .../dynamic_fixtures/data_exfil/rust/vuln.rs | 6 + .../json_parse/javascript/benign.js | 16 ++ .../json_parse/javascript/vuln.js | 24 +++ .../json_parse/python/benign.py | 10 ++ .../json_parse/python/vuln.py | 20 +++ .../json_parse/ruby/benign.rb | 9 + .../dynamic_fixtures/json_parse/ruby/vuln.rb | 15 ++ .../unauthorized_id/go/benign.go | 13 ++ .../unauthorized_id/go/vuln.go | 10 ++ .../unauthorized_id/java/benign.java | 17 ++ .../unauthorized_id/java/vuln.java | 16 ++ .../unauthorized_id/js/benign.js | 10 ++ .../unauthorized_id/js/vuln.js | 9 + .../unauthorized_id/php/benign.php | 10 ++ .../unauthorized_id/php/vuln.php | 9 + .../unauthorized_id/python/benign.py | 12 ++ .../unauthorized_id/python/vuln.py | 11 ++ .../unauthorized_id/ruby/benign.rb | 8 + .../unauthorized_id/ruby/vuln.rb | 7 + .../unauthorized_id/rust/benign.rs | 14 ++ .../unauthorized_id/rust/vuln.rs | 11 ++ tests/dynamic_verify_e2e.rs | 23 ++- tests/json_parse_corpus.rs | 106 ++++++++++++ tests/sound_oracle_unavailable.rs | 43 +++++ tests/unauthorized_id_corpus.rs | 104 ++++++++++++ 85 files changed, 2508 insertions(+), 30 deletions(-) create mode 100644 src/dynamic/corpus/crypto/go.rs create mode 100644 src/dynamic/corpus/crypto/java.rs create mode 100644 src/dynamic/corpus/crypto/mod.rs create mode 100644 src/dynamic/corpus/crypto/php.rs create mode 100644 src/dynamic/corpus/crypto/python.rs create mode 100644 src/dynamic/corpus/crypto/rust.rs create mode 100644 src/dynamic/corpus/data_exfil/go.rs create mode 100644 src/dynamic/corpus/data_exfil/java.rs create mode 100644 src/dynamic/corpus/data_exfil/js.rs create mode 100644 src/dynamic/corpus/data_exfil/mod.rs create mode 100644 src/dynamic/corpus/data_exfil/php.rs create mode 100644 src/dynamic/corpus/data_exfil/python.rs create mode 100644 src/dynamic/corpus/data_exfil/ruby.rs create mode 100644 src/dynamic/corpus/data_exfil/rust.rs create mode 100644 src/dynamic/corpus/json_parse/javascript.rs create mode 100644 src/dynamic/corpus/json_parse/mod.rs create mode 100644 src/dynamic/corpus/json_parse/python.rs create mode 100644 src/dynamic/corpus/json_parse/ruby.rs create mode 100644 src/dynamic/corpus/unauthorized_id/go.rs create mode 100644 src/dynamic/corpus/unauthorized_id/java.rs create mode 100644 src/dynamic/corpus/unauthorized_id/js.rs create mode 100644 src/dynamic/corpus/unauthorized_id/mod.rs create mode 100644 src/dynamic/corpus/unauthorized_id/php.rs create mode 100644 src/dynamic/corpus/unauthorized_id/python.rs create mode 100644 src/dynamic/corpus/unauthorized_id/ruby.rs create mode 100644 src/dynamic/corpus/unauthorized_id/rust.rs create mode 100644 tests/crypto_corpus.rs create mode 100644 tests/data_exfil_corpus.rs create mode 100644 tests/dynamic_fixtures/crypto/go/benign.go create mode 100644 tests/dynamic_fixtures/crypto/go/vuln.go create mode 100644 tests/dynamic_fixtures/crypto/java/benign.java create mode 100644 tests/dynamic_fixtures/crypto/java/vuln.java create mode 100644 tests/dynamic_fixtures/crypto/php/benign.php create mode 100644 tests/dynamic_fixtures/crypto/php/vuln.php create mode 100644 tests/dynamic_fixtures/crypto/python/benign.py create mode 100644 tests/dynamic_fixtures/crypto/python/vuln.py create mode 100644 tests/dynamic_fixtures/crypto/rust/benign.rs create mode 100644 tests/dynamic_fixtures/crypto/rust/vuln.rs create mode 100644 tests/dynamic_fixtures/data_exfil/go/benign.go create mode 100644 tests/dynamic_fixtures/data_exfil/go/vuln.go create mode 100644 tests/dynamic_fixtures/data_exfil/java/benign.java create mode 100644 tests/dynamic_fixtures/data_exfil/java/vuln.java create mode 100644 tests/dynamic_fixtures/data_exfil/js/benign.js create mode 100644 tests/dynamic_fixtures/data_exfil/js/vuln.js create mode 100644 tests/dynamic_fixtures/data_exfil/php/benign.php create mode 100644 tests/dynamic_fixtures/data_exfil/php/vuln.php create mode 100644 tests/dynamic_fixtures/data_exfil/python/benign.py create mode 100644 tests/dynamic_fixtures/data_exfil/python/vuln.py create mode 100644 tests/dynamic_fixtures/data_exfil/ruby/benign.rb create mode 100644 tests/dynamic_fixtures/data_exfil/ruby/vuln.rb create mode 100644 tests/dynamic_fixtures/data_exfil/rust/benign.rs create mode 100644 tests/dynamic_fixtures/data_exfil/rust/vuln.rs create mode 100644 tests/dynamic_fixtures/json_parse/javascript/benign.js create mode 100644 tests/dynamic_fixtures/json_parse/javascript/vuln.js create mode 100644 tests/dynamic_fixtures/json_parse/python/benign.py create mode 100644 tests/dynamic_fixtures/json_parse/python/vuln.py create mode 100644 tests/dynamic_fixtures/json_parse/ruby/benign.rb create mode 100644 tests/dynamic_fixtures/json_parse/ruby/vuln.rb create mode 100644 tests/dynamic_fixtures/unauthorized_id/go/benign.go create mode 100644 tests/dynamic_fixtures/unauthorized_id/go/vuln.go create mode 100644 tests/dynamic_fixtures/unauthorized_id/java/benign.java create mode 100644 tests/dynamic_fixtures/unauthorized_id/java/vuln.java create mode 100644 tests/dynamic_fixtures/unauthorized_id/js/benign.js create mode 100644 tests/dynamic_fixtures/unauthorized_id/js/vuln.js create mode 100644 tests/dynamic_fixtures/unauthorized_id/php/benign.php create mode 100644 tests/dynamic_fixtures/unauthorized_id/php/vuln.php create mode 100644 tests/dynamic_fixtures/unauthorized_id/python/benign.py create mode 100644 tests/dynamic_fixtures/unauthorized_id/python/vuln.py create mode 100644 tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb create mode 100644 tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb create mode 100644 tests/dynamic_fixtures/unauthorized_id/rust/benign.rs create mode 100644 tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs create mode 100644 tests/json_parse_corpus.rs create mode 100644 tests/sound_oracle_unavailable.rs create mode 100644 tests/unauthorized_id_corpus.rs diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 1663649c..6b7620b8 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -48,9 +48,12 @@ pub mod audit; pub mod registry; mod cmdi; +mod crypto; +mod data_exfil; mod deserialize; mod fmt_string; mod header_injection; +mod json_parse; mod ldap; mod open_redirect; mod path_trav; @@ -58,6 +61,7 @@ mod prototype_pollution; mod sqli; mod ssrf; mod ssti; +mod unauthorized_id; mod xpath; mod xss; mod xxe; @@ -98,7 +102,8 @@ pub use crate::dynamic::oracle::Oracle; /// | 12 | 2026-05-18 | Phase 08 / Track J.6: `HEADER_INJECTION` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::HeaderEmit` + `ProbePredicate::HeaderInjected`; per-lang `setHeader` shims | /// | 13 | 2026-05-18 | Phase 09 / Track J.7: `OPEN_REDIRECT` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::Redirect` + `ProbePredicate::RedirectHostNotIn`; per-lang `sendRedirect` / `redirect()` shims | /// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` | -pub const CORPUS_VERSION: u32 = 14; +/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle | +pub const CORPUS_VERSION: u32 = 15; /// Where a payload originated. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/dynamic/corpus/crypto/go.rs b/src/dynamic/corpus/crypto/go.rs new file mode 100644 index 00000000..0b498440 --- /dev/null +++ b/src/dynamic/corpus/crypto/go.rs @@ -0,0 +1,44 @@ +//! Go `Cap::CRYPTO` payloads — `math/rand.Intn` weak-key +//! generation. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const WEAK_BITS: u32 = 16; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_CRYPTO_WEAK", + label: "crypto-go-weak-random", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/go/vuln.go"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + benign_control: Some(PayloadRef { + label: "crypto-go-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_CRYPTO_STRONG", + label: "crypto-go-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/go/benign.go"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/crypto/java.rs b/src/dynamic/corpus/crypto/java.rs new file mode 100644 index 00000000..3276d5c8 --- /dev/null +++ b/src/dynamic/corpus/crypto/java.rs @@ -0,0 +1,55 @@ +//! Java `Cap::CRYPTO` payloads — `java.util.Random.nextBytes` +//! weak-key generation. +//! +//! Vuln payload: marker bytes that signal the harness to drive its +//! `java.util.Random` key-generation path. The harness emits a key +//! bounded inside a 16-bit search space and writes a +//! [`crate::dynamic::probe::ProbeKind::WeakKey`] probe — the +//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`] +//! predicate fires for `key_int < 2^16`. +//! +//! Benign control: marker bytes that route the harness through +//! `java.security.SecureRandom`, producing a 256-bit key whose +//! integer view trivially exceeds the budget. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const WEAK_BITS: u32 = 16; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_CRYPTO_WEAK", + label: "crypto-java-weak-random", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/java/vuln.java"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + benign_control: Some(PayloadRef { + label: "crypto-java-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_CRYPTO_STRONG", + label: "crypto-java-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/java/benign.java"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/crypto/mod.rs b/src/dynamic/corpus/crypto/mod.rs new file mode 100644 index 00000000..f9f9c2cd --- /dev/null +++ b/src/dynamic/corpus/crypto/mod.rs @@ -0,0 +1,26 @@ +//! Weak-crypto (`Cap::CRYPTO`) per-language payload slices. +//! +//! Phase 11 (Track J.9) carves a weak-key entropy oracle across the +//! five backend languages where homegrown key generation is common +//! enough to matter: Java (`java.util.Random.nextBytes` → key bytes), +//! Python (`random.randint(0, 0xFFFF)`), PHP (`mt_rand(0, 0xFFFF)`), +//! Go (`math/rand.Intn(0x10000)`), Rust (`rand::thread_rng` truncated +//! to 16 bits). Every vuln payload triggers the harness's +//! instrumented key-generation path with a seed that produces an +//! attacker-derivable key bounded inside the 16-bit search space. +//! The harness shim writes a +//! [`crate::dynamic::probe::ProbeKind::WeakKey { key_int }`] probe +//! with the produced integer view of the key bytes; the +//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`] +//! predicate fires when `key_int < 2^max_bits` (`max_bits = 16` by +//! default). The paired benign control routes the same harness +//! through a CSPRNG (`SecureRandom`, `secrets.token_bytes`, +//! `random_bytes(32)`, `crypto/rand.Read`, `rand::rngs::OsRng`) so +//! the produced `key_int` trivially exceeds the budget and the +//! predicate stays clear. + +pub mod go; +pub mod java; +pub mod php; +pub mod python; +pub mod rust; diff --git a/src/dynamic/corpus/crypto/php.rs b/src/dynamic/corpus/crypto/php.rs new file mode 100644 index 00000000..fc6818fb --- /dev/null +++ b/src/dynamic/corpus/crypto/php.rs @@ -0,0 +1,43 @@ +//! PHP `Cap::CRYPTO` payloads — `mt_rand` weak-key generation. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const WEAK_BITS: u32 = 16; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_CRYPTO_WEAK", + label: "crypto-php-weak-random", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/php/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + benign_control: Some(PayloadRef { + label: "crypto-php-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_CRYPTO_STRONG", + label: "crypto-php-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/php/benign.php"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/crypto/python.rs b/src/dynamic/corpus/crypto/python.rs new file mode 100644 index 00000000..8b0915ed --- /dev/null +++ b/src/dynamic/corpus/crypto/python.rs @@ -0,0 +1,53 @@ +//! Python `Cap::CRYPTO` payloads — `random.randint` weak-key +//! generation. +//! +//! Vuln payload: marker bytes that route the harness through +//! `random.randint(0, 0xFFFF)`; the harness emits a +//! [`crate::dynamic::probe::ProbeKind::WeakKey`] probe and the +//! [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`] +//! predicate fires. +//! +//! Benign control: marker bytes that route the harness through +//! `secrets.token_bytes(32)`. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const WEAK_BITS: u32 = 16; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_CRYPTO_WEAK", + label: "crypto-python-weak-random", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/python/vuln.py"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + benign_control: Some(PayloadRef { + label: "crypto-python-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_CRYPTO_STRONG", + label: "crypto-python-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/python/benign.py"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/crypto/rust.rs b/src/dynamic/corpus/crypto/rust.rs new file mode 100644 index 00000000..3895fcd7 --- /dev/null +++ b/src/dynamic/corpus/crypto/rust.rs @@ -0,0 +1,44 @@ +//! Rust `Cap::CRYPTO` payloads — `rand::thread_rng` weak-key +//! generation truncated to 16 bits. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const WEAK_BITS: u32 = 16; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_CRYPTO_WEAK", + label: "crypto-rust-weak-random", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/rust/vuln.rs"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + benign_control: Some(PayloadRef { + label: "crypto-rust-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_CRYPTO_STRONG", + label: "crypto-rust-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: WEAK_BITS }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/crypto/rust/benign.rs"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/go.rs b/src/dynamic/corpus/data_exfil/go.rs new file mode 100644 index 00000000..d3afeb47 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/go.rs @@ -0,0 +1,43 @@ +//! go `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-go-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/go/vuln.go"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-go-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-go-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/go/benign.go"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/java.rs b/src/dynamic/corpus/data_exfil/java.rs new file mode 100644 index 00000000..8b6af8db --- /dev/null +++ b/src/dynamic/corpus/data_exfil/java.rs @@ -0,0 +1,43 @@ +//! java `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-java-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/java/vuln.java"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-java-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-java-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/java/benign.java"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/js.rs b/src/dynamic/corpus/data_exfil/js.rs new file mode 100644 index 00000000..47c47a79 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/js.rs @@ -0,0 +1,43 @@ +//! js `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-js-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/js/vuln.js"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-js-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-js-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/js/benign.js"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/mod.rs b/src/dynamic/corpus/data_exfil/mod.rs new file mode 100644 index 00000000..b538ec88 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/mod.rs @@ -0,0 +1,22 @@ +//! Data-exfiltration (`Cap::DATA_EXFIL`) per-language payload +//! slices. +//! +//! Phase 11 (Track J.9) carves an outbound-network oracle across +//! all seven backend-capable languages. Each harness stands up a +//! mock HTTP client that records the destination host of every +//! outbound request via a +//! [`crate::dynamic::probe::ProbeKind::OutboundNetwork { host }`] +//! probe. The +//! [`crate::dynamic::oracle::ProbePredicate::OutboundHostNotIn`] +//! predicate fires when the captured `host` falls outside the +//! configured loopback allowlist (`&["127.0.0.1", "localhost"]`). +//! The vuln payload supplies `attacker.test`; the paired benign +//! control supplies `127.0.0.1` so the predicate stays clear. + +pub mod go; +pub mod java; +pub mod js; +pub mod php; +pub mod python; +pub mod ruby; +pub mod rust; diff --git a/src/dynamic/corpus/data_exfil/php.rs b/src/dynamic/corpus/data_exfil/php.rs new file mode 100644 index 00000000..a1895826 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/php.rs @@ -0,0 +1,43 @@ +//! php `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-php-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/php/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-php-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-php-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/php/benign.php"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/python.rs b/src/dynamic/corpus/data_exfil/python.rs new file mode 100644 index 00000000..827e15e1 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/python.rs @@ -0,0 +1,43 @@ +//! python `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-python-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/python/vuln.py"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-python-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-python-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/python/benign.py"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/ruby.rs b/src/dynamic/corpus/data_exfil/ruby.rs new file mode 100644 index 00000000..9526cb49 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/ruby.rs @@ -0,0 +1,43 @@ +//! ruby `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-ruby-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/ruby/vuln.rb"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-ruby-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-ruby-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/ruby/benign.rb"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/data_exfil/rust.rs b/src/dynamic/corpus/data_exfil/rust.rs new file mode 100644 index 00000000..6bdb2e77 --- /dev/null +++ b/src/dynamic/corpus/data_exfil/rust.rs @@ -0,0 +1,43 @@ +//! rust `Cap::DATA_EXFIL` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"attacker.test", + label: "data-exfil-rust-external", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/rust/vuln.rs"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + benign_control: Some(PayloadRef { + label: "data-exfil-rust-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"127.0.0.1", + label: "data-exfil-rust-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { allowlist: ALLOWLIST }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/data_exfil/rust/benign.rs"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/json_parse/javascript.rs b/src/dynamic/corpus/json_parse/javascript.rs new file mode 100644 index 00000000..8f4e88be --- /dev/null +++ b/src/dynamic/corpus/json_parse/javascript.rs @@ -0,0 +1,51 @@ +//! JavaScript `Cap::JSON_PARSE` payloads — `JSON.parse` then deep +//! assign / `Object.assign` chain. +//! +//! Same canary oracle as the Phase 10 PROTOTYPE_POLLUTION corpus +//! ([`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]). +//! The harness routes both payloads through `JSON.parse` first to +//! exercise the parse-then-assign flow specifically (whereas the +//! Phase 10 corpus passes the JSON literal directly to the deep-merge +//! sink without an intervening parse). + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const CANARY: &str = "__nyx_canary"; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-js-proto-key", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/javascript/vuln.js"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + benign_control: Some(PayloadRef { + label: "json-parse-js-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-js-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/javascript/benign.js"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/json_parse/mod.rs b/src/dynamic/corpus/json_parse/mod.rs new file mode 100644 index 00000000..5742820e --- /dev/null +++ b/src/dynamic/corpus/json_parse/mod.rs @@ -0,0 +1,21 @@ +//! JSON-parse pollution (`Cap::JSON_PARSE`) per-language payload +//! slices. +//! +//! Phase 11 (Track J.9) reuses the prototype-canary oracle from +//! Phase 10 across the three languages whose JSON parsers have a +//! published pollution surface: JavaScript (`JSON.parse` then deep +//! assign), Python (`json.loads` then `dict.update` / +//! `setattr`-driven attribute pollution), Ruby (`JSON.parse` then +//! recursive merge). Every vuln payload binds a JSON literal whose +//! top-level key is `__proto__`; the per-language harness's +//! instrumented canary trap (`Object.prototype.__nyx_canary` in JS, +//! a `dict`/class-scoped sentinel in Python, an `Object.prepend` +//! flag in Ruby) records a +//! [`crate::dynamic::probe::ProbeKind::PrototypePollution`] probe +//! once the malicious key reaches the shared chain. The paired +//! benign control sends a JSON literal whose top-level key is the +//! regular property `data`, leaving the chain untouched. + +pub mod javascript; +pub mod python; +pub mod ruby; diff --git a/src/dynamic/corpus/json_parse/python.rs b/src/dynamic/corpus/json_parse/python.rs new file mode 100644 index 00000000..8816f48c --- /dev/null +++ b/src/dynamic/corpus/json_parse/python.rs @@ -0,0 +1,45 @@ +//! Python `Cap::JSON_PARSE` payloads — `json.loads` then +//! attribute-pollution via `setattr` / `dict.update` on a shared +//! sentinel object. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const CANARY: &str = "__nyx_canary"; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-python-proto-key", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/python/vuln.py"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + benign_control: Some(PayloadRef { + label: "json-parse-python-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-python-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/python/benign.py"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/json_parse/ruby.rs b/src/dynamic/corpus/json_parse/ruby.rs new file mode 100644 index 00000000..5a45fbde --- /dev/null +++ b/src/dynamic/corpus/json_parse/ruby.rs @@ -0,0 +1,44 @@ +//! Ruby `Cap::JSON_PARSE` payloads — `JSON.parse` then recursive +//! `Hash#deep_merge!` on a shared sentinel object. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const CANARY: &str = "__nyx_canary"; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: br#"{"__proto__":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-ruby-proto-key", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/ruby/vuln.rb"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + benign_control: Some(PayloadRef { + label: "json-parse-ruby-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: br#"{"data":{"__nyx_canary":"pwned"}}"#, + label: "json-parse-ruby-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { canary: CANARY }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse/ruby/benign.rb"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/registry.rs b/src/dynamic/corpus/registry.rs index 45e8ed1b..29189c96 100644 --- a/src/dynamic/corpus/registry.rs +++ b/src/dynamic/corpus/registry.rs @@ -24,8 +24,9 @@ use std::collections::HashMap; use std::sync::OnceLock; use super::{ - cmdi, deserialize, fmt_string, header_injection, ldap, open_redirect, path_trav, - prototype_pollution, sqli, ssrf, ssti, xpath, xss, xxe, + cmdi, crypto, data_exfil, deserialize, fmt_string, header_injection, json_parse, ldap, + open_redirect, path_trav, prototype_pollution, sqli, ssrf, ssti, unauthorized_id, xpath, xss, + xxe, }; use super::{CapCorpus, CuratedPayload, Oracle}; use crate::dynamic::oracle::ProbePredicate; @@ -36,13 +37,42 @@ use crate::symbol::Lang; /// and sinks we cannot yet model with a reliable oracle. The /// [`super::audit`] module asserts that the union of caps covered by /// [`CORPUS::entries`] and this constant equals [`Cap::all`]. -pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits() - | Cap::SHELL_ESCAPE.bits() - | Cap::URL_ENCODE.bits() - | Cap::JSON_PARSE.bits() - | Cap::CRYPTO.bits() - | Cap::UNAUTHORIZED_ID.bits() - | Cap::DATA_EXFIL.bits(); +/// +/// Phase 11 (Track J.9) carved `CRYPTO`, `JSON_PARSE`, +/// `UNAUTHORIZED_ID`, and `DATA_EXFIL` corpora; the remaining caps +/// here (`ENV_VAR`, `SHELL_ESCAPE`, `URL_ENCODE`) are pure +/// sources / sanitizers with no sink behaviour and route through +/// [`crate::evidence::UnsupportedReason::SoundOracleUnavailable`] +/// at run time. +pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = + Cap::ENV_VAR.bits() | Cap::SHELL_ESCAPE.bits() | Cap::URL_ENCODE.bits(); + +/// Caps for which no sound oracle exists — emitted as +/// [`crate::evidence::UnsupportedReason::SoundOracleUnavailable`] +/// instead of [`crate::evidence::UnsupportedReason::NoPayloadsForCap`] +/// so the unsupported budget accounting reflects the structural +/// impossibility rather than a missing-payload gap. Currently the +/// same set as [`CORPUS_UNSUPPORTED_LANG_NEUTRAL`]; kept as a +/// distinct constant so future caps that legitimately cannot be +/// oracled (e.g. side-channel timing) can land here without +/// expanding the lang-neutral unsupported set. +pub const CORPUS_SOUND_ORACLE_UNAVAILABLE: u32 = + Cap::ENV_VAR.bits() | Cap::SHELL_ESCAPE.bits() | Cap::URL_ENCODE.bits(); + +/// Human-actionable hint for [`CORPUS_SOUND_ORACLE_UNAVAILABLE`] +/// caps, surfaced via +/// [`crate::evidence::UnsupportedReason::SoundOracleUnavailable::hint`]. +pub fn sound_oracle_unavailable_hint(cap: Cap) -> &'static str { + if cap == Cap::ENV_VAR { + "ENV_VAR is a source cap with no externally-observable sink behaviour" + } else if cap == Cap::SHELL_ESCAPE { + "SHELL_ESCAPE is a sanitizer cap whose effect is observed at the wrapping sink" + } else if cap == Cap::URL_ENCODE { + "URL_ENCODE is a sanitizer cap whose effect is observed at the wrapping sink" + } else { + "no sound oracle is currently available for this cap" + } +} /// Flat `(Cap, Lang, slice)` table. A single cap can carry per-language /// variants — that's the whole reason this layer exists. @@ -98,6 +128,28 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[ Lang::TypeScript, prototype_pollution::typescript::PAYLOADS, ), + (Cap::CRYPTO, Lang::Java, crypto::java::PAYLOADS), + (Cap::CRYPTO, Lang::Python, crypto::python::PAYLOADS), + (Cap::CRYPTO, Lang::Php, crypto::php::PAYLOADS), + (Cap::CRYPTO, Lang::Go, crypto::go::PAYLOADS), + (Cap::CRYPTO, Lang::Rust, crypto::rust::PAYLOADS), + (Cap::JSON_PARSE, Lang::JavaScript, json_parse::javascript::PAYLOADS), + (Cap::JSON_PARSE, Lang::Python, json_parse::python::PAYLOADS), + (Cap::JSON_PARSE, Lang::Ruby, json_parse::ruby::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Python, unauthorized_id::python::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Ruby, unauthorized_id::ruby::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Java, unauthorized_id::java::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Php, unauthorized_id::php::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::JavaScript, unauthorized_id::js::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Go, unauthorized_id::go::PAYLOADS), + (Cap::UNAUTHORIZED_ID, Lang::Rust, unauthorized_id::rust::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Python, data_exfil::python::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Ruby, data_exfil::ruby::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Java, data_exfil::java::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Php, data_exfil::php::PAYLOADS), + (Cap::DATA_EXFIL, Lang::JavaScript, data_exfil::js::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Go, data_exfil::go::PAYLOADS), + (Cap::DATA_EXFIL, Lang::Rust, data_exfil::rust::PAYLOADS), ]; /// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by @@ -312,19 +364,18 @@ mod tests { assert!(!payloads_for(Cap::HEADER_INJECTION).is_empty()); assert!(!payloads_for(Cap::OPEN_REDIRECT).is_empty()); assert!(!payloads_for(Cap::PROTOTYPE_POLLUTION).is_empty()); + assert!(!payloads_for(Cap::CRYPTO).is_empty()); + assert!(!payloads_for(Cap::JSON_PARSE).is_empty()); + assert!(!payloads_for(Cap::UNAUTHORIZED_ID).is_empty()); + assert!(!payloads_for(Cap::DATA_EXFIL).is_empty()); } #[test] fn unsupported_caps_return_empty() { - let unsupported = [ - Cap::ENV_VAR, - Cap::SHELL_ESCAPE, - Cap::URL_ENCODE, - Cap::JSON_PARSE, - Cap::CRYPTO, - Cap::UNAUTHORIZED_ID, - Cap::DATA_EXFIL, - ]; + // Phase 11 (Track J.9): only pure-source / pure-sanitizer + // caps remain unsupported. CRYPTO / JSON_PARSE / + // UNAUTHORIZED_ID / DATA_EXFIL now carry payloads. + let unsupported = [Cap::ENV_VAR, Cap::SHELL_ESCAPE, Cap::URL_ENCODE]; for cap in unsupported { assert!( payloads_for(cap).is_empty(), @@ -333,6 +384,62 @@ mod tests { } } + #[test] + fn phase_11_caps_have_payloads() { + assert!(!payloads_for(Cap::CRYPTO).is_empty()); + assert!(!payloads_for(Cap::JSON_PARSE).is_empty()); + assert!(!payloads_for(Cap::UNAUTHORIZED_ID).is_empty()); + assert!(!payloads_for(Cap::DATA_EXFIL).is_empty()); + } + + #[test] + fn phase_11_caps_pair_benign_controls_per_lang() { + let cases: &[(Cap, &[Lang])] = &[ + (Cap::CRYPTO, &[Lang::Java, Lang::Python, Lang::Php, Lang::Go, Lang::Rust]), + (Cap::JSON_PARSE, &[Lang::JavaScript, Lang::Python, Lang::Ruby]), + ( + Cap::UNAUTHORIZED_ID, + &[ + Lang::Python, + Lang::Ruby, + Lang::Java, + Lang::Php, + Lang::JavaScript, + Lang::Go, + Lang::Rust, + ], + ), + ( + Cap::DATA_EXFIL, + &[ + Lang::Python, + Lang::Ruby, + Lang::Java, + Lang::Php, + Lang::JavaScript, + Lang::Go, + Lang::Rust, + ], + ), + ]; + for (cap, langs) in cases { + for lang in *langs { + let slice = payloads_for_lang(*cap, *lang); + assert!( + !slice.is_empty(), + "({cap:?}, {lang:?}) must have payloads", + ); + let vuln = slice + .iter() + .find(|p| !p.is_benign) + .unwrap_or_else(|| panic!("missing vuln for ({cap:?}, {lang:?})")); + let resolved = resolve_benign_control_lang(vuln, *cap, *lang) + .unwrap_or_else(|| panic!("missing benign for ({cap:?}, {lang:?})")); + assert!(resolved.is_benign); + } + } + } + #[test] fn fileio_has_benign_payload() { assert!(benign_payload_for(Cap::FILE_IO).is_some()); @@ -359,6 +466,10 @@ mod tests { Cap::HEADER_INJECTION, Cap::OPEN_REDIRECT, Cap::PROTOTYPE_POLLUTION, + Cap::CRYPTO, + Cap::JSON_PARSE, + Cap::UNAUTHORIZED_ID, + Cap::DATA_EXFIL, ] { let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign); assert!(has_vuln, "{cap:?} must have at least one vuln payload"); @@ -413,6 +524,10 @@ mod tests { Cap::HEADER_INJECTION, Cap::OPEN_REDIRECT, Cap::PROTOTYPE_POLLUTION, + Cap::CRYPTO, + Cap::JSON_PARSE, + Cap::UNAUTHORIZED_ID, + Cap::DATA_EXFIL, ]; for cap in caps { for p in payloads_for(cap) { @@ -442,6 +557,10 @@ mod tests { Cap::HEADER_INJECTION, Cap::OPEN_REDIRECT, Cap::PROTOTYPE_POLLUTION, + Cap::CRYPTO, + Cap::JSON_PARSE, + Cap::UNAUTHORIZED_ID, + Cap::DATA_EXFIL, ]; for cap in caps { for p in payloads_for(cap) { @@ -558,6 +677,10 @@ mod tests { Cap::HEADER_INJECTION, Cap::OPEN_REDIRECT, Cap::PROTOTYPE_POLLUTION, + Cap::CRYPTO, + Cap::JSON_PARSE, + Cap::UNAUTHORIZED_ID, + Cap::DATA_EXFIL, ]; for cap in caps { for p in payloads_for(cap).iter().filter(|p| p.is_benign) { diff --git a/src/dynamic/corpus/unauthorized_id/go.rs b/src/dynamic/corpus/unauthorized_id/go.rs new file mode 100644 index 00000000..ce4a757f --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/go.rs @@ -0,0 +1,41 @@ +//! go `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-go-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/go/vuln.go"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-go-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-go-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/go/benign.go"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/java.rs b/src/dynamic/corpus/unauthorized_id/java.rs new file mode 100644 index 00000000..0e8d03cc --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/java.rs @@ -0,0 +1,41 @@ +//! java `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-java-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/java/vuln.java"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-java-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-java-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/java/benign.java"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/js.rs b/src/dynamic/corpus/unauthorized_id/js.rs new file mode 100644 index 00000000..5774ba3c --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/js.rs @@ -0,0 +1,41 @@ +//! js `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-js-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/js/vuln.js"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-js-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-js-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/js/benign.js"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/mod.rs b/src/dynamic/corpus/unauthorized_id/mod.rs new file mode 100644 index 00000000..84fe6e37 --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/mod.rs @@ -0,0 +1,23 @@ +//! IDOR / unauthorized-id-access (`Cap::UNAUTHORIZED_ID`) +//! per-language payload slices. +//! +//! Phase 11 (Track J.9) carves an IDOR oracle across all seven +//! backend-capable languages. Each harness stands up a mock data +//! store keyed by `owner_id` and a hard-coded `caller_id` +//! (`"alice"`). The vuln payload supplies an `owner_id` that +//! belongs to another user (`"bob"`); the harness's instrumented +//! lookup returns the record without an authorization check and +//! writes a [`crate::dynamic::probe::ProbeKind::IdorAccess { caller_id, +//! owner_id }`] probe. The +//! [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`] +//! predicate fires whenever `caller_id != owner_id`. The paired +//! benign control asks for the caller's own record (`"alice"`), so +//! the probe records matching ids and the predicate stays clear. + +pub mod go; +pub mod java; +pub mod js; +pub mod php; +pub mod python; +pub mod ruby; +pub mod rust; diff --git a/src/dynamic/corpus/unauthorized_id/php.rs b/src/dynamic/corpus/unauthorized_id/php.rs new file mode 100644 index 00000000..7947a5cb --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/php.rs @@ -0,0 +1,41 @@ +//! php `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-php-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/php/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-php-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-php-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/php/benign.php"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/python.rs b/src/dynamic/corpus/unauthorized_id/python.rs new file mode 100644 index 00000000..83cfb2a0 --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/python.rs @@ -0,0 +1,41 @@ +//! Python `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-python-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/python/vuln.py"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-python-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-python-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/python/benign.py"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/ruby.rs b/src/dynamic/corpus/unauthorized_id/ruby.rs new file mode 100644 index 00000000..b7b716ab --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/ruby.rs @@ -0,0 +1,41 @@ +//! ruby `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-ruby-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-ruby-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-ruby-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/unauthorized_id/rust.rs b/src/dynamic/corpus/unauthorized_id/rust.rs new file mode 100644 index 00000000..98f41d7f --- /dev/null +++ b/src/dynamic/corpus/unauthorized_id/rust.rs @@ -0,0 +1,41 @@ +//! rust `Cap::UNAUTHORIZED_ID` payloads. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"bob", + label: "idor-rust-cross-tenant", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::IdorBoundaryCrossed], + benign_control: Some(PayloadRef { + label: "idor-rust-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"alice", + label: "idor-rust-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/unauthorized_id/rust/benign.rs"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/oracle.rs b/src/dynamic/oracle.rs index fe709077..187ef394 100644 --- a/src/dynamic/oracle.rs +++ b/src/dynamic/oracle.rs @@ -315,6 +315,54 @@ pub enum ProbePredicate { /// [`ProbeKind::PrototypePollution::property`]. canary: &'static str, }, + /// Phase 11 (Track J.9): CRYPTO weak-key entropy predicate. + /// + /// Fires when at least one drained probe carries + /// [`ProbeKind::WeakKey`] whose `key_int` is strictly less than + /// `2^max_bits` — i.e. the produced key actually fits inside the + /// search-space budget the predicate models. A benign control + /// generated by a cryptographically strong RNG produces a + /// `key_int` whose magnitude trivially exceeds the budget, so the + /// predicate stays clear. + /// + /// Cross-cutting in the same sense as + /// [`Self::DeserializeGadgetInvoked`] / + /// [`Self::XxeEntityExpanded`] — evaluated across every drained + /// probe rather than against a single record. + WeakKeyEntropy { + /// Maximum effective-entropy budget (in bits) the produced + /// key may consume before the oracle fires. 16 matches the + /// brief's "fits in a 16-bit search space" oracle. + max_bits: u32, + }, + /// Phase 11 (Track J.9): UNAUTHORIZED_ID IDOR boundary predicate. + /// + /// Fires when at least one drained probe carries + /// [`ProbeKind::IdorAccess`] whose `caller_id` differs from + /// `owner_id`. Cross-cutting in the same sense as + /// [`Self::DeserializeGadgetInvoked`] / + /// [`Self::XxeEntityExpanded`] — evaluated across every drained + /// probe rather than against a single record. + IdorBoundaryCrossed, + /// Phase 11 (Track J.9): DATA_EXFIL outbound-host predicate. + /// + /// Fires when at least one drained probe carries + /// [`ProbeKind::OutboundNetwork`] whose `host` falls outside the + /// `allowlist`. Hosts are compared case-insensitively; the + /// canonical allowlist for benign controls is `&["127.0.0.1", + /// "localhost"]` so a vulnerable host that exfiltrates to + /// `attacker.test` resolves off-list and confirms. + /// + /// Cross-cutting in the same sense as + /// [`Self::DeserializeGadgetInvoked`] / + /// [`Self::XxeEntityExpanded`] — evaluated across every drained + /// probe rather than against a single record. + OutboundHostNotIn { + /// Allowlist of permitted egress hosts (e.g. + /// `&["127.0.0.1", "localhost"]`). A probe whose `host` + /// matches any entry is treated as same-origin. + allowlist: &'static [&'static str], + }, /// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count /// predicate shared by LDAP-filter and XPath-expression injection. /// @@ -524,6 +572,35 @@ pub fn oracle_fired_with_stubs( if !canary_ok { return false; } + // Phase 11 (Track J.9): CRYPTO weak-key, UNAUTHORIZED_ID + // IDOR, DATA_EXFIL outbound-host cross-cutting predicates. + let weak_key_ok = cross.iter().all(|p| match p { + ProbePredicate::WeakKeyEntropy { max_bits } => { + probes_satisfy_weak_key(probes, *max_bits) + } + _ => true, + }); + if !weak_key_ok { + return false; + } + let idor_ok = cross.iter().all(|p| match p { + ProbePredicate::IdorBoundaryCrossed => { + probes_satisfy_idor_crossed(probes) + } + _ => true, + }); + if !idor_ok { + return false; + } + let outbound_ok = cross.iter().all(|p| match p { + ProbePredicate::OutboundHostNotIn { allowlist } => { + probes_satisfy_outbound_off_list(probes, allowlist) + } + _ => true, + }); + if !outbound_ok { + return false; + } // Phase 04 (Track J.2): SSTI render-equality cross-cutting // predicates. Each `TemplateEvalEqual { expected }` consults // the captured stdout body — see [`stdout_template_equals`]. @@ -558,7 +635,10 @@ pub fn oracle_fired_with_stubs( | ProbeKind::Xpath { .. } | ProbeKind::HeaderEmit { .. } | ProbeKind::Redirect { .. } - | ProbeKind::PrototypePollution { .. } => false, + | ProbeKind::PrototypePollution { .. } + | ProbeKind::WeakKey { .. } + | ProbeKind::IdorAccess { .. } + | ProbeKind::OutboundNetwork { .. } => false, }), Oracle::OutputContains(needle) => { let nb = needle.as_bytes(); @@ -588,6 +668,9 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool { | ProbePredicate::HeaderInjected { .. } | ProbePredicate::RedirectHostNotIn { .. } | ProbePredicate::PrototypeCanaryTouched { .. } + | ProbePredicate::WeakKeyEntropy { .. } + | ProbePredicate::IdorBoundaryCrossed + | ProbePredicate::OutboundHostNotIn { .. } ) } @@ -624,6 +707,11 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) -> // log* rather than stub events; evaluated separately in // [`probes_satisfy_prototype_canary`] below. ProbePredicate::PrototypeCanaryTouched { .. } => true, + // Phase 11 (Track J.9) cross-cutters are all probe-log + // backed and evaluated by their dedicated helpers below. + ProbePredicate::WeakKeyEntropy { .. } => true, + ProbePredicate::IdorBoundaryCrossed => true, + ProbePredicate::OutboundHostNotIn { .. } => true, _ => true, } } @@ -744,6 +832,60 @@ fn probes_satisfy_prototype_canary(probes: &[SinkProbe], canary: &str) -> bool { }) } +/// True when at least one drained probe is a [`ProbeKind::WeakKey`] +/// record whose `key_int` is strictly less than `2^max_bits`. Powers +/// [`ProbePredicate::WeakKeyEntropy`] (Phase 11 — Track J.9). +/// +/// `max_bits >= 64` is treated as "never fires" — a 64-bit key +/// trivially exceeds any sub-search-space budget once you cap the +/// integer view at `u64`. The brief calls for a 16-bit search-space +/// oracle, so the real threshold sits far below `2^64`. +fn probes_satisfy_weak_key(probes: &[SinkProbe], max_bits: u32) -> bool { + if max_bits == 0 { + return false; + } + if max_bits >= 64 { + return probes + .iter() + .any(|p| matches!(p.kind, ProbeKind::WeakKey { .. })); + } + let budget = 1u64 << max_bits; + probes.iter().any(|p| match &p.kind { + ProbeKind::WeakKey { key_int } => *key_int < budget, + _ => false, + }) +} + +/// True when at least one drained probe is a +/// [`ProbeKind::IdorAccess`] record whose `caller_id` differs from +/// `owner_id`. Powers +/// [`ProbePredicate::IdorBoundaryCrossed`] (Phase 11 — Track J.9). +fn probes_satisfy_idor_crossed(probes: &[SinkProbe]) -> bool { + probes.iter().any(|p| match &p.kind { + ProbeKind::IdorAccess { caller_id, owner_id } => caller_id != owner_id, + _ => false, + }) +} + +/// True when at least one drained probe is a +/// [`ProbeKind::OutboundNetwork`] record whose `host` falls outside +/// `allowlist` (case-insensitive). Powers +/// [`ProbePredicate::OutboundHostNotIn`] (Phase 11 — Track J.9). +fn probes_satisfy_outbound_off_list(probes: &[SinkProbe], allowlist: &[&str]) -> bool { + probes.iter().any(|p| match &p.kind { + ProbeKind::OutboundNetwork { host } => { + let h = host.trim().to_ascii_lowercase(); + if h.is_empty() { + return false; + } + !allowlist + .iter() + .any(|a| h == a.trim().to_ascii_lowercase()) + } + _ => false, + }) +} + /// Returns `true` when `location` redirects to a host that is neither /// `request_host` nor any entry of `allowlist`. Crate-visible so the /// in-crate predicate above and the colocated tests can share one @@ -851,7 +993,10 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool { | ProbePredicate::QueryResultCountGreaterThan { .. } | ProbePredicate::HeaderInjected { .. } | ProbePredicate::RedirectHostNotIn { .. } - | ProbePredicate::PrototypeCanaryTouched { .. } => true, + | ProbePredicate::PrototypeCanaryTouched { .. } + | ProbePredicate::WeakKeyEntropy { .. } + | ProbePredicate::IdorBoundaryCrossed + | ProbePredicate::OutboundHostNotIn { .. } => true, } } @@ -880,7 +1025,10 @@ pub fn probe_crash_signal(probe: &SinkProbe) -> Option { | ProbeKind::Xpath { .. } | ProbeKind::HeaderEmit { .. } | ProbeKind::Redirect { .. } - | ProbeKind::PrototypePollution { .. } => None, + | ProbeKind::PrototypePollution { .. } + | ProbeKind::WeakKey { .. } + | ProbeKind::IdorAccess { .. } + | ProbeKind::OutboundNetwork { .. } => None, } } diff --git a/src/dynamic/probe.rs b/src/dynamic/probe.rs index a974bc53..c41aa938 100644 --- a/src/dynamic/probe.rs +++ b/src/dynamic/probe.rs @@ -260,6 +260,49 @@ pub enum ProbeKind { /// that traversed the chain. value: String, }, + /// Phase 11 (Track J.9) weak-key entropy observation. Stamped by + /// the per-language CRYPTO harness shim when the instrumented + /// key-generation path produces a key whose effective entropy + /// fits inside the search space the oracle pins. `key_int` is + /// the integer-decoded view of the produced key bytes (truncated + /// to a `u64`); the + /// [`crate::dynamic::oracle::ProbePredicate::WeakKeyEntropy`] + /// predicate fires when `key_int < 2^max_bits`. + WeakKey { + /// Truncated integer view of the produced key bytes. Big + /// keys (e.g. an honest 2048-bit RSA modulus) hash down via + /// `from_be_bytes` so a benign control with a strong key + /// trivially exceeds any plausible `max_bits` budget. + key_int: u64, + }, + /// Phase 11 (Track J.9) IDOR / authorization-bypass observation. + /// Stamped by the per-language UNAUTHORIZED_ID harness shim when + /// the instrumented mock data store materialises a record whose + /// `owner_id` differs from the harness's `caller_id`. The + /// [`crate::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`] + /// predicate fires whenever `caller_id != owner_id`. + IdorAccess { + /// Authenticated principal the harness modelled the request + /// as arriving from. Compared case-sensitively against + /// `owner_id`. + caller_id: String, + /// Owner of the record the host produced for the caller. + owner_id: String, + }, + /// Phase 11 (Track J.9) DATA_EXFIL outbound-network observation. + /// Stamped by the per-language harness shim's mock HTTP client + /// when the instrumented egress entry point (`http.post`, + /// `requests.post`, `HttpURLConnection`, `Net::HTTP`, `fetch`, + /// `http.NewRequest`, `reqwest::Client`) attempts to route the + /// captured request body to a non-loopback host. The + /// [`crate::dynamic::oracle::ProbePredicate::OutboundHostNotIn`] + /// predicate fires when the captured host falls outside the + /// configured allowlist (typically `127.0.0.1` / `localhost`). + OutboundNetwork { + /// Host the harness's mock HTTP client recorded. Compared + /// case-insensitively against the allowlist entries. + host: String, + }, } impl Default for ProbeKind { diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 5de4dcc0..8d7d1e98 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -97,6 +97,15 @@ pub struct Attempt { #[derive(Debug)] pub enum RunError { NoPayloadsForCap, + /// Phase 11 (Track J.9): the requested cap is in the structural + /// "no sound oracle" set + /// ([`crate::dynamic::corpus::registry::CORPUS_SOUND_ORACLE_UNAVAILABLE`]). + /// Surfaces as + /// [`crate::evidence::UnsupportedReason::SoundOracleUnavailable`] + /// at the verify boundary so unsupported-budget accounting + /// distinguishes "no oracle exists" from "no payloads carved + /// yet". + SoundOracleUnavailable { cap: crate::labels::Cap, lang: Lang, hint: String }, Harness(HarnessError), Sandbox(SandboxError), BuildFailed { stderr: String, attempts: u32 }, @@ -131,6 +140,22 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result VerifyResult { + finding_id: finding_id.to_owned(), + status: VerifyStatus::Unsupported, + triggered_payload: None, + reason: Some(UnsupportedReason::SoundOracleUnavailable { cap, lang, hint }), + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + differential: None, + replay_stable: None, + wrong: None, + hardening_outcome: None, + }, Err(RunError::Harness(e)) => { // Defence-in-depth residual for `EntryKindUnsupported` from the // lang dispatcher. Promote to `Inconclusive(EntryKindUnsupported)` diff --git a/src/evidence.rs b/src/evidence.rs index 1e079869..02cb1b6c 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -7,6 +7,7 @@ #![allow(clippy::collapsible_if)] use crate::commands::scan::Diag; +use crate::labels::Cap; use crate::patterns::Severity; use crate::symbol::Lang; use serde::{Deserialize, Serialize}; @@ -192,6 +193,27 @@ pub enum UnsupportedReason { RequiredFileRedactedForSecrets(String), /// The language is not yet supported by the dynamic harness emitter. LangUnsupported, + /// Phase 11 (Track J.9): the requested `(cap, lang)` pair has no + /// payloads in the corpus because no sound oracle exists for it + /// (e.g. `Cap::CRYPTO` "weak random" has no externally-observable + /// test vector, `Cap::SHELL_ESCAPE` / `Cap::URL_ENCODE` / + /// `Cap::ENV_VAR` are pure sanitizers / sources and cannot fire a + /// sink). Distinct from + /// [`UnsupportedReason::NoPayloadsForCap`]: that variant means a + /// payload *could* exist but the corpus has not yet carved one, + /// while `SoundOracleUnavailable` is a structural impossibility. + /// Carries the cap, the language the runner was asked to drive, + /// and a human-actionable hint pointing at why no oracle is + /// achievable. + SoundOracleUnavailable { + /// The capability whose sink we cannot soundly observe. + cap: Cap, + /// The language the run targeted (kept for telemetry parity + /// with the other typed reasons that carry a `Lang`). + lang: Lang, + /// One-line explanation of why no oracle exists for this cap. + hint: String, + }, } /// What kind of entry point a harness should call. diff --git a/src/fmt.rs b/src/fmt.rs index ca1cf915..25946ef3 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -579,6 +579,13 @@ fn format_unsupported_reason(r: &crate::evidence::UnsupportedReason) -> String { "file redacted for secrets".to_string() } UnsupportedReason::LangUnsupported => "language not supported".to_string(), + UnsupportedReason::SoundOracleUnavailable { cap, lang, hint } => { + if hint.is_empty() { + format!("sound oracle unavailable ({cap:?}, {lang:?})") + } else { + format!("sound oracle unavailable ({cap:?}, {lang:?}): {hint}") + } + } } } diff --git a/tests/crypto_corpus.rs b/tests/crypto_corpus.rs new file mode 100644 index 00000000..43a1a79a --- /dev/null +++ b/tests/crypto_corpus.rs @@ -0,0 +1,128 @@ +//! Phase 11 (Track J.9) — `Cap::CRYPTO` corpus acceptance. +//! +//! Asserts the new cap end-to-end at the corpus + oracle layer: +//! per-language vuln/benign slices register, lang-aware benign-control +//! resolution pairs them inside the correct slice, and the +//! `WeakKeyEntropy` predicate fires only when a `WeakKey { key_int }` +//! probe whose `key_int` is strictly less than `2^max_bits` lands on +//! the channel. Per-lang harness dispatchers are deferred — see +//! `.pitboss/play/deferred.md`. +//! +//! `cargo nextest run --features dynamic --test crypto_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::corpus::{payloads_for_lang, resolve_benign_control_lang}; +use nyx_scanner::dynamic::oracle::{oracle_fired, Oracle, ProbePredicate}; +use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe}; +use nyx_scanner::dynamic::sandbox::SandboxOutcome; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; +use std::time::Duration; + +const LANGS: &[Lang] = &[ + Lang::Java, + Lang::Python, + Lang::Php, + Lang::Go, + Lang::Rust, +]; + +fn outcome() -> SandboxOutcome { + SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: false, + duration: Duration::from_millis(1), + hardening_outcome: None, + } +} + +fn weak_key_probe(key_int: u64) -> SinkProbe { + SinkProbe { + sink_callee: "__nyx_weak_key".into(), + args: vec![], + captured_at_ns: 1, + payload_id: "crypto-test".into(), + kind: ProbeKind::WeakKey { key_int }, + witness: ProbeWitness::empty(), + } +} + +#[test] +fn corpus_registers_crypto_for_each_supported_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::CRYPTO, *lang); + assert!(!slice.is_empty(), "CRYPTO has no payloads for {lang:?}"); + assert!( + slice.iter().any(|p| !p.is_benign), + "{lang:?} CRYPTO missing vuln payload", + ); + assert!( + slice.iter().any(|p| p.is_benign), + "{lang:?} CRYPTO missing benign control", + ); + } +} + +#[test] +fn crypto_payloads_pair_benign_controls_per_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::CRYPTO, *lang); + let vuln = slice + .iter() + .find(|p| !p.is_benign) + .expect("vuln payload"); + let resolved = resolve_benign_control_lang(vuln, Cap::CRYPTO, *lang) + .expect("benign control resolves"); + assert!(resolved.is_benign); + match &vuln.oracle { + Oracle::SinkProbe { predicates } => { + assert!(predicates.iter().any(|p| matches!( + p, + ProbePredicate::WeakKeyEntropy { max_bits: 16 } + ))); + } + other => panic!("expected SinkProbe, got {other:?}"), + } + } +} + +#[test] +fn weak_key_entropy_fires_below_budget() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: 16 }], + }; + let probes = vec![weak_key_probe(0x1234)]; + assert!(oracle_fired(&oracle, &outcome(), &probes)); +} + +#[test] +fn weak_key_entropy_clears_above_budget() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: 16 }], + }; + let probes = vec![weak_key_probe(u64::MAX / 2)]; + assert!(!oracle_fired(&oracle, &outcome(), &probes)); +} + +#[test] +fn weak_key_entropy_clears_with_no_probe() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::WeakKeyEntropy { max_bits: 16 }], + }; + assert!(!oracle_fired(&oracle, &outcome(), &[])); +} + +#[test] +fn crypto_unsupported_for_other_langs() { + for lang in [Lang::C, Lang::Cpp, Lang::Ruby, Lang::JavaScript, Lang::TypeScript] { + assert!( + payloads_for_lang(Cap::CRYPTO, lang).is_empty(), + "CRYPTO has unexpected payloads for {lang:?}", + ); + } +} diff --git a/tests/data_exfil_corpus.rs b/tests/data_exfil_corpus.rs new file mode 100644 index 00000000..a70d1915 --- /dev/null +++ b/tests/data_exfil_corpus.rs @@ -0,0 +1,111 @@ +//! Phase 11 (Track J.9) — `Cap::DATA_EXFIL` corpus acceptance. +//! +//! Asserts the corpus + outbound-network oracle for all seven +//! backend-capable languages. The vuln payload supplies an +//! attacker-controlled host (`attacker.test`); the +//! [`nyx_scanner::dynamic::oracle::ProbePredicate::OutboundHostNotIn`] +//! predicate fires when the captured `host` falls outside the +//! loopback allowlist (`&["127.0.0.1", "localhost"]`). Per-lang +//! harness dispatchers are deferred — see +//! `.pitboss/play/deferred.md`. +//! +//! `cargo nextest run --features dynamic --test data_exfil_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::corpus::{payloads_for_lang, resolve_benign_control_lang}; +use nyx_scanner::dynamic::oracle::{oracle_fired, Oracle, ProbePredicate}; +use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe}; +use nyx_scanner::dynamic::sandbox::SandboxOutcome; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; +use std::time::Duration; + +const LANGS: &[Lang] = &[ + Lang::Python, + Lang::Ruby, + Lang::Java, + Lang::Php, + Lang::JavaScript, + Lang::Go, + Lang::Rust, +]; + +const ALLOWLIST: &[&str] = &["127.0.0.1", "localhost"]; + +fn outcome() -> SandboxOutcome { + SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: false, + duration: Duration::from_millis(1), + hardening_outcome: None, + } +} + +fn outbound_probe(host: &str) -> SinkProbe { + SinkProbe { + sink_callee: "__nyx_mock_http".into(), + args: vec![], + captured_at_ns: 1, + payload_id: "data-exfil-test".into(), + kind: ProbeKind::OutboundNetwork { host: host.into() }, + witness: ProbeWitness::empty(), + } +} + +#[test] +fn corpus_registers_data_exfil_for_each_supported_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::DATA_EXFIL, *lang); + assert!(!slice.is_empty(), "DATA_EXFIL missing for {lang:?}"); + assert!(slice.iter().any(|p| !p.is_benign)); + assert!(slice.iter().any(|p| p.is_benign)); + } +} + +#[test] +fn data_exfil_payloads_pair_benign_per_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::DATA_EXFIL, *lang); + let vuln = slice.iter().find(|p| !p.is_benign).expect("vuln"); + let resolved = resolve_benign_control_lang(vuln, Cap::DATA_EXFIL, *lang) + .expect("benign control resolves"); + assert!(resolved.is_benign); + match &vuln.oracle { + Oracle::SinkProbe { predicates } => assert!(predicates.iter().any(|p| matches!( + p, + ProbePredicate::OutboundHostNotIn { .. } + ))), + other => panic!("expected SinkProbe, got {other:?}"), + } + } +} + +#[test] +fn outbound_predicate_fires_off_allowlist() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::OutboundHostNotIn { + allowlist: ALLOWLIST, + }], + }; + assert!(oracle_fired( + &oracle, + &outcome(), + &[outbound_probe("attacker.test")] + )); + assert!(!oracle_fired( + &oracle, + &outcome(), + &[outbound_probe("127.0.0.1")] + )); + assert!(!oracle_fired( + &oracle, + &outcome(), + &[outbound_probe("Localhost")] + )); + assert!(!oracle_fired(&oracle, &outcome(), &[])); +} diff --git a/tests/dynamic_fixtures/crypto/go/benign.go b/tests/dynamic_fixtures/crypto/go/benign.go new file mode 100644 index 00000000..c48a0395 --- /dev/null +++ b/tests/dynamic_fixtures/crypto/go/benign.go @@ -0,0 +1,12 @@ +// Phase 11 (Track J.9) — Go CRYPTO benign control fixture. +// +// Uses crypto/rand.Read (a CSPRNG) for key derivation. +package benign + +import "crypto/rand" + +func Run(_ string) []byte { + buf := make([]byte, 32) + _, _ = rand.Read(buf) + return buf +} diff --git a/tests/dynamic_fixtures/crypto/go/vuln.go b/tests/dynamic_fixtures/crypto/go/vuln.go new file mode 100644 index 00000000..8c2f9c35 --- /dev/null +++ b/tests/dynamic_fixtures/crypto/go/vuln.go @@ -0,0 +1,12 @@ +// Phase 11 (Track J.9) — Go CRYPTO vuln fixture. +// +// Uses math/rand.Intn(0x10000) (a non-CSPRNG) to derive a 16-bit +// key. The harness's instrumented key path writes a +// `ProbeKind::WeakKey` probe and the `WeakKeyEntropy` oracle fires. +package vuln + +import "math/rand" + +func Run(_ string) int { + return rand.Intn(0x10000) +} diff --git a/tests/dynamic_fixtures/crypto/java/benign.java b/tests/dynamic_fixtures/crypto/java/benign.java new file mode 100644 index 00000000..63da0eef --- /dev/null +++ b/tests/dynamic_fixtures/crypto/java/benign.java @@ -0,0 +1,14 @@ +// Phase 11 (Track J.9) — Java CRYPTO benign control fixture. +// +// Uses java.security.SecureRandom (a CSPRNG) for key derivation, so +// the produced 256-bit key trivially exceeds the 16-bit weak budget. +import java.security.SecureRandom; + +public class Benign { + public static byte[] run(String _unused) { + SecureRandom r = new SecureRandom(); + byte[] key = new byte[32]; + r.nextBytes(key); + return key; + } +} diff --git a/tests/dynamic_fixtures/crypto/java/vuln.java b/tests/dynamic_fixtures/crypto/java/vuln.java new file mode 100644 index 00000000..b93f8fc9 --- /dev/null +++ b/tests/dynamic_fixtures/crypto/java/vuln.java @@ -0,0 +1,16 @@ +// Phase 11 (Track J.9) — Java CRYPTO vuln fixture. +// +// Uses java.util.Random (a non-CSPRNG) to derive key bytes, producing +// a key bounded inside a 16-bit search space. The harness's +// instrumented key-generation path writes a `ProbeKind::WeakKey` +// probe; the `WeakKeyEntropy` oracle fires for `key_int < 2^16`. +import java.util.Random; + +public class Vuln { + public static byte[] run(String seedTag) { + Random r = new Random(seedTag.hashCode()); + byte[] key = new byte[2]; + r.nextBytes(key); + return key; + } +} diff --git a/tests/dynamic_fixtures/crypto/php/benign.php b/tests/dynamic_fixtures/crypto/php/benign.php new file mode 100644 index 00000000..a3c32e80 --- /dev/null +++ b/tests/dynamic_fixtures/crypto/php/benign.php @@ -0,0 +1,7 @@ + [u8; 32] { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + key +} diff --git a/tests/dynamic_fixtures/crypto/rust/vuln.rs b/tests/dynamic_fixtures/crypto/rust/vuln.rs new file mode 100644 index 00000000..50ed9405 --- /dev/null +++ b/tests/dynamic_fixtures/crypto/rust/vuln.rs @@ -0,0 +1,9 @@ +// Phase 11 (Track J.9) — Rust CRYPTO vuln fixture. +// +// Uses `rand::thread_rng` truncated to 16 bits (a non-CSPRNG +// configuration) to derive a key bounded inside the weak budget. +use rand::Rng; + +pub fn run(_value: &str) -> u16 { + rand::thread_rng().gen_range(0..=0xFFFF) as u16 +} diff --git a/tests/dynamic_fixtures/data_exfil/go/benign.go b/tests/dynamic_fixtures/data_exfil/go/benign.go new file mode 100644 index 00000000..8cec0e9b --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/go/benign.go @@ -0,0 +1,19 @@ +// Phase 11 (Track J.9) — Go DATA_EXFIL benign control fixture. +package benign + +import ( + "net/http" + "net/url" +) + +var allowlist = map[string]struct{}{"127.0.0.1": {}, "localhost": {}} + +func Run(host string) { + if _, ok := allowlist[host]; !ok { + return + } + secret := "alice-creds" + q := url.Values{"token": {secret}} + u := url.URL{Scheme: "http", Host: host, Path: "/exfil", RawQuery: q.Encode()} + _, _ = http.Get(u.String()) +} diff --git a/tests/dynamic_fixtures/data_exfil/go/vuln.go b/tests/dynamic_fixtures/data_exfil/go/vuln.go new file mode 100644 index 00000000..410c7462 --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/go/vuln.go @@ -0,0 +1,14 @@ +// Phase 11 (Track J.9) — Go DATA_EXFIL vuln fixture. +package vuln + +import ( + "net/http" + "net/url" +) + +func Run(host string) { + secret := "alice-creds" + q := url.Values{"token": {secret}} + u := url.URL{Scheme: "http", Host: host, Path: "/exfil", RawQuery: q.Encode()} + _, _ = http.Get(u.String()) +} diff --git a/tests/dynamic_fixtures/data_exfil/java/benign.java b/tests/dynamic_fixtures/data_exfil/java/benign.java new file mode 100644 index 00000000..f9f45452 --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/java/benign.java @@ -0,0 +1,16 @@ +// Phase 11 (Track J.9) — Java DATA_EXFIL benign control fixture. +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Set; + +public class Benign { + private static final Set ALLOWLIST = Set.of("127.0.0.1", "localhost"); + + public static void run(String host) throws Exception { + if (!ALLOWLIST.contains(host)) return; + URL url = new URL("http://" + host + "/exfil?token=alice-creds"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.connect(); + conn.disconnect(); + } +} diff --git a/tests/dynamic_fixtures/data_exfil/java/vuln.java b/tests/dynamic_fixtures/data_exfil/java/vuln.java new file mode 100644 index 00000000..3626b14e --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/java/vuln.java @@ -0,0 +1,13 @@ +// Phase 11 (Track J.9) — Java DATA_EXFIL vuln fixture. +import java.net.HttpURLConnection; +import java.net.URL; + +public class Vuln { + public static void run(String host) throws Exception { + String secret = "alice-creds"; + URL url = new URL("http://" + host + "/exfil?token=" + secret); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.connect(); + conn.disconnect(); + } +} diff --git a/tests/dynamic_fixtures/data_exfil/js/benign.js b/tests/dynamic_fixtures/data_exfil/js/benign.js new file mode 100644 index 00000000..8b3f4ab5 --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/js/benign.js @@ -0,0 +1,17 @@ +// Phase 11 (Track J.9) — JavaScript DATA_EXFIL benign control fixture. +const http = require('http'); + +const ALLOWLIST = new Set(['127.0.0.1', 'localhost']); + +function run(host) { + if (!ALLOWLIST.has(host)) return; + const secret = 'alice-creds'; + const req = http.request({ + host, + path: '/exfil?token=' + encodeURIComponent(secret), + method: 'POST', + }); + req.end(); +} + +module.exports = { run }; diff --git a/tests/dynamic_fixtures/data_exfil/js/vuln.js b/tests/dynamic_fixtures/data_exfil/js/vuln.js new file mode 100644 index 00000000..969e04eb --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/js/vuln.js @@ -0,0 +1,14 @@ +// Phase 11 (Track J.9) — JavaScript DATA_EXFIL vuln fixture. +const http = require('http'); + +function run(host) { + const secret = 'alice-creds'; + const req = http.request({ + host, + path: '/exfil?token=' + encodeURIComponent(secret), + method: 'POST', + }); + req.end(); +} + +module.exports = { run }; diff --git a/tests/dynamic_fixtures/data_exfil/php/benign.php b/tests/dynamic_fixtures/data_exfil/php/benign.php new file mode 100644 index 00000000..2388d747 --- /dev/null +++ b/tests/dynamic_fixtures/data_exfil/php/benign.php @@ -0,0 +1,8 @@ + STORE = new HashMap<>(); + static { + STORE.put("alice", "alice@x"); + STORE.put("bob", "bob@x"); + } + + public static String run(String ownerId) { + if (!CALLER.equals(ownerId)) return null; + return STORE.get(ownerId); + } +} diff --git a/tests/dynamic_fixtures/unauthorized_id/java/vuln.java b/tests/dynamic_fixtures/unauthorized_id/java/vuln.java new file mode 100644 index 00000000..98ea1e68 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/java/vuln.java @@ -0,0 +1,16 @@ +// Phase 11 (Track J.9) — Java UNAUTHORIZED_ID vuln fixture. +import java.util.HashMap; +import java.util.Map; + +public class Vuln { + private static final String CALLER = "alice"; + private static final Map STORE = new HashMap<>(); + static { + STORE.put("alice", "alice@x"); + STORE.put("bob", "bob@x"); + } + + public static String run(String ownerId) { + return STORE.get(ownerId); + } +} diff --git a/tests/dynamic_fixtures/unauthorized_id/js/benign.js b/tests/dynamic_fixtures/unauthorized_id/js/benign.js new file mode 100644 index 00000000..2d2aa848 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/js/benign.js @@ -0,0 +1,10 @@ +// Phase 11 (Track J.9) — JavaScript UNAUTHORIZED_ID benign control fixture. +const CALLER_ID = "alice"; +const STORE = { alice: "alice@x", bob: "bob@x" }; + +function run(ownerId) { + if (ownerId !== CALLER_ID) return null; + return STORE[ownerId]; +} + +module.exports = { run }; diff --git a/tests/dynamic_fixtures/unauthorized_id/js/vuln.js b/tests/dynamic_fixtures/unauthorized_id/js/vuln.js new file mode 100644 index 00000000..079914e7 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/js/vuln.js @@ -0,0 +1,9 @@ +// Phase 11 (Track J.9) — JavaScript UNAUTHORIZED_ID vuln fixture. +const CALLER_ID = "alice"; +const STORE = { alice: "alice@x", bob: "bob@x" }; + +function run(ownerId) { + return STORE[ownerId]; +} + +module.exports = { run }; diff --git a/tests/dynamic_fixtures/unauthorized_id/php/benign.php b/tests/dynamic_fixtures/unauthorized_id/php/benign.php new file mode 100644 index 00000000..4c37ea02 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/php/benign.php @@ -0,0 +1,10 @@ + "alice@x", "bob" => "bob@x"]; + +function run($ownerId) { + global $STORE; + if ($ownerId !== CALLER_ID) return null; + return $STORE[$ownerId] ?? null; +} diff --git a/tests/dynamic_fixtures/unauthorized_id/php/vuln.php b/tests/dynamic_fixtures/unauthorized_id/php/vuln.php new file mode 100644 index 00000000..8d35458d --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/php/vuln.php @@ -0,0 +1,9 @@ + "alice@x", "bob" => "bob@x"]; + +function run($ownerId) { + global $STORE; + return $STORE[$ownerId] ?? null; +} diff --git a/tests/dynamic_fixtures/unauthorized_id/python/benign.py b/tests/dynamic_fixtures/unauthorized_id/python/benign.py new file mode 100644 index 00000000..e018a8a2 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/python/benign.py @@ -0,0 +1,12 @@ +# Phase 11 (Track J.9) — Python UNAUTHORIZED_ID benign control fixture. +# +# Compares `owner_id` against the authenticated caller and returns +# `None` for any boundary-crossing request. +_STORE = {"alice": {"email": "alice@x"}, "bob": {"email": "bob@x"}} +_CALLER_ID = "alice" + + +def run(owner_id): + if owner_id != _CALLER_ID: + return None + return _STORE.get(owner_id) diff --git a/tests/dynamic_fixtures/unauthorized_id/python/vuln.py b/tests/dynamic_fixtures/unauthorized_id/python/vuln.py new file mode 100644 index 00000000..e9eae4e4 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/python/vuln.py @@ -0,0 +1,11 @@ +# Phase 11 (Track J.9) — Python UNAUTHORIZED_ID vuln fixture. +# +# Looks up a record by `owner_id` without checking it against the +# authenticated caller; an attacker who supplies another user's id +# reads that user's record. +_STORE = {"alice": {"email": "alice@x"}, "bob": {"email": "bob@x"}} +_CALLER_ID = "alice" + + +def run(owner_id): + return _STORE.get(owner_id) diff --git a/tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb b/tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb new file mode 100644 index 00000000..cbabfec4 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/ruby/benign.rb @@ -0,0 +1,8 @@ +# Phase 11 (Track J.9) — Ruby UNAUTHORIZED_ID benign control fixture. +STORE = { "alice" => { email: "alice@x" }, "bob" => { email: "bob@x" } }.freeze +CALLER_ID = "alice" + +def run(owner_id) + return nil unless owner_id == CALLER_ID + STORE[owner_id] +end diff --git a/tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb b/tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb new file mode 100644 index 00000000..89929201 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/ruby/vuln.rb @@ -0,0 +1,7 @@ +# Phase 11 (Track J.9) — Ruby UNAUTHORIZED_ID vuln fixture. +STORE = { "alice" => { email: "alice@x" }, "bob" => { email: "bob@x" } }.freeze +CALLER_ID = "alice" + +def run(owner_id) + STORE[owner_id] +end diff --git a/tests/dynamic_fixtures/unauthorized_id/rust/benign.rs b/tests/dynamic_fixtures/unauthorized_id/rust/benign.rs new file mode 100644 index 00000000..032a4055 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/rust/benign.rs @@ -0,0 +1,14 @@ +// Phase 11 (Track J.9) — Rust UNAUTHORIZED_ID benign control fixture. +use std::collections::HashMap; + +const CALLER_ID: &str = "alice"; + +pub fn run(owner_id: &str) -> Option { + if owner_id != CALLER_ID { + return None; + } + let mut store = HashMap::new(); + store.insert("alice".to_string(), "alice@x".to_string()); + store.insert("bob".to_string(), "bob@x".to_string()); + store.get(owner_id).cloned() +} diff --git a/tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs b/tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs new file mode 100644 index 00000000..5cc72272 --- /dev/null +++ b/tests/dynamic_fixtures/unauthorized_id/rust/vuln.rs @@ -0,0 +1,11 @@ +// Phase 11 (Track J.9) — Rust UNAUTHORIZED_ID vuln fixture. +use std::collections::HashMap; + +const CALLER_ID: &str = "alice"; + +pub fn run(owner_id: &str) -> Option { + let mut store = HashMap::new(); + store.insert("alice".to_string(), "alice@x".to_string()); + store.insert("bob".to_string(), "bob@x".to_string()); + store.get(owner_id).cloned() +} diff --git a/tests/dynamic_verify_e2e.rs b/tests/dynamic_verify_e2e.rs index b0712650..19e8a09d 100644 --- a/tests/dynamic_verify_e2e.rs +++ b/tests/dynamic_verify_e2e.rs @@ -131,18 +131,27 @@ mod verify_e2e { assert!(result.attempts.is_empty()); } - /// A finding with an unsupported cap (CRYPTO has no payload corpus) reaches - /// `run_spec`, which returns `RunError::NoPayloadsForCap`, producing - /// `VerifyStatus::Unsupported` with `reason = NoPayloadsForCap`. - /// This is distinct from `BackendUnavailable` and tests the two code paths. + /// A finding whose cap has no sound oracle (Phase 11 / Track J.9 + /// routes `ENV_VAR` / `SHELL_ESCAPE` / `URL_ENCODE` through this + /// path) reaches `run_spec`, which returns + /// `RunError::SoundOracleUnavailable`, producing + /// `VerifyStatus::Unsupported` with + /// `reason = SoundOracleUnavailable { cap, lang, hint }`. Distinct + /// from `BackendUnavailable` and `NoPayloadsForCap`. #[test] - fn verify_finding_with_unsupported_cap_returns_no_payloads() { - let diag = taint_diag_with_cap(Cap::CRYPTO); + fn verify_finding_with_unsupported_cap_returns_sound_oracle_unavailable() { + let diag = taint_diag_with_cap(Cap::ENV_VAR); let opts = VerifyOptions::default(); let result = verify_finding(&diag, &opts); assert_eq!(result.status, VerifyStatus::Unsupported); - assert_eq!(result.reason, Some(UnsupportedReason::NoPayloadsForCap)); + match result.reason { + Some(UnsupportedReason::SoundOracleUnavailable { cap, hint, .. }) => { + assert_eq!(cap, Cap::ENV_VAR); + assert!(!hint.is_empty()); + } + other => panic!("expected SoundOracleUnavailable, got {other:?}"), + } } /// A low-confidence finding is rejected before spec derivation with diff --git a/tests/json_parse_corpus.rs b/tests/json_parse_corpus.rs new file mode 100644 index 00000000..44be649c --- /dev/null +++ b/tests/json_parse_corpus.rs @@ -0,0 +1,106 @@ +//! Phase 11 (Track J.9) — `Cap::JSON_PARSE` corpus acceptance. +//! +//! Asserts the corpus + oracle layer for the pollution oracle that +//! reuses the Phase 10 prototype canary across the three languages +//! whose JSON parsers have a published pollution surface: JavaScript, +//! Python, Ruby. Per-lang harness dispatchers are deferred — see +//! `.pitboss/play/deferred.md`. +//! +//! `cargo nextest run --features dynamic --test json_parse_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::corpus::{payloads_for_lang, resolve_benign_control_lang}; +use nyx_scanner::dynamic::oracle::{oracle_fired, Oracle, ProbePredicate}; +use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe}; +use nyx_scanner::dynamic::sandbox::SandboxOutcome; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; +use std::time::Duration; + +const LANGS: &[Lang] = &[Lang::JavaScript, Lang::Python, Lang::Ruby]; + +fn outcome() -> SandboxOutcome { + SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: false, + duration: Duration::from_millis(1), + hardening_outcome: None, + } +} + +fn canary_probe(property: &str) -> SinkProbe { + SinkProbe { + sink_callee: "__nyx_pp_canary_set".into(), + args: vec![], + captured_at_ns: 1, + payload_id: "json-parse-test".into(), + kind: ProbeKind::PrototypePollution { + property: property.into(), + value: "pwned".into(), + }, + witness: ProbeWitness::empty(), + } +} + +#[test] +fn corpus_registers_json_parse_for_each_supported_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::JSON_PARSE, *lang); + assert!(!slice.is_empty(), "JSON_PARSE missing for {lang:?}"); + assert!(slice.iter().any(|p| !p.is_benign)); + assert!(slice.iter().any(|p| p.is_benign)); + } +} + +#[test] +fn json_parse_pairs_benign_per_lang_via_canary_predicate() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::JSON_PARSE, *lang); + let vuln = slice.iter().find(|p| !p.is_benign).expect("vuln"); + let resolved = resolve_benign_control_lang(vuln, Cap::JSON_PARSE, *lang) + .expect("benign control resolves"); + assert!(resolved.is_benign); + match &vuln.oracle { + Oracle::SinkProbe { predicates } => assert!(predicates.iter().any(|p| matches!( + p, + ProbePredicate::PrototypeCanaryTouched { canary: "__nyx_canary" } + ))), + other => panic!("expected SinkProbe, got {other:?}"), + } + } +} + +#[test] +fn canary_predicate_fires_only_on_canary_property() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::PrototypeCanaryTouched { + canary: "__nyx_canary", + }], + }; + assert!(oracle_fired(&oracle, &outcome(), &[canary_probe("__nyx_canary")])); + assert!(!oracle_fired(&oracle, &outcome(), &[canary_probe("__data__")])); + assert!(!oracle_fired(&oracle, &outcome(), &[])); +} + +#[test] +fn json_parse_unsupported_for_other_langs() { + for lang in [ + Lang::Rust, + Lang::C, + Lang::Cpp, + Lang::Java, + Lang::Go, + Lang::Php, + Lang::TypeScript, + ] { + assert!( + payloads_for_lang(Cap::JSON_PARSE, lang).is_empty(), + "JSON_PARSE has unexpected payloads for {lang:?}", + ); + } +} diff --git a/tests/sound_oracle_unavailable.rs b/tests/sound_oracle_unavailable.rs new file mode 100644 index 00000000..21265e1e --- /dev/null +++ b/tests/sound_oracle_unavailable.rs @@ -0,0 +1,43 @@ +//! Phase 11 (Track J.9) — `UnsupportedReason::SoundOracleUnavailable` +//! routing for caps that have no sound oracle. +//! +//! Asserts that a `HarnessSpec` whose `expected_cap` is in +//! [`nyx_scanner::dynamic::corpus::registry::CORPUS_SOUND_ORACLE_UNAVAILABLE`] +//! produces a `RunError::SoundOracleUnavailable` from `run_spec`, and +//! that the verify layer in turn surfaces +//! `UnsupportedReason::SoundOracleUnavailable { cap, lang, hint }` +//! instead of the legacy `NoPayloadsForCap`. +//! +//! `cargo nextest run --features dynamic --test sound_oracle_unavailable`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::corpus::registry::{ + sound_oracle_unavailable_hint, CORPUS_SOUND_ORACLE_UNAVAILABLE, +}; +use nyx_scanner::labels::Cap; + +#[test] +fn pure_source_and_sanitizer_caps_are_in_the_no_oracle_set() { + let set = CORPUS_SOUND_ORACLE_UNAVAILABLE; + assert!(set & Cap::ENV_VAR.bits() != 0); + assert!(set & Cap::SHELL_ESCAPE.bits() != 0); + assert!(set & Cap::URL_ENCODE.bits() != 0); +} + +#[test] +fn phase_11_caps_left_the_no_oracle_set() { + let set = CORPUS_SOUND_ORACLE_UNAVAILABLE; + assert!(set & Cap::CRYPTO.bits() == 0); + assert!(set & Cap::JSON_PARSE.bits() == 0); + assert!(set & Cap::UNAUTHORIZED_ID.bits() == 0); + assert!(set & Cap::DATA_EXFIL.bits() == 0); +} + +#[test] +fn hint_carries_a_human_actionable_message() { + for cap in [Cap::ENV_VAR, Cap::SHELL_ESCAPE, Cap::URL_ENCODE] { + let hint = sound_oracle_unavailable_hint(cap); + assert!(!hint.is_empty(), "{cap:?} hint should be populated"); + } +} diff --git a/tests/unauthorized_id_corpus.rs b/tests/unauthorized_id_corpus.rs new file mode 100644 index 00000000..440a6edc --- /dev/null +++ b/tests/unauthorized_id_corpus.rs @@ -0,0 +1,104 @@ +//! Phase 11 (Track J.9) — `Cap::UNAUTHORIZED_ID` corpus acceptance. +//! +//! Asserts the corpus + IDOR oracle for all seven backend-capable +//! languages. The vuln payload supplies an `owner_id` belonging to +//! another user; the +//! [`nyx_scanner::dynamic::oracle::ProbePredicate::IdorBoundaryCrossed`] +//! predicate fires when `caller_id != owner_id`. Per-lang harness +//! dispatchers are deferred — see `.pitboss/play/deferred.md`. +//! +//! `cargo nextest run --features dynamic --test unauthorized_id_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::corpus::{payloads_for_lang, resolve_benign_control_lang}; +use nyx_scanner::dynamic::oracle::{oracle_fired, Oracle, ProbePredicate}; +use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe}; +use nyx_scanner::dynamic::sandbox::SandboxOutcome; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; +use std::time::Duration; + +const LANGS: &[Lang] = &[ + Lang::Python, + Lang::Ruby, + Lang::Java, + Lang::Php, + Lang::JavaScript, + Lang::Go, + Lang::Rust, +]; + +fn outcome() -> SandboxOutcome { + SandboxOutcome { + exit_code: Some(0), + stdout: vec![], + stderr: vec![], + timed_out: false, + oob_callback_seen: false, + sink_hit: false, + duration: Duration::from_millis(1), + hardening_outcome: None, + } +} + +fn idor_probe(caller: &str, owner: &str) -> SinkProbe { + SinkProbe { + sink_callee: "__nyx_idor_lookup".into(), + args: vec![], + captured_at_ns: 1, + payload_id: "idor-test".into(), + kind: ProbeKind::IdorAccess { + caller_id: caller.into(), + owner_id: owner.into(), + }, + witness: ProbeWitness::empty(), + } +} + +#[test] +fn corpus_registers_unauthorized_id_for_each_supported_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::UNAUTHORIZED_ID, *lang); + assert!( + !slice.is_empty(), + "UNAUTHORIZED_ID missing for {lang:?}" + ); + assert!(slice.iter().any(|p| !p.is_benign)); + assert!(slice.iter().any(|p| p.is_benign)); + } +} + +#[test] +fn idor_payloads_pair_benign_per_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::UNAUTHORIZED_ID, *lang); + let vuln = slice.iter().find(|p| !p.is_benign).expect("vuln"); + let resolved = + resolve_benign_control_lang(vuln, Cap::UNAUTHORIZED_ID, *lang) + .expect("benign control resolves"); + assert!(resolved.is_benign); + match &vuln.oracle { + Oracle::SinkProbe { predicates } => assert!( + predicates + .iter() + .any(|p| matches!(p, ProbePredicate::IdorBoundaryCrossed)) + ), + other => panic!("expected SinkProbe, got {other:?}"), + } + } +} + +#[test] +fn idor_predicate_fires_on_boundary_crossing() { + let oracle = Oracle::SinkProbe { + predicates: &[ProbePredicate::IdorBoundaryCrossed], + }; + assert!(oracle_fired(&oracle, &outcome(), &[idor_probe("alice", "bob")])); + assert!(!oracle_fired( + &oracle, + &outcome(), + &[idor_probe("alice", "alice")] + )); + assert!(!oracle_fired(&oracle, &outcome(), &[])); +}