From 0d4ab22c4ccf0e5d2b8af728960b7aafef32edd8 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 13:17:28 -0500 Subject: [PATCH] [pitboss/grind] cleanup session-0004 (20260522T163126Z-7d60) --- docs/dynamic.md | 9 ++-- src/cli.rs | 6 +++ src/main.rs | 22 ++++++---- src/summary/mod.rs | 1 + src/summary/tests.rs | 30 +++++-------- src/taint/mod.rs | 16 ++----- tests/cli_validation_tests.rs | 80 +++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 45 deletions(-) diff --git a/docs/dynamic.md b/docs/dynamic.md index 21fc34fa..dea6174d 100644 --- a/docs/dynamic.md +++ b/docs/dynamic.md @@ -71,9 +71,10 @@ nyx scan --unsafe-sandbox # alias for --backend process ``` Docker is the preferred backend. It mounts only the entry file's directory and -blocks outbound network by default. If out-of-band detection is enabled with -`oob_listener`, Docker uses bridge networking with a host-gateway route so the -harness can reach the listener. +blocks outbound network by default. Nyx binds a loopback OOB listener at scan +start for callback-style payloads (SSRF, blind SSTI). When the bind succeeds, +Docker switches to bridge networking with a host-gateway route so the harness +can reach the listener; OOB payloads are skipped if the bind fails. The process backend is useful for development and machines without Docker. It does not provide the same isolation. @@ -141,7 +142,7 @@ The literal `nyx_version` and `corpus_version` values shift between releases; se | `schema_version` | Event schema version. Readers reject mismatches. | | `nyx_version` | Version of the Nyx binary that wrote the event. | | `corpus_version` | Payload corpus version used for the verdict. | -| `kind` | `verdict`, `rank_delta`, or `feedback`. | +| `kind` | `verdict` or `rank_delta`. Feedback rows use an `event: "verify_feedback"` field instead and may pre-date the schema envelope. | | `ts` | Write time in RFC 3339 format. | | `finding_id` | Stable finding identifier. | | `spec_hash` | Hash of the harness spec. | diff --git a/src/cli.rs b/src/cli.rs index e4b332ac..24bddae7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,12 @@ impl Commands { && (fmt == OutputFormat::Json || fmt == OutputFormat::Sarif) } + /// Whether the user explicitly asked this invocation to suppress + /// human-readable output. + pub fn quiet_requested(&self) -> bool { + matches!(self, Commands::Scan { quiet: true, .. }) + } + /// Whether this is a long-running server command (skip timing output). pub fn is_serve(&self) -> bool { matches!(self, Commands::Serve { .. }) diff --git a/src/main.rs b/src/main.rs index fbd21ef6..ee3d962e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,10 +14,16 @@ use tracing_subscriber::{EnvFilter, Registry, fmt as tracing_fmt}; // use tracing_appender::rolling::{RollingFileAppender, Rotation}; // use tracing_appender::non_blocking; -fn init_tracing() { +fn init_tracing(quiet: bool) { // let file_appender = RollingFileAppender::new(Rotation::HOURLY, "logs", "nyx-scanner.log"); // let (file_writer, guard) = non_blocking(file_appender); + let filter = if quiet { + EnvFilter::new("off") + } else { + EnvFilter::from_default_env() + }; + let fmt_layer = tracing_fmt::layer() .pretty() .with_writer(std::io::stderr) @@ -29,17 +35,11 @@ fn init_tracing() { // .without_time() // .json(); - Registry::default() - .with(EnvFilter::from_default_env()) - .with(fmt_layer) - .init(); + Registry::default().with(filter).with(fmt_layer).init(); } fn main() -> NyxResult<()> { let now = Instant::now(); - init_tracing(); - - tracing::debug!("CLI starting up"); if std::env::args().count() == 1 { eprint!("{}", fmt::render_welcome()); @@ -60,6 +60,10 @@ fn main() -> NyxResult<()> { let (mut config, config_note) = Config::load(config_dir)?; + let explicit_quiet = config.output.quiet || cli.command.quiet_requested(); + init_tracing(explicit_quiet); + tracing::debug!("CLI starting up"); + rayon::ThreadPoolBuilder::new() .stack_size(config.performance.rayon_thread_stack_size) .build_global() @@ -67,7 +71,7 @@ fn main() -> NyxResult<()> { let is_serve = cli.command.is_serve(); let is_info = cli.command.is_informational(); - let quiet = config.output.quiet || cli.command.is_structured_output(&config); + let quiet = explicit_quiet || cli.command.is_structured_output(&config); // Print config note before scanning (human-readable mode only). Pure // informational commands suppress it too, their output is usually diff --git a/src/summary/mod.rs b/src/summary/mod.rs index c4f008f3..b919953c 100644 --- a/src/summary/mod.rs +++ b/src/summary/mod.rs @@ -788,6 +788,7 @@ impl GlobalSummaries { .wrapping_mul(0x9E37_79B9) .wrapping_add(probe); key.disambig = Some(SYNTHETIC_DISAMBIG_BIT | (synth & !SYNTHETIC_DISAMBIG_BIT)); + key.arity = Some(body.param_count); probe = probe.wrapping_add(1); if probe >= 1024 { tracing::warn!( diff --git a/src/summary/tests.rs b/src/summary/tests.rs index e4e943f9..4df6fafa 100644 --- a/src/summary/tests.rs +++ b/src/summary/tests.rs @@ -3272,27 +3272,17 @@ fn insert_body_param_count_mismatch_rekeys() { assert_eq!(head.param_count, 2); // Invariant 2: the conflicting body is preserved under a synthetic - // disambig, not dropped. Reconstruct the expected synth disambig - // using the same formula as `reconcile_body_key`. - let mut found_conflicting = false; + // disambig at its own arity, not dropped. let base = (4u32).wrapping_mul(0x9E37_79B9); - for probe in 0u32..1024 { - let synth = base.wrapping_add(probe); - let synth_key = FuncKey { - disambig: Some(0x8000_0000 | (synth & 0x7FFF_FFFF)), - ..key.clone() - }; - if let Some(body) = gs.get_body(&synth_key) - && body.param_count == 4 - { - found_conflicting = true; - break; - } - } - assert!( - found_conflicting, - "the 4-param body must be preserved under a synthetic disambig key" - ); + let synth_key = FuncKey { + arity: Some(4), + disambig: Some(0x8000_0000 | (base & 0x7FFF_FFFF)), + ..key.clone() + }; + let conflicting = gs + .get_body(&synth_key) + .expect("the 4-param body must be preserved under a synthetic disambig key"); + assert_eq!(conflicting.param_count, 4); } #[test] diff --git a/src/taint/mod.rs b/src/taint/mod.rs index bf82d9a8..3994f88f 100644 --- a/src/taint/mod.rs +++ b/src/taint/mod.rs @@ -1940,18 +1940,10 @@ pub(crate) fn extract_intra_file_ssa_summaries( Err(_) => continue, }; - // Param count = number of formal params (from CFG), falling back to - // counting all SsaOp::Param ops when no local summary is available. - let param_count = if !formal_params.is_empty() { - formal_params.len() - } else { - func_ssa - .blocks - .iter() - .flat_map(|b| b.phis.iter().chain(b.body.iter())) - .filter(|i| matches!(i.op, crate::ssa::ir::SsaOp::Param { .. })) - .count() - }; + // `formal_params` is authoritative even when it is empty. SSA lowering + // also emits Param ops for external captures; counting those as arity + // makes zero-arg functions look like synthetic overloads. + let param_count = formal_params.len(); // Zero-param helpers are normally elided, a fixture with no // parameters cannot carry per-parameter taint transforms. But diff --git a/tests/cli_validation_tests.rs b/tests/cli_validation_tests.rs index 39e4f492..af281a04 100644 --- a/tests/cli_validation_tests.rs +++ b/tests/cli_validation_tests.rs @@ -15,6 +15,7 @@ use assert_cmd::Command; use predicates::prelude::*; +use serde_json::Value; use std::path::PathBuf; /// Build a scan command with a fresh config dir and a writable tempdir as @@ -164,6 +165,85 @@ fn scan_with_no_extra_flags_on_clean_target_succeeds() { cmd.assert().success(); } +fn assert_stdout_is_json_from_byte_zero(output: &[u8], context: &str) -> Value { + assert_eq!( + output.first().copied(), + Some(b'{'), + "{context}: stdout must start with a JSON object, got prefix {:?}", + String::from_utf8_lossy(&output[..output.len().min(80)]) + ); + serde_json::from_slice(output).unwrap_or_else(|e| { + panic!( + "{context}: stdout did not parse as JSON: {e}\n--- stdout prefix ---\n{}", + String::from_utf8_lossy(&output[..output.len().min(400)]) + ) + }) +} + +#[test] +fn scan_json_stdout_is_machine_clean_when_tracing_warns() { + let home = tempfile::tempdir().unwrap(); + let target = prepare_scan_target(); + let (mut cmd, _) = scan_cmd(home.path(), target.path()); + cmd.env("RUST_LOG", "warn") + .args(["--format", "json", "--no-index", "--parse-timeout-ms", "0"]); + + let assert = cmd.assert().success(); + let value = + assert_stdout_is_json_from_byte_zero(&assert.get_output().stdout, "nyx scan --format json"); + assert!( + value.get("findings").is_some(), + "JSON scan payload missing findings" + ); +} + +#[test] +fn scan_sarif_stdout_is_machine_clean_when_tracing_warns() { + let home = tempfile::tempdir().unwrap(); + let target = prepare_scan_target(); + let (mut cmd, _) = scan_cmd(home.path(), target.path()); + cmd.env("RUST_LOG", "warn").args([ + "--format", + "sarif", + "--no-index", + "--parse-timeout-ms", + "0", + ]); + + let assert = cmd.assert().success(); + let value = assert_stdout_is_json_from_byte_zero( + &assert.get_output().stdout, + "nyx scan --format sarif", + ); + assert_eq!(value["version"], "2.1.0", "SARIF version missing"); +} + +#[test] +fn scan_quiet_suppresses_tracing_warnings() { + let home = tempfile::tempdir().unwrap(); + let target = prepare_scan_target(); + let (mut cmd, _) = scan_cmd(home.path(), target.path()); + cmd.env("RUST_LOG", "warn").args([ + "--format", + "json", + "--quiet", + "--no-index", + "--parse-timeout-ms", + "0", + ]); + + let assert = cmd.assert().success(); + assert_stdout_is_json_from_byte_zero( + &assert.get_output().stdout, + "nyx scan --format json --quiet", + ); + assert!( + assert.get_output().stderr.is_empty(), + "--quiet should suppress tracing/status stderr, got:\n{}", + String::from_utf8_lossy(&assert.get_output().stderr) + ); +} + /// `--explain-engine` short-circuits the scan path and prints the resolved /// engine configuration to stdout. Exit code 0, non-empty stdout, and the /// "Effective engine configuration" header present.