mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0002 (20260521T143544Z-f898)
This commit is contained in:
parent
be4021d8c0
commit
b3766311fb
20 changed files with 388 additions and 664 deletions
16
src/cli.rs
16
src/cli.rs
|
|
@ -471,9 +471,9 @@ pub enum Commands {
|
|||
|
||||
/// Build a harness and dynamically verify each finding in a sandbox.
|
||||
///
|
||||
/// Dynamic verification is on by default (M7). This flag is a no-op
|
||||
/// when verification is already enabled via config. Use `--no-verify`
|
||||
/// to disable for a single run. Requires the binary to be built with
|
||||
/// Dynamic verification is on by default. This flag is a no-op when
|
||||
/// verification is already enabled via config. Use `--no-verify` to
|
||||
/// disable it for a single run. Requires the binary to be built with
|
||||
/// `--features dynamic`; without that feature this flag is silently ignored.
|
||||
#[cfg_attr(not(feature = "dynamic"), arg(hide = true))]
|
||||
#[arg(long, help_heading = "Dynamic", conflicts_with = "no_verify")]
|
||||
|
|
@ -489,9 +489,9 @@ pub enum Commands {
|
|||
|
||||
/// Also verify `Confidence < Medium` findings dynamically.
|
||||
///
|
||||
/// By default only `Confidence >= Medium` findings are verified (§5.1).
|
||||
/// Pass this flag to run verification on all findings regardless of
|
||||
/// confidence. Intended for corpus-building and backfill runs.
|
||||
/// By default only `Confidence >= Medium` findings are verified. Pass
|
||||
/// this flag to run verification on all findings regardless of
|
||||
/// confidence. Intended for payload tuning and backfill runs.
|
||||
#[cfg_attr(not(feature = "dynamic"), arg(hide = true))]
|
||||
#[arg(long, help_heading = "Dynamic")]
|
||||
verify_all_confidence: bool,
|
||||
|
|
@ -532,7 +532,7 @@ pub enum Commands {
|
|||
)]
|
||||
harden: Option<String>,
|
||||
|
||||
// ── Baseline / patch-validation (§M6.5) ────────────────────────
|
||||
// Baseline / patch-validation
|
||||
/// Read a previous scan's JSON output (or a stripped .nyx/baseline.json)
|
||||
/// and diff it against the current scan on stable_hash.
|
||||
///
|
||||
|
|
@ -564,7 +564,7 @@ pub enum Commands {
|
|||
gate: Option<String>,
|
||||
},
|
||||
|
||||
/// Submit feedback on a dynamic verification verdict (§21.2).
|
||||
/// Submit feedback on a dynamic verification verdict.
|
||||
///
|
||||
/// Records a correction or confirmation for a finding's verdict in the
|
||||
/// local telemetry log. Requires `--features dynamic`.
|
||||
|
|
|
|||
|
|
@ -283,10 +283,17 @@ pub fn method_formal_types(method: Node<'_>, bytes: &[u8]) -> Vec<(String, Strin
|
|||
|
||||
/// Extract placeholder names from a route path template.
|
||||
///
|
||||
/// Supports two placeholder syntaxes:
|
||||
/// Supports three placeholder syntaxes:
|
||||
/// - JAX-RS / Spring / Micronaut: `/users/{id}` → `id`,
|
||||
/// `/users/{id:[0-9]+}` → `id`.
|
||||
/// - Servlet-mapping `*` wildcards: ignored (no name to bind).
|
||||
/// - Spring 5.3+ capture-all variables: `/files/{*path}` → `path`
|
||||
/// (matches the remainder of the URI including slashes).
|
||||
/// - Bare Ant-style `*` / `**` wildcards (`/users/*`, `/files/**`):
|
||||
/// intentionally yield no placeholders. They are unnamed by Spring's
|
||||
/// `AntPathMatcher` and cannot bind by formal name; handlers that
|
||||
/// need the matched segment use `HttpServletRequest.getRequestURI()`
|
||||
/// (already routed to [`ParamSource::Implicit`]) or the named
|
||||
/// `{*name}` capture-all syntax above.
|
||||
pub fn extract_path_placeholders(path: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let bytes = path.as_bytes();
|
||||
|
|
@ -295,7 +302,8 @@ pub fn extract_path_placeholders(path: &str) -> Vec<String> {
|
|||
if bytes[i] == b'{'
|
||||
&& let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') {
|
||||
let inner = &path[i + 1..i + 1 + end];
|
||||
let name = inner.split(':').next().unwrap_or(inner).trim();
|
||||
let inner_name = inner.split(':').next().unwrap_or(inner).trim();
|
||||
let name = inner_name.strip_prefix('*').unwrap_or(inner_name);
|
||||
if !name.is_empty() && !out.iter().any(|n| n == name) {
|
||||
out.push(name.to_owned());
|
||||
}
|
||||
|
|
@ -420,6 +428,26 @@ mod tests {
|
|||
assert_eq!(extract_path_placeholders("/u/{id:[0-9]+}"), vec!["id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_capture_all_variable() {
|
||||
assert_eq!(extract_path_placeholders("/files/{*path}"), vec!["path"]);
|
||||
assert_eq!(
|
||||
extract_path_placeholders("/api/{tenant}/files/{*resource}"),
|
||||
vec!["tenant", "resource"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_ant_globs_yield_no_placeholders() {
|
||||
// Bare `*` and `**` are unnamed by Spring's AntPathMatcher and have
|
||||
// no name to bind a formal to. Handlers that need the matched
|
||||
// segment use the request object (routed to [`ParamSource::Implicit`])
|
||||
// or the named `{*name}` capture-all syntax above.
|
||||
assert!(extract_path_placeholders("/users/*").is_empty());
|
||||
assert!(extract_path_placeholders("/files/**").is_empty());
|
||||
assert!(extract_path_placeholders("/a/*/b/**/c").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_drops_double_slash() {
|
||||
assert_eq!(join_route_path("/api", "/x"), "/api/x");
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use tree_sitter::Node;
|
|||
|
||||
use super::ruby_routes::{
|
||||
bind_path_params, class_extends, class_name, find_class_with_method, first_string_arg,
|
||||
kwarg_string, method_formal_names, source_imports_rails, verb_from_ident,
|
||||
first_symbol_arg, kwarg_string, method_formal_names, source_imports_rails, verb_from_ident,
|
||||
};
|
||||
|
||||
pub struct RubyRailsAdapter;
|
||||
|
|
@ -40,9 +40,13 @@ fn class_is_rails_controller(class: Node<'_>, bytes: &[u8]) -> bool {
|
|||
/// Walk the file's top-level `call` nodes looking for a
|
||||
/// `Rails.application.routes.draw` block or bare `get / post / ...`
|
||||
/// dispatch lines, and return the first `(method, path)` whose
|
||||
/// `to: 'controller#action'` kwarg references the target. Returns
|
||||
/// `None` when no route mapping is present (the caller then falls
|
||||
/// back to the conventional `/{action}` shape).
|
||||
/// `to: 'controller#action'` kwarg references the target. Respects
|
||||
/// `namespace :api do ... end` and `scope :v1 do ... end` /
|
||||
/// `scope path: '/v1' do ... end` nesting so a route declared inside
|
||||
/// such a block resolves against the prefixed path + controller name
|
||||
/// Rails actually mounts it under. Returns `None` when no mapping
|
||||
/// is present (the caller then falls back to the conventional
|
||||
/// `/{action}` shape).
|
||||
fn find_route_mapping<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
|
|
@ -50,7 +54,7 @@ fn find_route_mapping<'a>(
|
|||
action: &str,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
visit_routes(root, bytes, controller, action, &mut hit);
|
||||
visit_routes(root, bytes, controller, action, "", "", &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
|
|
@ -59,19 +63,98 @@ fn visit_routes<'a>(
|
|||
bytes: &'a [u8],
|
||||
controller: &str,
|
||||
action: &str,
|
||||
path_prefix: &str,
|
||||
ctrl_prefix: &str,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call"
|
||||
&& let Some(found) = try_route_mapping(node, bytes, controller, action) {
|
||||
if node.kind() == "call" {
|
||||
if let Some((kind, ident)) = route_nesting_kind(node, bytes) {
|
||||
let (path_pfx, ctrl_pfx) = match kind {
|
||||
NestingKind::Namespace => (
|
||||
format!("{path_prefix}/{ident}"),
|
||||
format!("{ctrl_prefix}{ident}/"),
|
||||
),
|
||||
NestingKind::ScopeSymbol => (
|
||||
format!("{path_prefix}/{ident}"),
|
||||
format!("{ctrl_prefix}{ident}/"),
|
||||
),
|
||||
NestingKind::ScopePath => (format!("{path_prefix}/{ident}"), ctrl_prefix.to_owned()),
|
||||
};
|
||||
recurse_into_block(node, bytes, controller, action, &path_pfx, &ctrl_pfx, out);
|
||||
return;
|
||||
}
|
||||
if let Some(found) = try_route_mapping(node, bytes, controller, action, path_prefix, ctrl_prefix) {
|
||||
*out = Some(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
visit_routes(child, bytes, controller, action, out);
|
||||
visit_routes(child, bytes, controller, action, path_prefix, ctrl_prefix, out);
|
||||
}
|
||||
}
|
||||
|
||||
enum NestingKind {
|
||||
Namespace,
|
||||
ScopeSymbol,
|
||||
ScopePath,
|
||||
}
|
||||
|
||||
/// If `call` is a routes-DSL nesting block (`namespace :api do ... end`,
|
||||
/// `scope :v1 do ... end`, or `scope path: '/v1' do ... end`) return
|
||||
/// the kind + the extracted identifier (a bare token for namespace /
|
||||
/// symbol-scope, a leading-slash-stripped path for path-scope).
|
||||
fn route_nesting_kind<'a>(call: Node<'a>, bytes: &'a [u8]) -> Option<(NestingKind, String)> {
|
||||
let mut cur = call.walk();
|
||||
let mut ident: Option<&str> = None;
|
||||
let mut args: Option<Node<'a>> = None;
|
||||
for child in call.named_children(&mut cur) {
|
||||
match child.kind() {
|
||||
"identifier" => ident = child.utf8_text(bytes).ok(),
|
||||
"argument_list" => args = Some(child),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ident = ident?;
|
||||
let args = args?;
|
||||
match ident {
|
||||
"namespace" => {
|
||||
let sym = first_symbol_arg(args, bytes)?;
|
||||
Some((NestingKind::Namespace, sym))
|
||||
}
|
||||
"scope" => {
|
||||
if let Some(sym) = first_symbol_arg(args, bytes) {
|
||||
Some((NestingKind::ScopeSymbol, sym))
|
||||
} else {
|
||||
let path = kwarg_string(args, bytes, "path")?;
|
||||
let trimmed = path.trim_start_matches('/').to_owned();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((NestingKind::ScopePath, trimmed))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn recurse_into_block<'a>(
|
||||
call: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
controller: &str,
|
||||
action: &str,
|
||||
path_prefix: &str,
|
||||
ctrl_prefix: &str,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
) {
|
||||
let mut cur = call.walk();
|
||||
for child in call.named_children(&mut cur) {
|
||||
if child.kind() == "do_block" || child.kind() == "block" {
|
||||
visit_routes(child, bytes, controller, action, path_prefix, ctrl_prefix, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +163,8 @@ fn try_route_mapping<'a>(
|
|||
bytes: &'a [u8],
|
||||
controller: &str,
|
||||
action: &str,
|
||||
path_prefix: &str,
|
||||
ctrl_prefix: &str,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut cur = call.walk();
|
||||
let mut verb: Option<HttpMethod> = None;
|
||||
|
|
@ -100,8 +185,14 @@ fn try_route_mapping<'a>(
|
|||
let path = first_string_arg(args, bytes)?;
|
||||
let to = kwarg_string(args, bytes, "to")?;
|
||||
let (ctrl, act) = to.split_once('#')?;
|
||||
if controller_matches(ctrl, controller) && act == action {
|
||||
return Some((verb, path));
|
||||
let full_ctrl = format!("{ctrl_prefix}{ctrl}");
|
||||
if controller_matches(&full_ctrl, controller) && act == action {
|
||||
let full_path = if path_prefix.is_empty() {
|
||||
path
|
||||
} else {
|
||||
format!("{}/{}", path_prefix, path.trim_start_matches('/'))
|
||||
};
|
||||
return Some((verb, full_path));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -269,6 +360,51 @@ mod tests {
|
|||
assert!(matches!(id.source, crate::dynamic::framework::ParamSource::PathSegment(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_draw_namespace_applies_prefix_to_path_and_controller() {
|
||||
let src: &[u8] = b"Rails.application.routes.draw do\n namespace :api do\n get '/users', to: 'users#index'\n end\nend\n\nclass Api::UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/api/users");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_draw_scope_path_prefixes_path_only() {
|
||||
let src: &[u8] = b"Rails.application.routes.draw do\n scope path: '/v1' do\n get '/users', to: 'users#index'\n end\nend\n\nclass UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/v1/users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_draw_scope_symbol_prefixes_path_and_controller() {
|
||||
let src: &[u8] = b"Rails.application.routes.draw do\n scope :admin do\n get '/users', to: 'users#index'\n end\nend\n\nclass Admin::UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/admin/users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_draw_nested_namespaces_compose_prefixes() {
|
||||
let src: &[u8] = b"Rails.application.routes.draw do\n namespace :api do\n namespace :v1 do\n get '/users', to: 'users#index'\n end\n end\nend\n\nclass Api::V1::UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/api/v1/users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_class_is_not_a_controller() {
|
||||
let src: &[u8] = b"class Foo\n def bar\n 'ok'\n end\nend\n";
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ fn named_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
|
|||
pub fn class_name<'a>(class: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
let mut cur = class.walk();
|
||||
for c in class.named_children(&mut cur) {
|
||||
if c.kind() == "constant" {
|
||||
if c.kind() == "constant" || c.kind() == "scope_resolution" {
|
||||
return c.utf8_text(bytes).ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +352,22 @@ fn is_implicit_formal(name: &str) -> bool {
|
|||
matches!(name, "env" | "request" | "req" | "params" | "response" | "res")
|
||||
}
|
||||
|
||||
/// Read the first positional symbol argument (`:foo`) from an
|
||||
/// `argument_list` child. Used by the Rails router DSL to pull the
|
||||
/// namespace name out of `namespace :api do ... end` and the
|
||||
/// positional form of `scope :v1 do ... end`. The returned string
|
||||
/// is the symbol's identifier portion without the leading colon.
|
||||
pub fn first_symbol_arg<'a>(args: Node<'a>, bytes: &'a [u8]) -> Option<String> {
|
||||
let mut cur = args.walk();
|
||||
for c in args.named_children(&mut cur) {
|
||||
if c.kind() == "simple_symbol" {
|
||||
let raw = c.utf8_text(bytes).ok()?;
|
||||
return Some(raw.trim_start_matches(':').to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the first positional string-literal argument from an
|
||||
/// `argument_list` child. Used by every Ruby route adapter to pull
|
||||
/// a path template out of `get '/run' do ... end` and the Rails
|
||||
|
|
|
|||
|
|
@ -565,10 +565,9 @@ pub enum ReplayResult {
|
|||
/// Tri-state map of [`ReplayResult`] onto the eval-corpus
|
||||
/// `VerifyResult::replay_stable` field shape.
|
||||
///
|
||||
/// * `Some(true)` — replay matched the recorded outcome.
|
||||
/// * `Some(false)` — replay diverged or aborted in a way that the M7
|
||||
/// Gate-5 inversion treats as instability.
|
||||
/// * `None` — replay was not informative (toolchain mismatched, docker
|
||||
/// * `Some(true)` - replay matched the recorded outcome.
|
||||
/// * `Some(false)` - replay diverged or aborted.
|
||||
/// * `None` - replay was not informative (toolchain mismatched, docker
|
||||
/// unavailable, or the bundle had no `reproduce.sh`). The corpus
|
||||
/// tabulator treats `None` as "no signal" and excludes the row from
|
||||
/// the per-cell `stable_replays` numerator.
|
||||
|
|
@ -582,15 +581,14 @@ pub fn replay_stability(result: &ReplayResult) -> Option<bool> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 28 — Track H.3. Run `reproduce.sh` in `bundle_root` and map the
|
||||
/// shell exit code into a [`ReplayResult`].
|
||||
/// Run `reproduce.sh` in `bundle_root` and map the shell exit code into a
|
||||
/// [`ReplayResult`].
|
||||
///
|
||||
/// `extra_args` is appended to `reproduce.sh` (`--docker` when the caller
|
||||
/// wants the docker backend; empty for the process backend).
|
||||
///
|
||||
/// This is the host-side companion to the M7 Gate 5 inversion: callers
|
||||
/// who want "did this bundle replay green?" semantics see a typed result
|
||||
/// and the M7 gate script gets a uniform contract to assert against.
|
||||
/// Callers who want "did this bundle replay green?" semantics get a typed
|
||||
/// result instead of parsing shell output.
|
||||
pub fn replay_bundle(
|
||||
bundle_root: &Path,
|
||||
extra_args: &[&str],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Telemetry event log (§21.1).
|
||||
//! Telemetry event log.
|
||||
//!
|
||||
//! Writes one JSON line per verdict to `~/.cache/nyx/dynamic/events.jsonl`.
|
||||
//! `NYX_NO_TELEMETRY=1` silently disables all writes (§21.4).
|
||||
//! `NYX_NO_TELEMETRY=1` silently disables all writes.
|
||||
//!
|
||||
//! # Schema (Phase 27)
|
||||
//! # Schema
|
||||
//!
|
||||
//! Every record starts with three envelope fields so the on-disk format can
|
||||
//! evolve across releases without silently mixing incompatible records:
|
||||
|
|
@ -12,11 +12,10 @@
|
|||
//! - `nyx_version`: the Cargo package version that wrote the record.
|
||||
//! - `corpus_version`: the payload-corpus version active at write time.
|
||||
//!
|
||||
//! Followed by a `kind` discriminator (`"verdict"` or `"rank_delta"`). All
|
||||
//! readers (`read_events`, the M7 ship gate) require `schema_version ==
|
||||
//! [`SCHEMA_VERSION`]; mismatched records produce
|
||||
//! [`TelemetryReadError::SchemaMismatch`] instead of being silently parsed
|
||||
//! as if they matched.
|
||||
//! Followed by a `kind` discriminator (`"verdict"` or `"rank_delta"`). All
|
||||
//! readers require `schema_version == SCHEMA_VERSION`; mismatched records
|
||||
//! produce [`TelemetryReadError::SchemaMismatch`] instead of being silently
|
||||
//! parsed as if they matched.
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
|
|
@ -258,12 +257,10 @@ fn lang_from_path(path: &str) -> String {
|
|||
.unwrap_or_else(|| "unknown".to_owned())
|
||||
}
|
||||
|
||||
/// Sampling decision for telemetry writes (Phase 27, Track H.2).
|
||||
/// Sampling decision for telemetry writes.
|
||||
///
|
||||
/// Confirmed and Inconclusive verdicts are calibration-critical (false-Confirmed
|
||||
/// rate gates M7 ship; Inconclusive reasons drive the spec-derivation roadmap)
|
||||
/// and are always retained. Other verdict statuses can be downsampled to bound
|
||||
/// log growth on high-volume scans.
|
||||
/// Confirmed and Inconclusive verdicts are kept for calibration. Other verdict
|
||||
/// statuses can be downsampled to bound log growth on high-volume scans.
|
||||
///
|
||||
/// The decision is seeded by `spec_hash` so the *same* finding makes the *same*
|
||||
/// keep-or-drop call across reruns. Without this, two scans of the same project
|
||||
|
|
@ -413,12 +410,11 @@ pub fn log_path() -> Option<std::path::PathBuf> {
|
|||
events_log_path()
|
||||
}
|
||||
|
||||
// ── Reading events back (Phase 27) ───────────────────────────────────────────
|
||||
// Reading events back
|
||||
|
||||
/// Structured error returned by [`read_events`].
|
||||
///
|
||||
/// Surfaced to the M7 ship gate so Gate 2 can fail loudly on schema-mismatch
|
||||
/// rather than silently treating mismatched records as "no data".
|
||||
/// Returned when a log mixes records from incompatible schema versions.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TelemetryReadError {
|
||||
#[error("io error reading {path}: {source}")]
|
||||
|
|
@ -451,14 +447,12 @@ pub enum TelemetryReadError {
|
|||
///
|
||||
/// Returns each line as a `serde_json::Value` so callers can dispatch on the
|
||||
/// `kind` discriminator themselves. Rejects any record whose `schema_version`
|
||||
/// does not match [`SCHEMA_VERSION`] (this is the explicit failure mode the
|
||||
/// M7 ship gate Gate 2 consumes; a v0 record from an older release must not
|
||||
/// silently parse as if the schema had never changed).
|
||||
/// does not match [`SCHEMA_VERSION`]. A v0 record from an older release must
|
||||
/// not silently parse as if the schema had never changed.
|
||||
///
|
||||
/// Blank lines are skipped. Any malformed JSON or missing `schema_version`
|
||||
/// fails the whole read; partial recovery is not the contract here because
|
||||
/// the ship gate already treats "log missing or unreadable" as "no data,
|
||||
/// skip Gate 2 with a notice."
|
||||
/// Blank lines are skipped. Any malformed JSON or missing `schema_version`
|
||||
/// fails the whole read; partial recovery is not the contract for telemetry
|
||||
/// logs.
|
||||
pub fn read_events(path: &Path) -> Result<Vec<serde_json::Value>, TelemetryReadError> {
|
||||
let file = std::fs::File::open(path).map_err(|e| TelemetryReadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
|
|
@ -551,8 +545,8 @@ pub fn feedback_wrong_for_finding(path: &Path, finding_id: &str) -> Option<bool>
|
|||
/// One telemetry event per ranked finding that carries a dynamic verdict delta.
|
||||
///
|
||||
/// Emitted by `rank::rank_diags` for every diag whose dynamic verdict shifts
|
||||
/// its rank score (delta != 0). Used by the M7 calibration pipeline to tune
|
||||
/// the N/M boost/penalty constants from real-world verdict distributions.
|
||||
/// its rank score (delta != 0). Used to tune the N/M boost/penalty constants
|
||||
/// from real-world verdict distributions.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RankDeltaEvent {
|
||||
pub schema_version: u32,
|
||||
|
|
|
|||
|
|
@ -85,14 +85,11 @@ pub struct VerifyOptions {
|
|||
/// Default `false`. [`Self::from_config`] honours the
|
||||
/// `NYX_VERIFY_REPLAY_STABLE` environment variable (`1` / `true`).
|
||||
pub replay_stable_check: bool,
|
||||
/// Phase 31 follow-up: when `true` and `replay_stable_check` is also
|
||||
/// `true`, the verifier passes `--docker` to `reproduce.sh` instead of
|
||||
/// running it through the host's process backend. Lets the eval-corpus
|
||||
/// driver mark `replay_stable` based on the bare-image replay path so
|
||||
/// the M7 ship-gate's Gate 5 reflects the docker bundle's green/red
|
||||
/// signal — required when the corpus walks a host that has stripped
|
||||
/// the language toolchains (the bare-image CI matrix at
|
||||
/// `.github/workflows/repro-bare.yml`).
|
||||
/// When `true` and `replay_stable_check` is also `true`, the verifier
|
||||
/// passes `--docker` to `reproduce.sh` instead of running it through the
|
||||
/// host's process backend. This lets eval-corpus runs mark
|
||||
/// `replay_stable` from the bare-image replay path when the host has
|
||||
/// stripped language toolchains.
|
||||
///
|
||||
/// Default `false`. [`Self::from_config`] honours the
|
||||
/// `NYX_VERIFY_REPLAY_DOCKER` environment variable (`1` / `true`).
|
||||
|
|
|
|||
17
src/rank.rs
17
src/rank.rs
|
|
@ -99,7 +99,7 @@ pub fn compute_attack_rank(diag: &Diag) -> AttackRank {
|
|||
// All other verdicts (Unsupported, Inconclusive, no verdict) are
|
||||
// unaffected: no data is better than speculative data.
|
||||
//
|
||||
// Calibrated values (M7 eval corpus): N=20, M=5.
|
||||
// Calibrated values from the eval corpus: N=20, M=5.
|
||||
// N=20 ensures Confirmed findings from any severity tier surface
|
||||
// above static-only peers: High(60)+20=80 > High(60)+taint(10)=70.
|
||||
// M=5 nudges exhausted-corpus NotConfirmed below equal static peers
|
||||
|
|
@ -209,7 +209,7 @@ pub fn rank_diags(diags: &mut [Diag]) {
|
|||
if !rank.components.is_empty() {
|
||||
d.rank_reason = Some(rank.components.clone());
|
||||
}
|
||||
// Emit rank-delta telemetry for M7 calibration (§21 / deferred M7 hook).
|
||||
// Emit rank-delta telemetry for score calibration.
|
||||
// Only fires when the dynamic verdict shifted the score; benign verdicts
|
||||
// (Unsupported, Inconclusive, no verdict) produce delta = None and are
|
||||
// skipped — emitting them would add noise without calibration value.
|
||||
|
|
@ -247,17 +247,16 @@ pub fn rank_diags(diags: &mut [Diag]) {
|
|||
/// Returns `None` when there is no verdict (static-only scan) or the verdict
|
||||
/// does not change the score (Unsupported, Inconclusive).
|
||||
///
|
||||
/// Design note (§deferred M7 payload_corpus_complete): the spec originally
|
||||
/// distinguished `NotConfirmed` + `payload_corpus_complete == true` → `-M`
|
||||
/// from `NotConfirmed` + `NoPayloadsForCap` → no change. In practice the
|
||||
/// Design note: the spec originally distinguished `NotConfirmed` +
|
||||
/// `payload_corpus_complete == true` from `NotConfirmed` +
|
||||
/// `NoPayloadsForCap`. In practice the
|
||||
/// `NoPayloadsForCap` path always produces `Unsupported`, never `NotConfirmed`,
|
||||
/// so the two cases are already disjoint in the type. The heuristic
|
||||
/// `!dv.attempts.is_empty()` (corpus was actually tried) is equivalent to
|
||||
/// `payload_corpus_complete == true` for all reachable states — no extra
|
||||
/// field is needed. See also §deferred decision in `.pitboss/play/deferred.md`.
|
||||
/// `payload_corpus_complete == true` for all reachable states, so no extra
|
||||
/// field is needed.
|
||||
///
|
||||
/// Values calibrated against M7 eval corpus (OWASP Benchmark v1.2 + in-house curated set):
|
||||
/// N=20, M=5 — see `docs/dynamic_eval_m7.md` for precision/recall breakdowns.
|
||||
/// Values calibrated against the eval corpus: N=20, M=5.
|
||||
fn dynamic_verdict_delta(diag: &Diag) -> Option<f64> {
|
||||
use crate::evidence::VerifyStatus;
|
||||
let dv = diag.evidence.as_ref()?.dynamic_verdict.as_ref()?;
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ struct StartScanRequest {
|
|||
engine_profile: Option<String>,
|
||||
/// Override dynamic verification for this scan.
|
||||
///
|
||||
/// `true` — force on even if config says off.
|
||||
/// `false` — force off even if config says on (M7 default-on).
|
||||
/// absent — inherit config default (true since M7).
|
||||
/// `true` - force on even if config says off.
|
||||
/// `false` - force off even if config says on.
|
||||
/// absent - inherit config default.
|
||||
///
|
||||
/// Requires `--features dynamic`; `true` returns 400 when the
|
||||
/// feature is absent.
|
||||
|
|
|
|||
|
|
@ -251,8 +251,8 @@ pub struct ScannerConfig {
|
|||
|
||||
/// Run dynamic verification on each finding after the static pass.
|
||||
///
|
||||
/// Default `true` (M7 flip). Each `Confidence >= Medium` finding is
|
||||
/// passed to `dynamic::verify_finding` and the result is stored in
|
||||
/// Default `true`. Each `Confidence >= Medium` finding is passed to
|
||||
/// `dynamic::verify_finding` and the result is stored in
|
||||
/// `Evidence::dynamic_verdict`. Use `--no-verify` (CLI) or set
|
||||
/// `verify = false` in `nyx.toml` to disable.
|
||||
///
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue