nyx/tests/secret_derivation.rs
2026-06-05 10:16:30 -05:00

257 lines
8.1 KiB
Rust

//! 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::{
SECRET_VALUE_PREFIX, build_secret_bag, derive_secret, extract_env_var_references,
};
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}"
);
}