mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
[pitboss] phase 11: Track D.4 + D.5 — Deterministic secrets + NetworkPolicy
This commit is contained in:
parent
50f0729d01
commit
523bd0c53a
8 changed files with 789 additions and 32 deletions
21
tests/dynamic_fixtures/secret_injection/flask_secret/app.py
Normal file
21
tests/dynamic_fixtures/secret_injection/flask_secret/app.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Phase 11 fixture: Flask app that reads FLASK_SECRET at import time via
|
||||
# the bare-index `os.environ["FLASK_SECRET"]` form (the canonical KeyError
|
||||
# trap). The harness must populate the env *before* the module is
|
||||
# imported or app.secret_key resolution raises.
|
||||
#
|
||||
# Phase 11 — Track D.4 acceptance bullet:
|
||||
# "A Flask fixture with `app.secret_key = os.environ["FLASK_SECRET"]`
|
||||
# boots without raising `KeyError`."
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ["FLASK_SECRET"]
|
||||
|
||||
API_TOKEN = os.environ.get("API_TOKEN", "default-token")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return "ok"
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
#[cfg(feature = "dynamic")]
|
||||
mod escape_tests {
|
||||
use nyx_scanner::dynamic::harness::BuiltHarness;
|
||||
use nyx_scanner::dynamic::sandbox::{self, SandboxBackend, SandboxError, SandboxOptions};
|
||||
use nyx_scanner::dynamic::sandbox::{
|
||||
self, NetworkPolicy, SandboxBackend, SandboxError, SandboxOptions,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
|
@ -58,7 +60,7 @@ mod escape_tests {
|
|||
backend: SandboxBackend::Docker,
|
||||
env_passthrough: vec![],
|
||||
output_limit: 65536,
|
||||
oob_listener: None,
|
||||
network_policy: NetworkPolicy::None,
|
||||
probe_channel: None,
|
||||
extra_env: vec![],
|
||||
stub_harness: None,
|
||||
|
|
|
|||
118
tests/network_policy.rs
Normal file
118
tests/network_policy.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! Phase 11 — Track D.5: [`NetworkPolicy`] acceptance.
|
||||
//!
|
||||
//! These tests exercise the public API surface; they do *not* drive a
|
||||
//! real container. The docker backend's per-variant flag emission is
|
||||
//! covered indirectly by `tests/dynamic_sandbox_escape.rs` (which still
|
||||
//! pins `NetworkPolicy::None`), and the Linux iptables filter path is
|
||||
//! covered by `src/dynamic/sandbox.rs` unit tests.
|
||||
//!
|
||||
//! Scope here is structural: each variant exposes the right accessor
|
||||
//! shape, the default is `None`, and [`SandboxOptions::oob_listener`]
|
||||
//! still resolves the legacy callsite without the runner caring which
|
||||
//! variant fed it.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::oob::OobListener;
|
||||
use nyx_scanner::dynamic::sandbox::{HostPort, NetworkPolicy, SandboxOptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn default_policy_is_none() {
|
||||
let opts = SandboxOptions::default();
|
||||
assert!(matches!(opts.network_policy, NetworkPolicy::None));
|
||||
assert!(opts.oob_listener().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_blocks_network() {
|
||||
let p = NetworkPolicy::None;
|
||||
assert!(!p.allows_network());
|
||||
assert!(p.oob_listener().is_none());
|
||||
assert!(p.stub_allow_list().is_none());
|
||||
assert_eq!(p.variant_tag(), "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stubs_only_carries_allowlist() {
|
||||
let p = NetworkPolicy::StubsOnly {
|
||||
allow: vec![
|
||||
HostPort::new("db.local", 5432),
|
||||
HostPort::new("redis.local", 6379),
|
||||
],
|
||||
};
|
||||
assert!(p.allows_network());
|
||||
assert!(p.oob_listener().is_none());
|
||||
let allow = p.stub_allow_list().expect("allow list present");
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert_eq!(allow[0].host, "db.local");
|
||||
assert_eq!(allow[0].port, 5432);
|
||||
assert_eq!(p.variant_tag(), "stubs-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oob_outbound_carries_listener() {
|
||||
// Skip on hosts where loopback bind is impossible (e.g. extremely
|
||||
// locked-down sandboxes). All other CI hosts can bind 127.0.0.1.
|
||||
let Ok(listener) = OobListener::bind() else {
|
||||
eprintln!("OobListener::bind failed — skipping oob_outbound_carries_listener");
|
||||
return;
|
||||
};
|
||||
let listener = Arc::new(listener);
|
||||
let p = NetworkPolicy::OobOutbound { listener: Arc::clone(&listener) };
|
||||
assert!(p.allows_network());
|
||||
let got = p.oob_listener().expect("listener present");
|
||||
assert!(
|
||||
Arc::ptr_eq(got, &listener),
|
||||
"oob_listener() must return the same Arc"
|
||||
);
|
||||
assert!(p.stub_allow_list().is_none());
|
||||
assert_eq!(p.variant_tag(), "oob-outbound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_allows_network_with_no_filter() {
|
||||
let p = NetworkPolicy::Open;
|
||||
assert!(p.allows_network());
|
||||
assert!(p.oob_listener().is_none());
|
||||
assert!(p.stub_allow_list().is_none());
|
||||
assert_eq!(p.variant_tag(), "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_options_oob_listener_accessor_finds_oob_variant() {
|
||||
let Ok(listener) = OobListener::bind() else {
|
||||
eprintln!("OobListener::bind failed — skipping accessor test");
|
||||
return;
|
||||
};
|
||||
let listener = Arc::new(listener);
|
||||
let opts = SandboxOptions {
|
||||
network_policy: NetworkPolicy::OobOutbound {
|
||||
listener: Arc::clone(&listener),
|
||||
},
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
let got = opts.oob_listener().expect("listener present");
|
||||
assert!(Arc::ptr_eq(got, &listener));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_options_oob_listener_accessor_none_for_other_variants() {
|
||||
let opts_none = SandboxOptions {
|
||||
network_policy: NetworkPolicy::None,
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
assert!(opts_none.oob_listener().is_none());
|
||||
|
||||
let opts_open = SandboxOptions {
|
||||
network_policy: NetworkPolicy::Open,
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
assert!(opts_open.oob_listener().is_none());
|
||||
|
||||
let opts_stubs = SandboxOptions {
|
||||
network_policy: NetworkPolicy::StubsOnly { allow: vec![] },
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
assert!(opts_stubs.oob_listener().is_none());
|
||||
}
|
||||
254
tests/secret_derivation.rs
Normal file
254
tests/secret_derivation.rs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
//! Phase 11 — Track D.4: deterministic secret derivation acceptance.
|
||||
//!
|
||||
//! Asserts:
|
||||
//!
|
||||
//! 1. [`derive_secret`] is byte-for-byte deterministic across runs with
|
||||
//! identical (`spec_hash`, `env_var_name`) inputs.
|
||||
//! 2. Distinct env-var names produce distinct values under the same
|
||||
//! spec.
|
||||
//! 3. Distinct spec hashes produce distinct values for the same env-var
|
||||
//! name (no cross-spec aliasing).
|
||||
//! 4. Every value carries the `nyx-stub-` prefix so a leaked harness
|
||||
//! credential is recognisable.
|
||||
//! 5. [`extract_env_var_references`] picks up every supported per-lang
|
||||
//! env access pattern for the languages currently in scope.
|
||||
//! 6. [`build_secret_bag`] returns one entry per literally-referenced
|
||||
//! env var.
|
||||
//! 7. End-to-end: the Phase 11 Flask fixture, when its captured env bag
|
||||
//! is injected as process env vars, boots without raising
|
||||
//! `KeyError: 'FLASK_SECRET'` (skipped on hosts without
|
||||
//! `python3 -c 'import flask'`).
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::environment::{
|
||||
build_secret_bag, derive_secret, extract_env_var_references, SECRET_VALUE_PREFIX,
|
||||
};
|
||||
use nyx_scanner::symbol::Lang;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("dynamic_fixtures")
|
||||
.join("secret_injection")
|
||||
.join("flask_secret")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_secret_is_deterministic() {
|
||||
let a = derive_secret("spec0001abcd1234", "FLASK_SECRET");
|
||||
let b = derive_secret("spec0001abcd1234", "FLASK_SECRET");
|
||||
assert_eq!(a, b, "same inputs must yield same output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_secret_has_stub_prefix() {
|
||||
let v = derive_secret("any-spec-hash", "ANY_VAR");
|
||||
assert!(
|
||||
v.as_str().starts_with(SECRET_VALUE_PREFIX),
|
||||
"missing nyx-stub- prefix: {v}"
|
||||
);
|
||||
// 32 hex chars after the prefix.
|
||||
assert_eq!(v.as_str().len(), SECRET_VALUE_PREFIX.len() + 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_secret_distinguishes_env_var_names() {
|
||||
let a = derive_secret("specA", "FLASK_SECRET");
|
||||
let b = derive_secret("specA", "API_TOKEN");
|
||||
assert_ne!(a, b, "different env var names must produce distinct values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_secret_distinguishes_spec_hashes() {
|
||||
let a = derive_secret("specA", "FLASK_SECRET");
|
||||
let b = derive_secret("specB", "FLASK_SECRET");
|
||||
assert_ne!(a, b, "different spec hashes must produce distinct values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_env_var_references_python_patterns() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("app.py");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
import os
|
||||
SECRET = os.environ["FLASK_SECRET"]
|
||||
DB = os.environ.get("DATABASE_URL")
|
||||
PORT = os.getenv("PORT", "8000")
|
||||
DYNAMIC = os.environ.get(some_dynamic_var) # skipped (non-literal)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let refs = extract_env_var_references(&path, Lang::Python);
|
||||
assert!(refs.contains(&"FLASK_SECRET".to_owned()), "refs = {refs:?}");
|
||||
assert!(refs.contains(&"DATABASE_URL".to_owned()), "refs = {refs:?}");
|
||||
assert!(refs.contains(&"PORT".to_owned()), "refs = {refs:?}");
|
||||
// Dynamic arg must be skipped.
|
||||
assert!(!refs.iter().any(|r| r == "some_dynamic_var"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_env_var_references_js_patterns() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("app.js");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
const a = process.env.NODE_ENV;
|
||||
const b = process.env["DATABASE_URL"];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let refs = extract_env_var_references(&path, Lang::JavaScript);
|
||||
assert!(refs.contains(&"NODE_ENV".to_owned()), "refs = {refs:?}");
|
||||
assert!(refs.contains(&"DATABASE_URL".to_owned()), "refs = {refs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_env_var_references_java_patterns() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("App.java");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
String s = System.getenv("JWT_SECRET");
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let refs = extract_env_var_references(&path, Lang::Java);
|
||||
assert!(refs.contains(&"JWT_SECRET".to_owned()), "refs = {refs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_env_var_references_rust_patterns() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("main.rs");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
fn main() {
|
||||
let s = std::env::var("HOME").unwrap();
|
||||
let t = env::var("PATH").unwrap_or_default();
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let refs = extract_env_var_references(&path, Lang::Rust);
|
||||
assert!(refs.contains(&"HOME".to_owned()), "refs = {refs:?}");
|
||||
assert!(refs.contains(&"PATH".to_owned()), "refs = {refs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_env_var_references_go_patterns() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("main.go");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func main() {
|
||||
s := os.Getenv("HOME")
|
||||
t, _ := os.LookupEnv("PATH")
|
||||
_ = s
|
||||
_ = t
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let refs = extract_env_var_references(&path, Lang::Go);
|
||||
assert!(refs.contains(&"HOME".to_owned()), "refs = {refs:?}");
|
||||
assert!(refs.contains(&"PATH".to_owned()), "refs = {refs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_secret_bag_returns_one_entry_per_var() {
|
||||
let path = fixture_root().join("app.py");
|
||||
let bag = build_secret_bag(&path, Lang::Python, "specphase11test1");
|
||||
|
||||
// FLASK_SECRET (bare index) + API_TOKEN (.get with literal arg).
|
||||
let names: Vec<&str> = bag.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(names.contains(&"FLASK_SECRET"), "bag = {bag:?}");
|
||||
assert!(names.contains(&"API_TOKEN"), "bag = {bag:?}");
|
||||
|
||||
// Every value bears the stub prefix.
|
||||
for (_, v) in &bag {
|
||||
assert!(
|
||||
v.starts_with(SECRET_VALUE_PREFIX),
|
||||
"leaked unprefixed value: {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end acceptance: the Phase 11 Flask fixture boots without
|
||||
/// raising `KeyError: 'FLASK_SECRET'` once the derived secret bag is set
|
||||
/// as process env vars.
|
||||
///
|
||||
/// Skipped on hosts where `python3 -c 'import flask'` fails — the
|
||||
/// dynamic verifier itself is gated on the same precondition (see
|
||||
/// `tests/env_capture_flask.rs`).
|
||||
#[test]
|
||||
fn flask_fixture_boots_with_derived_secret_env() {
|
||||
let has_python3 = std::process::Command::new("python3")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !has_python3 {
|
||||
eprintln!("python3 not on PATH — Phase 11 boot check skipped");
|
||||
return;
|
||||
}
|
||||
let has_flask = std::process::Command::new("python3")
|
||||
.args(["-c", "import flask"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !has_flask {
|
||||
eprintln!("flask not installed on host — Phase 11 boot check skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
let fixture = fixture_root();
|
||||
let app_py = fixture.join("app.py");
|
||||
let bag = build_secret_bag(&app_py, Lang::Python, "phase11specabcd1");
|
||||
assert!(
|
||||
bag.iter().any(|(n, _)| n == "FLASK_SECRET"),
|
||||
"fixture scan missed FLASK_SECRET: bag = {bag:?}"
|
||||
);
|
||||
|
||||
// Spawn python3 in the fixture directory, env-clear, layer the bag
|
||||
// on top, and confirm the module imports without raising.
|
||||
let mut cmd = std::process::Command::new("python3");
|
||||
cmd.args(["-c", "import sys; sys.path.insert(0, '.'); import app; print('OK')"]);
|
||||
cmd.current_dir(&fixture);
|
||||
cmd.env_clear();
|
||||
// PATH is required so python3 can re-locate its stdlib; the
|
||||
// verifier's process backend preserves it via env_passthrough.
|
||||
if let Ok(p) = std::env::var("PATH") {
|
||||
cmd.env("PATH", p);
|
||||
}
|
||||
for (k, v) in &bag {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let out = cmd.output().expect("invoke python3");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"fixture did not boot with derived secret env: stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
assert!(stdout.contains("OK"), "missing OK marker: {stdout}");
|
||||
assert!(
|
||||
!stderr.contains("KeyError"),
|
||||
"Phase 11 acceptance violated — KeyError raised: {stderr}"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue