From 3b660ba1d3650f7e20b3af03fbf755a06696fb0e Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 14 May 2026 03:45:51 -0500 Subject: [PATCH] [pitboss] sweep after phase 03: 3 deferred items resolved --- .config/nextest.toml | 19 ++++++++ src/dynamic/lang/go.rs | 8 ++-- src/dynamic/lang/java.rs | 8 ++-- src/dynamic/lang/javascript.rs | 6 +-- src/dynamic/lang/php.rs | 6 +-- src/dynamic/lang/python.rs | 4 +- src/dynamic/lang/rust.rs | 6 +-- src/dynamic/verify.rs | 32 +++++++------ src/evidence.rs | 5 +++ src/fmt.rs | 1 + tests/spec_derivation_strategies.rs | 69 +++++++++++++++++++++++++++++ 11 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..3e38a6e4 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,19 @@ +# nextest configuration +# +# See https://nexte.st/docs/configuration/ for the full schema. + +# ── Test groups ────────────────────────────────────────────────────────────── +# +# `hostile-input-timing` serialises the two timing-bounded +# `hostile_input_tests` cases that pass under nextest in isolation but fail +# under the full-suite parallel run on darwin (resource contention from the +# other ~4000 tests pushes them past their internal budget). Pinning them to +# a single thread within their own group keeps their wall-clock predictable +# without slowing the rest of the suite. + +[test-groups] +hostile-input-timing = { max-threads = 1 } + +[[profile.default.overrides]] +filter = 'binary(hostile_input_tests) and (test(very_long_single_line_parses) or test(many_small_functions_do_not_explode))' +test-group = 'hostile-input-timing' diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index ffea12ef..be76a6d6 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -20,7 +20,7 @@ //! Payload slot support: //! - `PayloadSlot::Param(0)` — pass payload as `string` first argument. //! - `PayloadSlot::EnvVar(name)` — set env var before calling entry. -//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). @@ -57,7 +57,7 @@ impl LangEmitter for GoEmitter { pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let main_go = generate_main_go(spec); @@ -218,14 +218,14 @@ mod tests { fn emit_param_gt_0_is_unsupported() { let spec = make_spec(PayloadSlot::Param(1)); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] fn emit_stdin_is_unsupported() { let spec = make_spec(PayloadSlot::Stdin); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 1a60aee7..aa00e83c 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -22,7 +22,7 @@ //! Payload slot support: //! - `PayloadSlot::Param(0)` — pass payload as `String` first argument. //! - `PayloadSlot::EnvVar(name)` — set system property before calling entry. -//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1). @@ -59,7 +59,7 @@ impl LangEmitter for JavaEmitter { pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let source = generate_harness_java(spec); @@ -197,14 +197,14 @@ mod tests { fn emit_param_gt_0_is_unsupported() { let spec = make_spec(PayloadSlot::Param(1)); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] fn emit_stdin_is_unsupported() { let spec = make_spec(PayloadSlot::Stdin); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 8f2e0e1c..cea6c7a1 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -14,7 +14,7 @@ //! - `PayloadSlot::Param(n)` — n-th positional argument. //! - `PayloadSlot::EnvVar(name)` — set env var before calling. //! - `PayloadSlot::Stdin` — pipe payload to process.stdin. -//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build: no compilation step. Command is `node harness.js`. //! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1). @@ -53,7 +53,7 @@ impl LangEmitter for JavaScriptEmitter { pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let source = generate_source(spec); @@ -246,7 +246,7 @@ mod tests { fn emit_http_body_is_unsupported() { let spec = make_spec(PayloadSlot::HttpBody); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index d0d22689..26784834 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -13,7 +13,7 @@ //! - `PayloadSlot::Param(n)` — n-th positional argument. //! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling. //! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload. -//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build: no compilation step. Command is `php harness.php`. //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). @@ -51,7 +51,7 @@ impl LangEmitter for PhpEmitter { pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let source = generate_source(spec); @@ -208,7 +208,7 @@ mod tests { fn emit_http_body_is_unsupported() { let spec = make_spec(PayloadSlot::HttpBody); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index cc57faf3..51e23d5b 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -11,7 +11,7 @@ //! Payload slot support: //! - `PayloadSlot::Param(n)` — n-th positional argument. //! - `PayloadSlot::EnvVar(name)` — set env var before calling. -//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. +//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; @@ -47,7 +47,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { // Validate payload slot. match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let source = generate_source(spec); diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index f8d03a2e..537b4bd0 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -16,7 +16,7 @@ //! - `PayloadSlot::Param(0)` — pass payload as `&str` first argument. //! - `PayloadSlot::EnvVar(name)` — set env var before calling entry. //! - All other slots (`Stdin`, `Param(n>0)`, `QueryParam`, `HttpBody`, `Argv`) -//! produce `UnsupportedReason::EntryKindUnsupported`. Stdin piping into the +//! produce `UnsupportedReason::PayloadSlotUnsupported`. Stdin piping into the //! generated harness is not yet wired (deferred). //! //! HTML_ESCAPE is n/a for Rust (§15.4). @@ -55,7 +55,7 @@ impl LangEmitter for RustEmitter { pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} - _ => return Err(UnsupportedReason::EntryKindUnsupported), + _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } let cargo_toml = generate_cargo_toml(spec.expected_cap); @@ -262,7 +262,7 @@ mod tests { fn emit_param_gt_0_is_unsupported() { let spec = make_spec(PayloadSlot::Param(1)); let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::EntryKindUnsupported); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } #[test] diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index ed818a0f..95658619 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -567,24 +567,28 @@ fn build_verdict( toolchain_match: None, }, Err(RunError::Harness(e)) => { - // EntryKindUnsupported coming back from the lang emitter is - // promoted to a structured `Inconclusive(EntryKindUnsupported)` - // verdict so the operator sees the supported list + hint, not a - // bare `Unsupported`. The pre-flight gate in `verify_finding` - // catches the common case (entry_kind decided by spec - // derivation); this arm covers the residual where an emitter - // rejects a payload-slot / shape combination internally. + // Defence-in-depth residual for `EntryKindUnsupported` from the + // lang dispatcher. Promote to `Inconclusive(EntryKindUnsupported)` + // so the operator sees the supported list + hint, but only when + // the spec's entry kind is genuinely outside the supported list — + // otherwise the pre-flight gate already handled it (or a stray + // emitter mis-tagged a payload-slot rejection, which now uses + // `PayloadSlotUnsupported` and falls through to the generic + // `Unsupported(reason)` arm below). if let crate::dynamic::harness::HarnessError::Unsupported( UnsupportedReason::EntryKindUnsupported, ) = &e { - return entry_kind_unsupported_verdict( - finding_id.to_owned(), - None, - &spec.entry_file, - spec.lang, - spec.entry_kind, - ); + let supported = crate::dynamic::lang::entry_kinds_supported(spec.lang); + if !supported.contains(&spec.entry_kind) { + return entry_kind_unsupported_verdict( + finding_id.to_owned(), + None, + &spec.entry_file, + spec.lang, + spec.entry_kind, + ); + } } // Typed `Unsupported(reason)` carries its semantics in `reason`; the // free-form `detail` is reserved for `Inconclusive`/unexpected paths diff --git a/src/evidence.rs b/src/evidence.rs index 36509679..e2887658 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -172,6 +172,11 @@ pub enum UnsupportedReason { /// The entry kind (e.g. `HttpRoute`, `CliSubcommand`) is not yet supported; /// only `EntryKind::Function` is driven in current milestones. EntryKindUnsupported, + /// The lang emitter does not yet support the spec's [`crate::dynamic::spec::PayloadSlot`] + /// shape (e.g. `PayloadSlot::Param(n>0)` on Rust, `PayloadSlot::HttpBody` + /// on JavaScript). Distinct from [`UnsupportedReason::EntryKindUnsupported`]: + /// the entry kind is driveable, only the payload-injection slot is not. + PayloadSlotUnsupported, /// Finding confidence is below `Medium`; dynamic verification is not /// attempted for low-confidence findings to avoid noise. ConfidenceTooLow, diff --git a/src/fmt.rs b/src/fmt.rs index 3d3706b4..60393f50 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -502,6 +502,7 @@ fn format_unsupported_reason(r: &crate::evidence::UnsupportedReason) -> String { match r { UnsupportedReason::BackendUnavailable => "backend unavailable".to_string(), UnsupportedReason::EntryKindUnsupported => "entry kind not supported".to_string(), + UnsupportedReason::PayloadSlotUnsupported => "payload slot not supported".to_string(), UnsupportedReason::ConfidenceTooLow => "confidence too low".to_string(), UnsupportedReason::NoFlowSteps => "no flow steps".to_string(), UnsupportedReason::NoPayloadsForCap => "no payloads for cap".to_string(), diff --git a/tests/spec_derivation_strategies.rs b/tests/spec_derivation_strategies.rs index 9c7eeec2..85961c65 100644 --- a/tests/spec_derivation_strategies.rs +++ b/tests/spec_derivation_strategies.rs @@ -312,4 +312,73 @@ mod spec_strategies { let spec = HarnessSpec::from_finding(&diag).unwrap(); assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps); } + + // ── Phase 03 acceptance: entry-kind gate produces Inconclusive ─────────── + + /// Phase 03 promised that findings whose [`EntryKind`] is not in the + /// emitter's supported list surface as + /// `Inconclusive(EntryKindUnsupported { lang, attempted, supported, hint })` + /// rather than `Unsupported`. End-to-end coverage: + /// - construct an HttpRoute spec via `derive_from_callgraph_entry` + /// (Python emitter currently advertises `[Function]` only); + /// - drive it through `verify_finding`; + /// - assert the verdict shape matches the promise. + #[test] + fn entry_kind_gate_promotes_unsupported_to_inconclusive_with_hint() { + let mut diag = make_diag( + "py.http.flask_route", + "tests/dynamic_fixtures/spec_strategies/callgraph_entry_http.py", + 8, + ); + let mut ev = Evidence::default(); + ev.sink_caps = Cap::SSRF.bits(); + diag.evidence = Some(ev); + + // Sanity: the spec really does carry an HttpRoute entry kind. + let spec = HarnessSpec::from_finding(&diag).unwrap(); + assert!(matches!(spec.entry_kind, EntryKind::HttpRoute)); + + let result = verify_finding(&diag, &VerifyOptions::default()); + assert_eq!( + result.status, + VerifyStatus::Inconclusive, + "entry-kind gate must emit Inconclusive; got {:?}", + result.status + ); + assert!( + result.reason.is_none(), + "Inconclusive verdicts carry inconclusive_reason, not reason; got {:?}", + result.reason + ); + match result.inconclusive_reason { + Some(InconclusiveReason::EntryKindUnsupported { + lang, + attempted, + supported, + hint, + }) => { + assert_eq!(lang, nyx_scanner::symbol::Lang::Python); + assert!(matches!(attempted, EntryKind::HttpRoute)); + assert!( + !supported.is_empty(), + "supported list must be non-empty so operators can triage" + ); + assert!( + supported.contains(&EntryKind::Function), + "Python emitter must advertise Function support; got {supported:?}" + ); + assert!( + !hint.is_empty(), + "hint must guide the operator toward the gap" + ); + assert!( + hint.contains("HttpRoute"), + "hint must name the attempted entry kind; got {hint:?}" + ); + } + other => panic!( + "expected InconclusiveReason::EntryKindUnsupported, got {other:?}" + ), + } + } }