mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-18 20:15:14 +02:00
[pitboss] phase 02: M2 — Python end-to-end excellence with all hardening baked in
This commit is contained in:
parent
894f587b60
commit
0bf39047b9
50 changed files with 4167 additions and 170 deletions
12
tests/dynamic_fixtures/python/cmdi_adversarial.py
Normal file
12
tests/dynamic_fixtures/python/cmdi_adversarial.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Command injection — adversarial collision fixture.
|
||||
|
||||
Prints NYX_PWN_CMDI unconditionally without going through a shell sink.
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected).
|
||||
"""
|
||||
|
||||
|
||||
def process_input(user_input):
|
||||
"""Prints oracle marker without using a command execution sink."""
|
||||
# Coincidental oracle match — not a shell sink.
|
||||
print("NYX_PWN_CMDI")
|
||||
return user_input.upper()
|
||||
22
tests/dynamic_fixtures/python/cmdi_negative.py
Normal file
22
tests/dynamic_fixtures/python/cmdi_negative.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Command injection — negative fixture.
|
||||
|
||||
Safe function: uses subprocess list form (no shell=True), preventing injection.
|
||||
Expected verdict: NotConfirmed.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_ping(host):
|
||||
"""Safe: list-form subprocess, no shell expansion."""
|
||||
# Sanitize: only allow alphanumeric and dots
|
||||
safe_host = "".join(c for c in host if c.isalnum() or c == ".")
|
||||
if not safe_host:
|
||||
print("Invalid host")
|
||||
return
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", safe_host],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
print(result.stdout)
|
||||
19
tests/dynamic_fixtures/python/cmdi_positive.py
Normal file
19
tests/dynamic_fixtures/python/cmdi_positive.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Command injection — positive fixture.
|
||||
|
||||
Vulnerable function: passes user input directly to shell=True subprocess.
|
||||
Expected verdict: Confirmed (payload "; echo NYX_PWN_CMDI" echoes the marker).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_ping(host):
|
||||
"""Vulnerable: user input in shell command."""
|
||||
result = subprocess.run(
|
||||
"ping -c 1 " + host,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
print(result.stdout)
|
||||
print(result.stderr, end="")
|
||||
11
tests/dynamic_fixtures/python/cmdi_unsupported.py
Normal file
11
tests/dynamic_fixtures/python/cmdi_unsupported.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Command injection — unsupported fixture.
|
||||
|
||||
Low-confidence finding that produces Unsupported(ConfidenceTooLow).
|
||||
Expected verdict: Unsupported(ConfidenceTooLow)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
|
||||
def process_request(cmd):
|
||||
"""Vulnerable function used in unsupported-confidence test."""
|
||||
subprocess.run(cmd, shell=True)
|
||||
12
tests/dynamic_fixtures/python/fileio_adversarial.py
Normal file
12
tests/dynamic_fixtures/python/fileio_adversarial.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""File I/O — adversarial collision fixture.
|
||||
|
||||
Prints "root:" unconditionally without reading any file.
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected).
|
||||
"""
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""Prints oracle marker without opening any file."""
|
||||
# Coincidental match — not a file I/O sink.
|
||||
print("root: nobody:*:0:0:System Administrator:/var/root:/bin/sh")
|
||||
return path
|
||||
22
tests/dynamic_fixtures/python/fileio_negative.py
Normal file
22
tests/dynamic_fixtures/python/fileio_negative.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""File I/O — negative fixture.
|
||||
|
||||
Safe function: validates path stays within allowed directory.
|
||||
Expected verdict: NotConfirmed.
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""Safe: resolves and validates path is within /tmp/safe-uploads/."""
|
||||
base_dir = "/tmp/safe-uploads"
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
# Resolve to absolute path and check it stays within base_dir.
|
||||
abs_path = os.path.realpath(os.path.join(base_dir, path))
|
||||
if not abs_path.startswith(base_dir + os.sep) and abs_path != base_dir:
|
||||
print("Access denied: path traversal detected")
|
||||
return
|
||||
try:
|
||||
with open(abs_path) as f:
|
||||
print(f.read())
|
||||
except FileNotFoundError:
|
||||
print("File not found")
|
||||
14
tests/dynamic_fixtures/python/fileio_positive.py
Normal file
14
tests/dynamic_fixtures/python/fileio_positive.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""File I/O — positive fixture.
|
||||
|
||||
Vulnerable function: opens a file at a user-controlled path.
|
||||
Expected verdict: Confirmed (path traversal payload reaches /etc/passwd).
|
||||
"""
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""Vulnerable: reads file at user-controlled path."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
print(f.read())
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Error reading {path}: {e}", end="")
|
||||
10
tests/dynamic_fixtures/python/fileio_unsupported.py
Normal file
10
tests/dynamic_fixtures/python/fileio_unsupported.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""File I/O — unsupported fixture (low confidence).
|
||||
|
||||
Expected verdict: Unsupported(ConfidenceTooLow)
|
||||
"""
|
||||
|
||||
|
||||
def read_config(path):
|
||||
"""Vulnerable function in unsupported-confidence test."""
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
19
tests/dynamic_fixtures/python/sqli_adversarial.py
Normal file
19
tests/dynamic_fixtures/python/sqli_adversarial.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""SQL injection — adversarial collision fixture.
|
||||
|
||||
This function prints "NYX_SQL_CONFIRMED" unconditionally (simulating a
|
||||
coincidental oracle match). The sink is a harmless print statement, not
|
||||
an actual SQL execution.
|
||||
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected)
|
||||
- oracle_fired = True (OutputContains("NYX_SQL_CONFIRMED"))
|
||||
- sink_hit = False (sys.settrace probe never fires on SQL execute line)
|
||||
"""
|
||||
|
||||
|
||||
def get_value(user_input):
|
||||
"""Always prints the oracle marker — no actual SQL involved."""
|
||||
# Coincidental output match — not a real vulnerability.
|
||||
print("NYX_SQL_CONFIRMED")
|
||||
# The above is not a SQL sink; the settrace probe on a real sink line
|
||||
# (different line number or file) will not fire.
|
||||
return user_input
|
||||
18
tests/dynamic_fixtures/python/sqli_negative.py
Normal file
18
tests/dynamic_fixtures/python/sqli_negative.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""SQL injection — negative fixture.
|
||||
|
||||
Safe function: uses parameterized queries.
|
||||
Expected verdict: NotConfirmed (parameterized query prevents injection).
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
|
||||
def login(username):
|
||||
"""Safe login: parameterized query prevents SQL injection."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
conn.execute("INSERT INTO users VALUES (1, 'alice')")
|
||||
# Safe: parameterized query
|
||||
rows = conn.execute("SELECT name FROM users WHERE name=?", (username,)).fetchall()
|
||||
for row in rows:
|
||||
print(row[0])
|
||||
conn.close()
|
||||
27
tests/dynamic_fixtures/python/sqli_positive.py
Normal file
27
tests/dynamic_fixtures/python/sqli_positive.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""SQL injection — positive fixture.
|
||||
|
||||
Vulnerable function: directly concatenates user input into SQL.
|
||||
Expected verdict: Confirmed (SQLI corpus UNION payload causes "NYX_SQL_CONFIRMED"
|
||||
to appear in output when the fixture prints query results).
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
|
||||
def login(username):
|
||||
"""Vulnerable login: direct string concatenation in SQL query."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
conn.execute("INSERT INTO users VALUES (1, 'alice')")
|
||||
conn.execute("INSERT INTO users VALUES (2, 'bob')")
|
||||
# Vulnerable: user input directly concatenated
|
||||
query = "SELECT name FROM users WHERE name='" + username + "'"
|
||||
try:
|
||||
rows = conn.execute(query).fetchall()
|
||||
for row in rows:
|
||||
print(row[0])
|
||||
except sqlite3.OperationalError as e:
|
||||
# Error-based: print query on failure (common in debug mode)
|
||||
print(f"DB query: {query}")
|
||||
print(f"DB error: {e}", end="")
|
||||
finally:
|
||||
conn.close()
|
||||
18
tests/dynamic_fixtures/python/sqli_unsupported.py
Normal file
18
tests/dynamic_fixtures/python/sqli_unsupported.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""SQL injection — unsupported fixture.
|
||||
|
||||
This file contains a vulnerable class method. The test creates a Diag
|
||||
with `confidence = Low`, which makes `from_finding` return
|
||||
`Err(UnsupportedReason::ConfidenceTooLow)`.
|
||||
|
||||
Expected verdict: Unsupported(ConfidenceTooLow)
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Vulnerable class method — entry kind unsupported in current milestone."""
|
||||
|
||||
def find_user(self, name):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
query = "SELECT * FROM users WHERE name='" + name + "'"
|
||||
return conn.execute(query).fetchall()
|
||||
28
tests/dynamic_fixtures/python/sqli_with_secret.py
Normal file
28
tests/dynamic_fixtures/python/sqli_with_secret.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""SQL injection fixture with a secrets file in the project.
|
||||
|
||||
Used for the secret-bearing fixture test: verifies that the AWS key
|
||||
from .env does not appear in cache, telemetry, or repro outcome.json
|
||||
after redaction.
|
||||
|
||||
Expected verdict: result depends on execution; secret must never appear
|
||||
in any output artifact.
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
|
||||
def login(username):
|
||||
"""Vulnerable login — same as sqli_positive but in a project with .env."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
conn.execute("INSERT INTO users VALUES (1, 'alice')")
|
||||
query = "SELECT name FROM users WHERE name='" + username + "'"
|
||||
try:
|
||||
rows = conn.execute(query).fetchall()
|
||||
for row in rows:
|
||||
print(row[0])
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"DB query: {query}")
|
||||
print(f"DB error: {e}", end="")
|
||||
finally:
|
||||
conn.close()
|
||||
11
tests/dynamic_fixtures/python/ssrf_adversarial.py
Normal file
11
tests/dynamic_fixtures/python/ssrf_adversarial.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""SSRF — adversarial collision fixture.
|
||||
|
||||
Prints "daemon:" unconditionally without making any network request.
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected).
|
||||
"""
|
||||
|
||||
|
||||
def fetch_url(url):
|
||||
"""Prints oracle marker without fetching any URL."""
|
||||
print("daemon:*:1:1:System Services:/var/root:/usr/bin/false")
|
||||
return url
|
||||
33
tests/dynamic_fixtures/python/ssrf_negative.py
Normal file
33
tests/dynamic_fixtures/python/ssrf_negative.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""SSRF — negative fixture.
|
||||
|
||||
Safe function: validates URL scheme and host against an allowlist.
|
||||
Expected verdict: NotConfirmed.
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
|
||||
ALLOWED_SCHEMES = {"https"}
|
||||
ALLOWED_HOSTS = {"api.example.com", "data.example.com"}
|
||||
|
||||
|
||||
def fetch_url(url):
|
||||
"""Safe: validates URL before fetching."""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
print("Invalid URL")
|
||||
return
|
||||
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
print(f"Scheme not allowed: {parsed.scheme}")
|
||||
return
|
||||
if parsed.hostname not in ALLOWED_HOSTS:
|
||||
print(f"Host not allowed: {parsed.hostname}")
|
||||
return
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||
print(resp.read().decode("utf-8", errors="replace"))
|
||||
except Exception as e:
|
||||
print(f"Fetch error: {e}", end="")
|
||||
16
tests/dynamic_fixtures/python/ssrf_positive.py
Normal file
16
tests/dynamic_fixtures/python/ssrf_positive.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""SSRF — positive fixture.
|
||||
|
||||
Vulnerable function: fetches a user-controlled URL.
|
||||
Expected verdict: Confirmed (file:// payload reads /etc/passwd → "root:").
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
|
||||
def fetch_url(url):
|
||||
"""Vulnerable: fetches URL provided by user without validation."""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||
content = resp.read().decode("utf-8", errors="replace")
|
||||
print(content)
|
||||
except Exception as e:
|
||||
print(f"Fetch error: {e}", end="")
|
||||
10
tests/dynamic_fixtures/python/ssrf_unsupported.py
Normal file
10
tests/dynamic_fixtures/python/ssrf_unsupported.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""SSRF — unsupported fixture (low confidence).
|
||||
|
||||
Expected verdict: Unsupported(ConfidenceTooLow)
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
|
||||
def fetch(url):
|
||||
"""Vulnerable function in unsupported-confidence test."""
|
||||
return urllib.request.urlopen(url).read()
|
||||
13
tests/dynamic_fixtures/python/xss_adversarial.py
Normal file
13
tests/dynamic_fixtures/python/xss_adversarial.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""XSS — adversarial collision fixture.
|
||||
|
||||
Outputs the XSS marker string unconditionally without it being a real
|
||||
HTML sink (e.g., a test that checks for a string literal).
|
||||
Expected verdict: Inconclusive(OracleCollisionSuspected).
|
||||
"""
|
||||
|
||||
|
||||
def render_comment(user_input):
|
||||
"""Prints oracle marker outside of any HTML rendering context."""
|
||||
# Coincidental match — not an HTML sink.
|
||||
print("<script>NYX_XSS_CONFIRMED</script>")
|
||||
return user_input
|
||||
12
tests/dynamic_fixtures/python/xss_negative.py
Normal file
12
tests/dynamic_fixtures/python/xss_negative.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""XSS — negative fixture.
|
||||
|
||||
Safe function: uses html.escape() before rendering.
|
||||
Expected verdict: NotConfirmed (script tag escaped to <script>).
|
||||
"""
|
||||
import html
|
||||
|
||||
|
||||
def render_comment(user_input):
|
||||
"""Safe: HTML-escapes user input before rendering."""
|
||||
safe = html.escape(user_input)
|
||||
print(f"<div class='comment'>{safe}</div>")
|
||||
11
tests/dynamic_fixtures/python/xss_positive.py
Normal file
11
tests/dynamic_fixtures/python/xss_positive.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""XSS — positive fixture.
|
||||
|
||||
Vulnerable function: echoes user input directly into HTML without escaping.
|
||||
Expected verdict: Confirmed (XSS payload echoed verbatim to output).
|
||||
"""
|
||||
|
||||
|
||||
def render_comment(user_input):
|
||||
"""Vulnerable: no HTML escaping."""
|
||||
html = f"<div class='comment'>{user_input}</div>"
|
||||
print(html)
|
||||
9
tests/dynamic_fixtures/python/xss_unsupported.py
Normal file
9
tests/dynamic_fixtures/python/xss_unsupported.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""XSS — unsupported fixture (low confidence).
|
||||
|
||||
Expected verdict: Unsupported(ConfidenceTooLow)
|
||||
"""
|
||||
|
||||
|
||||
def render(input_text):
|
||||
"""Vulnerable render in unsupported-confidence test."""
|
||||
print(f"<span>{input_text}</span>")
|
||||
5
tests/dynamic_fixtures/secrets/.env
Normal file
5
tests/dynamic_fixtures/secrets/.env
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
AWS_ACCESS_KEY_ID=AKIAFAKETEST00000000
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYFAKEKEY00
|
||||
DATABASE_URL=postgres://user:password123@localhost:5432/mydb
|
||||
API_KEY=sk-proj-fakesecrettoken123456789abcdef0123456789abcdef
|
||||
GITHUB_TOKEN=ghp_fakegithubtoken1234567890abcdefghij
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
//! | `src/main.rs` | binary entry point; wires --features dynamic|
|
||||
//! | `src/lib.rs` | crate root; `#[cfg(feature="dynamic")]` mod|
|
||||
//! | `src/commands/scan.rs` | enrichment loop lives here |
|
||||
//! | `src/commands/mod.rs` | `verify-feedback` subcommand (§21.2) |
|
||||
//! | `src/server/` (any file) | server start_scan verify wiring |
|
||||
|
||||
use std::fs;
|
||||
|
|
@ -25,6 +26,7 @@ const ALLOWED: &[&str] = &[
|
|||
"main.rs",
|
||||
"lib.rs",
|
||||
"commands/scan.rs",
|
||||
"commands/mod.rs",
|
||||
"server/",
|
||||
// The dynamic module itself is obviously allowed.
|
||||
"dynamic/",
|
||||
|
|
|
|||
|
|
@ -86,16 +86,16 @@ mod verify_e2e {
|
|||
}
|
||||
|
||||
/// A finding with a supported cap (SQL_QUERY) and a derivable spec reaches
|
||||
/// `harness::build`, which returns `Unimplemented` in phase M1, producing
|
||||
/// `VerifyStatus::Unsupported` with `reason = BackendUnavailable`.
|
||||
/// `harness::build`. The finding uses a Rust entry file, so the Python-only
|
||||
/// harness emitter returns `LangUnsupported`.
|
||||
#[test]
|
||||
fn verify_finding_with_supported_cap_returns_backend_unavailable() {
|
||||
fn verify_finding_rust_lang_returns_lang_unsupported() {
|
||||
let diag = taint_diag_with_cap(Cap::SQL_QUERY);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&diag, &opts);
|
||||
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::BackendUnavailable));
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::LangUnsupported));
|
||||
assert!(result.triggered_payload.is_none());
|
||||
assert!(result.attempts.is_empty());
|
||||
}
|
||||
|
|
@ -127,11 +127,11 @@ mod verify_e2e {
|
|||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
/// The JSON shape of `VerifyResult` for `BackendUnavailable` matches the
|
||||
/// documented contract: `status`, `reason` present; `triggered_payload`,
|
||||
/// `detail`, `attempts` absent (skipped by serde default).
|
||||
/// The JSON shape of `VerifyResult` for a Rust finding (lang unsupported)
|
||||
/// matches the documented contract: `status`, `reason` present;
|
||||
/// `triggered_payload`, `detail`, `attempts` absent (skipped by serde).
|
||||
#[test]
|
||||
fn verify_result_json_shape_backend_unavailable() {
|
||||
fn verify_result_json_shape_lang_unsupported() {
|
||||
let diag = taint_diag_with_cap(Cap::SQL_QUERY);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&diag, &opts);
|
||||
|
|
@ -140,7 +140,7 @@ mod verify_e2e {
|
|||
let v: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
|
||||
|
||||
assert_eq!(v["status"], "Unsupported");
|
||||
assert_eq!(v["reason"], "BackendUnavailable");
|
||||
assert_eq!(v["reason"], "LangUnsupported");
|
||||
assert!(v.get("triggered_payload").is_none(), "triggered_payload must be absent");
|
||||
assert!(v.get("detail").is_none(), "detail must be absent");
|
||||
assert!(v.get("attempts").is_none(), "attempts must be absent (empty vec skipped)");
|
||||
|
|
|
|||
222
tests/marker_uniqueness.rs
Normal file
222
tests/marker_uniqueness.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Marker uniqueness test (§4.1, §17.4).
|
||||
//!
|
||||
//! Asserts that no `NYX_PWN_*` marker from one cap's corpus is a substring
|
||||
//! of any other cap's payloads, expected sanitizer outputs, or §17.4
|
||||
//! redactor patterns.
|
||||
//!
|
||||
//! This prevents oracle collisions where a SQLi payload accidentally
|
||||
//! triggers the CMDi oracle (or vice versa), producing false `Confirmed`
|
||||
//! verdicts.
|
||||
//!
|
||||
//! Tests are gated on `#[cfg(feature = "dynamic")]` because the corpus
|
||||
//! module lives under the `dynamic` feature.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::corpus::payloads_for;
|
||||
use nyx_scanner::labels::Cap;
|
||||
|
||||
/// All markers extracted from non-benign payload oracles, tagged with the cap
|
||||
/// they came from.
|
||||
fn oracle_markers() -> Vec<(Cap, &'static str, &'static [u8])> {
|
||||
let mut markers = Vec::new();
|
||||
for cap in [
|
||||
Cap::SQL_QUERY,
|
||||
Cap::CODE_EXEC,
|
||||
Cap::FILE_IO,
|
||||
Cap::SSRF,
|
||||
Cap::HTML_ESCAPE,
|
||||
] {
|
||||
for payload in payloads_for(cap).iter().filter(|p| !p.is_benign) {
|
||||
if let nyx_scanner::dynamic::corpus::Oracle::OutputContains(marker) = payload.oracle {
|
||||
markers.push((cap, marker, payload.bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
/// Redactor patterns from §17.4 (the literal strings that trigger redaction).
|
||||
const REDACTOR_PREFIXES: &[&str] = &[
|
||||
"AKIA",
|
||||
"ghp_",
|
||||
"github_pat_",
|
||||
"ghs_",
|
||||
"ghr_",
|
||||
"xoxa-",
|
||||
"xoxb-",
|
||||
"xoxp-",
|
||||
"xoxr-",
|
||||
"sk-",
|
||||
"-----BEGIN",
|
||||
"password=",
|
||||
"api_key=",
|
||||
"api_token=",
|
||||
"secret=",
|
||||
"Bearer ",
|
||||
];
|
||||
|
||||
/// Expected sanitizer outputs (strings that appear after correct sanitization).
|
||||
/// These must NOT appear in any payload oracle marker.
|
||||
const EXPECTED_SANITIZED_OUTPUTS: &[&str] = &[
|
||||
"<script>",
|
||||
">",
|
||||
"<",
|
||||
"&",
|
||||
"'",
|
||||
"%27",
|
||||
"\\u003c",
|
||||
"\\u003e",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn no_marker_is_substring_of_another_caps_payload() {
|
||||
let markers = oracle_markers();
|
||||
|
||||
// For each marker, check it does not appear in another cap's payloads.
|
||||
let caps = [
|
||||
Cap::SQL_QUERY,
|
||||
Cap::CODE_EXEC,
|
||||
Cap::FILE_IO,
|
||||
Cap::SSRF,
|
||||
Cap::HTML_ESCAPE,
|
||||
];
|
||||
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
|
||||
for (src_cap, marker_str, _marker_src_payload) in &markers {
|
||||
let marker_bytes = marker_str.as_bytes();
|
||||
|
||||
for cap in caps {
|
||||
// Within-cap reuse is allowed per §4.1 (cap A's marker may appear
|
||||
// in cap A's own payloads); only cross-cap appearance is a collision.
|
||||
if cap == *src_cap {
|
||||
continue;
|
||||
}
|
||||
for payload in payloads_for(cap).iter().filter(|p| !p.is_benign) {
|
||||
let payload_contains_marker = payload.bytes.windows(marker_bytes.len())
|
||||
.any(|w| w == marker_bytes);
|
||||
|
||||
if payload_contains_marker {
|
||||
violations.push(format!(
|
||||
"marker {:?} (from cap {:?}) appears as substring in payload {:?} (cap {:?})",
|
||||
marker_str,
|
||||
src_cap,
|
||||
payload.label,
|
||||
cap,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Marker uniqueness violation(s):\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_is_substring_of_sanitized_output() {
|
||||
let markers = oracle_markers();
|
||||
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
|
||||
for (_, marker, _) in &markers {
|
||||
for sanitized in EXPECTED_SANITIZED_OUTPUTS {
|
||||
if sanitized.contains(marker) || marker.contains(sanitized) {
|
||||
violations.push(format!(
|
||||
"marker {:?} overlaps with expected sanitized output {:?}",
|
||||
marker, sanitized
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Marker/sanitizer overlap violation(s):\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_is_substring_of_redactor_patterns() {
|
||||
let markers = oracle_markers();
|
||||
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
|
||||
for (_, marker, _) in &markers {
|
||||
for pattern in REDACTOR_PREFIXES {
|
||||
// Check if the redactor pattern is a substring of the marker or vice versa.
|
||||
if marker.contains(pattern) && pattern.len() > 3 {
|
||||
violations.push(format!(
|
||||
"marker {:?} contains redactor pattern {:?}",
|
||||
marker, pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Marker/redactor overlap violation(s):\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_are_unique_across_caps() {
|
||||
// Per §4.1: a marker may be reused within a single cap (e.g. two SQLi
|
||||
// payloads sharing one oracle marker), but must NOT appear in more than
|
||||
// one cap — that would risk one cap's payload accidentally firing
|
||||
// another cap's oracle.
|
||||
let markers = oracle_markers();
|
||||
|
||||
// Cap is bitflags and does not implement Hash; key by bits().
|
||||
let mut seen: std::collections::HashMap<&str, std::collections::HashSet<u32>> =
|
||||
std::collections::HashMap::new();
|
||||
for (cap, marker, _) in &markers {
|
||||
seen.entry(marker).or_default().insert(cap.bits());
|
||||
}
|
||||
|
||||
let cross_cap: Vec<_> = seen
|
||||
.iter()
|
||||
.filter(|(_, caps)| caps.len() > 1)
|
||||
.map(|(m, caps)| (*m, caps.clone()))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
cross_cap.is_empty(),
|
||||
"Oracle marker(s) reused across caps (collision risk): {:?}\n\
|
||||
Each cap must use a marker that does not appear in any other cap.",
|
||||
cross_cap
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_vuln_payloads_have_non_empty_oracle_marker() {
|
||||
for cap in [
|
||||
Cap::SQL_QUERY,
|
||||
Cap::CODE_EXEC,
|
||||
Cap::FILE_IO,
|
||||
Cap::SSRF,
|
||||
Cap::HTML_ESCAPE,
|
||||
] {
|
||||
for payload in payloads_for(cap).iter().filter(|p| !p.is_benign) {
|
||||
if let nyx_scanner::dynamic::corpus::Oracle::OutputContains(marker) = payload.oracle {
|
||||
assert!(
|
||||
!marker.is_empty(),
|
||||
"payload {:?} for {cap:?} has empty OutputContains marker",
|
||||
payload.label
|
||||
);
|
||||
assert!(
|
||||
marker.len() >= 4,
|
||||
"payload {:?} for {cap:?} has very short marker {:?} (< 4 chars) — collision risk",
|
||||
payload.label, marker
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
470
tests/python_fixtures.rs
Normal file
470
tests/python_fixtures.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
//! Python fixture integration tests (§15 Pillar B acceptance gate).
|
||||
//!
|
||||
//! Runs the dynamic verification pipeline against each Python fixture and
|
||||
//! asserts the expected verdict. Requires `--features dynamic` and Python3
|
||||
//! to be available on PATH.
|
||||
//!
|
||||
//! Verdicts under test:
|
||||
//! - positive → Confirmed
|
||||
//! - negative → NotConfirmed
|
||||
//! - unsupported → Unsupported(ConfidenceTooLow) [spec-level rejection]
|
||||
//! - adversarial → Inconclusive(OracleCollisionSuspected)
|
||||
//!
|
||||
//! Tests are skipped when Python3 is not available.
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
mod python_fixture_tests {
|
||||
use nyx_scanner::commands::scan::Diag;
|
||||
use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions};
|
||||
use nyx_scanner::evidence::{
|
||||
Confidence, Evidence, FlowStep, FlowStepKind, InconclusiveReason, UnsupportedReason,
|
||||
VerifyStatus,
|
||||
};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::patterns::{FindingCategory, Severity};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Returns `true` if `python3` is available.
|
||||
fn python3_available() -> bool {
|
||||
std::process::Command::new("python3")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/dynamic_fixtures/python")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
/// Run a fixture and return the verdict.
|
||||
fn run_fixture(fixture: &str, func: &str, cap: Cap, sink_line: u32) -> nyx_scanner::evidence::VerifyResult {
|
||||
let path = fixture_path(fixture);
|
||||
// Copy fixture to a temp dir so the harness can import it.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dst = tmp.path().join(Path::new(fixture).file_name().unwrap());
|
||||
std::fs::copy(&path, &dst).expect("fixture file must exist");
|
||||
|
||||
// Set up repro and telemetry to temp dirs to avoid side effects.
|
||||
unsafe {
|
||||
std::env::set_var("NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap());
|
||||
std::env::set_var("NYX_TELEMETRY_PATH", tmp.path().join("events.jsonl").to_str().unwrap());
|
||||
}
|
||||
|
||||
// Use the temp dir copy as the fixture path.
|
||||
let diag = make_diag(&dst, func, cap, sink_line);
|
||||
|
||||
// Change CWD to the temp dir so the harness can find the module.
|
||||
let original_dir = std::env::current_dir().ok();
|
||||
let _ = std::env::set_current_dir(tmp.path());
|
||||
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&diag, &opts);
|
||||
|
||||
if let Some(dir) = original_dir {
|
||||
let _ = std::env::set_current_dir(dir);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("NYX_REPRO_BASE");
|
||||
std::env::remove_var("NYX_TELEMETRY_PATH");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ── SQLi fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sqli_positive_is_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("sqli_positive.py", "login", Cap::SQL_QUERY, 17);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Confirmed,
|
||||
"sqli_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||
result.status, result.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqli_negative_is_not_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("sqli_negative.py", "login", Cap::SQL_QUERY, 12);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::NotConfirmed,
|
||||
"sqli_negative must be NotConfirmed; got {:?}",
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqli_unsupported_is_unsupported() {
|
||||
// Low-confidence Diag → Unsupported(ConfidenceTooLow) without execution.
|
||||
let path = fixture_path("sqli_unsupported.py");
|
||||
let mut d = make_diag(&path, "find_user", Cap::SQL_QUERY, 10);
|
||||
d.confidence = Some(Confidence::Low);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&d, &opts);
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqli_adversarial_is_inconclusive_collision() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
// The adversarial fixture prints the oracle marker WITHOUT going through
|
||||
// any SQL sink — so the oracle fires but the probe at the (nonexistent)
|
||||
// SQL execute line does not.
|
||||
// We point the sink line at a line that doesn't exist in the file (999)
|
||||
// so the settrace probe can't fire.
|
||||
let result = run_fixture("sqli_adversarial.py", "get_value", Cap::SQL_QUERY, 999);
|
||||
// Oracle fires (prints "NYX_SQL_CONFIRMED") but probe doesn't (line 999 missing).
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Inconclusive,
|
||||
"sqli_adversarial must be Inconclusive; got {:?}",
|
||||
result.status
|
||||
);
|
||||
assert_eq!(
|
||||
result.inconclusive_reason,
|
||||
Some(InconclusiveReason::OracleCollisionSuspected),
|
||||
"adversarial must be OracleCollisionSuspected"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Command injection fixtures ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cmdi_positive_is_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("cmdi_positive.py", "run_ping", Cap::CODE_EXEC, 13);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Confirmed,
|
||||
"cmdi_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||
result.status, result.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmdi_negative_is_not_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("cmdi_negative.py", "run_ping", Cap::CODE_EXEC, 17);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::NotConfirmed,
|
||||
"cmdi_negative must be NotConfirmed; got {:?}",
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmdi_unsupported_is_unsupported() {
|
||||
let path = fixture_path("cmdi_unsupported.py");
|
||||
let mut d = make_diag(&path, "process_request", Cap::CODE_EXEC, 9);
|
||||
d.confidence = Some(Confidence::Low);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&d, &opts);
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmdi_adversarial_is_inconclusive_collision() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("cmdi_adversarial.py", "process_input", Cap::CODE_EXEC, 999);
|
||||
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||
assert_eq!(
|
||||
result.inconclusive_reason,
|
||||
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||
);
|
||||
}
|
||||
|
||||
// ── File I/O fixtures ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fileio_positive_is_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("fileio_positive.py", "read_file", Cap::FILE_IO, 11);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Confirmed,
|
||||
"fileio_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||
result.status, result.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileio_negative_is_not_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("fileio_negative.py", "read_file", Cap::FILE_IO, 18);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::NotConfirmed,
|
||||
"fileio_negative must be NotConfirmed; got {:?}",
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileio_unsupported_is_unsupported() {
|
||||
let path = fixture_path("fileio_unsupported.py");
|
||||
let mut d = make_diag(&path, "read_config", Cap::FILE_IO, 7);
|
||||
d.confidence = Some(Confidence::Low);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&d, &opts);
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileio_adversarial_is_inconclusive_collision() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("fileio_adversarial.py", "read_file", Cap::FILE_IO, 999);
|
||||
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||
assert_eq!(
|
||||
result.inconclusive_reason,
|
||||
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||
);
|
||||
}
|
||||
|
||||
// ── SSRF fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ssrf_positive_is_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("ssrf_positive.py", "fetch_url", Cap::SSRF, 11);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Confirmed,
|
||||
"ssrf_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||
result.status, result.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_negative_is_not_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("ssrf_negative.py", "fetch_url", Cap::SSRF, 26);
|
||||
// Blocked by host validation — oracle won't fire.
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::NotConfirmed,
|
||||
"ssrf_negative must be NotConfirmed; got {:?}",
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_unsupported_is_unsupported() {
|
||||
let path = fixture_path("ssrf_unsupported.py");
|
||||
let mut d = make_diag(&path, "fetch", Cap::SSRF, 9);
|
||||
d.confidence = Some(Confidence::Low);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&d, &opts);
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_adversarial_is_inconclusive_collision() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("ssrf_adversarial.py", "fetch_url", Cap::SSRF, 999);
|
||||
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||
assert_eq!(
|
||||
result.inconclusive_reason,
|
||||
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||
);
|
||||
}
|
||||
|
||||
// ── XSS fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn xss_positive_is_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("xss_positive.py", "render_comment", Cap::HTML_ESCAPE, 9);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::Confirmed,
|
||||
"xss_positive must be Confirmed; got {:?} (detail: {:?})",
|
||||
result.status, result.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xss_negative_is_not_confirmed() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("xss_negative.py", "render_comment", Cap::HTML_ESCAPE, 11);
|
||||
assert_eq!(
|
||||
result.status, VerifyStatus::NotConfirmed,
|
||||
"xss_negative must be NotConfirmed; got {:?}",
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xss_unsupported_is_unsupported() {
|
||||
let path = fixture_path("xss_unsupported.py");
|
||||
let mut d = make_diag(&path, "render", Cap::HTML_ESCAPE, 7);
|
||||
d.confidence = Some(Confidence::Low);
|
||||
let opts = VerifyOptions::default();
|
||||
let result = verify_finding(&d, &opts);
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::ConfidenceTooLow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xss_adversarial_is_inconclusive_collision() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
let result = run_fixture("xss_adversarial.py", "render_comment", Cap::HTML_ESCAPE, 999);
|
||||
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||
assert_eq!(
|
||||
result.inconclusive_reason,
|
||||
Some(InconclusiveReason::OracleCollisionSuspected)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Secrets fixture ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn secret_not_in_telemetry_after_verify() {
|
||||
if !python3_available() {
|
||||
eprintln!("SKIP: python3 not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let telemetry_path = tmp.path().join("events.jsonl");
|
||||
unsafe {
|
||||
std::env::set_var("NYX_REPRO_BASE", tmp.path().join("repro").to_str().unwrap());
|
||||
std::env::set_var("NYX_TELEMETRY_PATH", telemetry_path.to_str().unwrap());
|
||||
}
|
||||
|
||||
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/dynamic_fixtures/python/sqli_positive.py");
|
||||
let tmp_fix = tmp.path().join("sqli_positive.py");
|
||||
let _ = std::fs::copy(&fixture, &tmp_fix);
|
||||
|
||||
let original_dir = std::env::current_dir().ok();
|
||||
let _ = std::env::set_current_dir(tmp.path());
|
||||
|
||||
let diag = make_diag(&tmp_fix, "login", Cap::SQL_QUERY, 17);
|
||||
let opts = VerifyOptions::default();
|
||||
let _ = verify_finding(&diag, &opts);
|
||||
|
||||
if let Some(dir) = original_dir {
|
||||
let _ = std::env::set_current_dir(dir);
|
||||
}
|
||||
|
||||
// Check telemetry doesn't contain any secret patterns.
|
||||
if telemetry_path.exists() {
|
||||
let content = std::fs::read_to_string(&telemetry_path).unwrap_or_default();
|
||||
// Telemetry must not contain the fake AWS key.
|
||||
assert!(
|
||||
!content.contains("AKIAFAKETEST00000000"),
|
||||
"telemetry must not contain fake AWS key; got: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("NYX_REPRO_BASE");
|
||||
std::env::remove_var("NYX_TELEMETRY_PATH");
|
||||
}
|
||||
}
|
||||
|
||||
fn make_diag(path: &Path, func: &str, cap: Cap, sink_line: u32) -> Diag {
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let evidence = Evidence {
|
||||
flow_steps: vec![
|
||||
FlowStep {
|
||||
step: 1,
|
||||
kind: FlowStepKind::Source,
|
||||
file: path_str.clone(),
|
||||
line: 1,
|
||||
col: 0,
|
||||
snippet: None,
|
||||
variable: Some("payload".into()),
|
||||
callee: None,
|
||||
function: Some(func.to_owned()),
|
||||
is_cross_file: false,
|
||||
},
|
||||
FlowStep {
|
||||
step: 2,
|
||||
kind: FlowStepKind::Sink,
|
||||
file: path_str.clone(),
|
||||
line: sink_line,
|
||||
col: 4,
|
||||
snippet: None,
|
||||
variable: None,
|
||||
callee: None,
|
||||
function: None,
|
||||
is_cross_file: false,
|
||||
},
|
||||
],
|
||||
sink_caps: cap.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
Diag {
|
||||
path: path_str,
|
||||
line: sink_line as usize,
|
||||
col: 0,
|
||||
severity: Severity::High,
|
||||
id: "taint-unsanitised-flow".into(),
|
||||
category: FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(evidence),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: vec![],
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
175
tests/repro_determinism.rs
Normal file
175
tests/repro_determinism.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! Repro determinism test (§18.2).
|
||||
//!
|
||||
//! For every `Confirmed` fixture: the repro artifact `expected/outcome.json`
|
||||
//! produced during verification must be byte-identical when regenerated from
|
||||
//! the repro bundle.
|
||||
//!
|
||||
//! Tests are gated on `#[cfg(feature = "dynamic")]` and Python availability.
|
||||
//! They are also skipped if no `Confirmed` fixtures have been produced yet
|
||||
//! (trivially passes — zero assertions).
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
mod repro_determinism_tests {
|
||||
use nyx_scanner::dynamic::repro;
|
||||
use nyx_scanner::dynamic::sandbox::{SandboxOptions, SandboxOutcome};
|
||||
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::evidence::{AttemptSummary, VerifyResult, VerifyStatus};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_confirmed_spec(spec_hash: &str) -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "determinism00001".into(),
|
||||
entry_file: "app.py".into(),
|
||||
entry_name: "login".into(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang: Lang::Python,
|
||||
toolchain_id: "python-3".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::SQL_QUERY,
|
||||
constraint_hints: vec![],
|
||||
sink_file: "app.py".into(),
|
||||
sink_line: 10,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_confirmed_outcome() -> SandboxOutcome {
|
||||
SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: b"NYX_SQL_CONFIRMED\nsome extra output".to_vec(),
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(150),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_confirmed_verdict(finding_id: &str) -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::Confirmed,
|
||||
triggered_payload: Some("sqli-union-nyx".into()),
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![AttemptSummary {
|
||||
payload_label: "sqli-union-nyx".into(),
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
triggered: true,
|
||||
sink_hit: true,
|
||||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a repro bundle and verify it round-trips correctly.
|
||||
#[test]
|
||||
fn confirmed_repro_is_deterministic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Override repro base to temp dir.
|
||||
unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) };
|
||||
|
||||
let spec = make_confirmed_spec("determ0000000001");
|
||||
let opts = SandboxOptions::default();
|
||||
let outcome = make_confirmed_outcome();
|
||||
let verdict = make_confirmed_verdict("determinism00001");
|
||||
|
||||
// Write repro bundle (first time).
|
||||
let artifact1 = repro::write(
|
||||
&spec, &opts, &outcome, &verdict,
|
||||
"# harness source v1\n",
|
||||
"def login(x): pass\n",
|
||||
b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||
"sqli-union-nyx",
|
||||
None,
|
||||
).expect("first repro write must succeed");
|
||||
|
||||
let outcome_json_1 =
|
||||
std::fs::read_to_string(artifact1.root.join("expected/outcome.json"))
|
||||
.expect("outcome.json must exist after first write");
|
||||
|
||||
// Write repro bundle (second time, same inputs).
|
||||
// Remove existing dir first (simulate fresh run).
|
||||
std::fs::remove_dir_all(&artifact1.root).unwrap();
|
||||
|
||||
let artifact2 = repro::write(
|
||||
&spec, &opts, &outcome, &verdict,
|
||||
"# harness source v1\n",
|
||||
"def login(x): pass\n",
|
||||
b"' UNION SELECT 'NYX_SQL_CONFIRMED'--",
|
||||
"sqli-union-nyx",
|
||||
None,
|
||||
).expect("second repro write must succeed");
|
||||
|
||||
let outcome_json_2 =
|
||||
std::fs::read_to_string(artifact2.root.join("expected/outcome.json"))
|
||||
.expect("outcome.json must exist after second write");
|
||||
|
||||
assert_eq!(
|
||||
outcome_json_1, outcome_json_2,
|
||||
"outcome.json must be byte-identical across two runs with the same inputs"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||
}
|
||||
|
||||
/// Verify that redacted outcome.json does not contain the secret.
|
||||
#[test]
|
||||
fn outcome_json_secrets_are_redacted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) };
|
||||
|
||||
let spec = make_confirmed_spec("determ0000000002");
|
||||
let opts = SandboxOptions::default();
|
||||
let mut outcome = make_confirmed_outcome();
|
||||
// Inject a fake AWS key into stdout.
|
||||
outcome.stdout = b"AKIAFAKETEST00000000 result ok NYX_SQL_CONFIRMED".to_vec();
|
||||
let verdict = make_confirmed_verdict("determinism00002");
|
||||
|
||||
let artifact = repro::write(
|
||||
&spec, &opts, &outcome, &verdict,
|
||||
"# harness", "# entry", b"payload", "label", None,
|
||||
).expect("repro write must succeed");
|
||||
|
||||
let outcome_json =
|
||||
std::fs::read_to_string(artifact.root.join("expected/outcome.json")).unwrap();
|
||||
|
||||
assert!(
|
||||
!outcome_json.contains("AKIAFAKETEST00000000"),
|
||||
"AWS key must be redacted from outcome.json; got: {outcome_json}"
|
||||
);
|
||||
|
||||
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||
}
|
||||
|
||||
/// Verify verdict.json is correctly structured.
|
||||
#[test]
|
||||
fn verdict_json_is_valid() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) };
|
||||
|
||||
let spec = make_confirmed_spec("determ0000000003");
|
||||
let opts = SandboxOptions::default();
|
||||
let outcome = make_confirmed_outcome();
|
||||
let verdict = make_confirmed_verdict("determinism00003");
|
||||
|
||||
let artifact = repro::write(
|
||||
&spec, &opts, &outcome, &verdict,
|
||||
"# harness", "# entry", b"payload", "label", None,
|
||||
).expect("repro write must succeed");
|
||||
|
||||
let verdict_json =
|
||||
std::fs::read_to_string(artifact.root.join("expected/verdict.json")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&verdict_json).unwrap();
|
||||
|
||||
assert_eq!(parsed["status"], "Confirmed");
|
||||
assert_eq!(parsed["finding_id"], "determinism00003");
|
||||
|
||||
unsafe { std::env::remove_var("NYX_REPRO_BASE") };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue