mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
* refactor: Update comments for clarity and add expectations.json files for performance metrics * feat: Implement FP guard for JS/TS local-collection receivers to suppress missing ownership checks * feat: Enhance Rust parameter handling to classify local collections and prevent false ownership checks * refactor: Simplify code formatting for better readability in multiple files * refactor: Improve UTF-8 sequence length handling and enhance clarity in loop iteration * feat: Update Java and Python patterns to include new security rules * refactor: Improve comment clarity and consistency across multiple Rust files * refactor: Simplify code formatting for improved readability in integration tests and module files * refactor: Improve comment formatting and enhance clarity in assertions across multiple files
509 lines
18 KiB
Rust
509 lines
18 KiB
Rust
pub mod clean;
|
|
pub mod config;
|
|
pub mod index;
|
|
pub mod list;
|
|
pub mod scan;
|
|
#[cfg(feature = "serve")]
|
|
pub mod serve;
|
|
|
|
use crate::cli::{Commands, EngineProfile, IndexMode, ScanMode};
|
|
use crate::errors::NyxResult;
|
|
use crate::patterns::{Severity, SeverityFilter};
|
|
use crate::utils::config::{AnalysisMode, Config};
|
|
use std::path::Path;
|
|
|
|
pub fn handle_command(
|
|
command: Commands,
|
|
database_dir: &Path,
|
|
config_dir: &Path,
|
|
config: &mut Config,
|
|
) -> NyxResult<()> {
|
|
// Resolve engine options once for the whole process. Scan overlays CLI
|
|
// flags below; other subcommands use the config values verbatim. The
|
|
// install is a no-op after the first call, so Scan's overlay must happen
|
|
// before we reach this point for its own call path, we delay the install
|
|
// to the Scan arm and gate non-scan commands behind a fallback install of
|
|
// the bare config values.
|
|
let install_from_config = |config: &Config| {
|
|
if config.analysis.engine.parse_timeout_ms == 0 {
|
|
tracing::warn!(
|
|
"parse_timeout_ms = 0 disables tree-sitter parse timeout entirely; \
|
|
this is unsafe for untrusted input."
|
|
);
|
|
}
|
|
let _ = crate::utils::analysis_options::install(config.analysis.engine);
|
|
};
|
|
|
|
match command {
|
|
Commands::Scan {
|
|
path,
|
|
index,
|
|
format,
|
|
severity,
|
|
mode,
|
|
profile,
|
|
engine_profile,
|
|
explain_engine,
|
|
all_targets,
|
|
keep_nonprod_severity,
|
|
quiet,
|
|
fail_on,
|
|
no_state,
|
|
no_rank,
|
|
show_suppressed,
|
|
show_all,
|
|
include_quality,
|
|
max_low,
|
|
max_low_per_file,
|
|
max_low_per_rule,
|
|
rollup_examples,
|
|
show_instances,
|
|
min_score,
|
|
min_confidence,
|
|
require_converged,
|
|
// Analysis engine toggles
|
|
constraint_solving,
|
|
no_constraint_solving,
|
|
abstract_interp,
|
|
no_abstract_interp,
|
|
context_sensitive,
|
|
no_context_sensitive,
|
|
symex,
|
|
no_symex,
|
|
cross_file_symex,
|
|
no_cross_file_symex,
|
|
symex_interproc,
|
|
no_symex_interproc,
|
|
smt,
|
|
no_smt,
|
|
backwards_analysis,
|
|
no_backwards_analysis,
|
|
parse_timeout_ms,
|
|
max_origins,
|
|
max_pointsto,
|
|
// Deprecated aliases
|
|
no_index,
|
|
rebuild_index,
|
|
high_only,
|
|
ast_only,
|
|
cfg_only,
|
|
} => {
|
|
// ── Apply profile first (CLI flags override after) ──────────
|
|
if let Some(ref name) = profile {
|
|
config.apply_profile(name)?;
|
|
}
|
|
|
|
// ── Resolve deprecated aliases ──────────────────────────────
|
|
// Each alias still works but emits a one-line stderr nudge so
|
|
// users learn the new flag. Suppressed under --quiet and
|
|
// structured output formats so machine pipelines stay clean.
|
|
use crate::cli::OutputFormat;
|
|
let effective_format = format.unwrap_or(config.output.default_format);
|
|
let structured = matches!(effective_format, OutputFormat::Json | OutputFormat::Sarif);
|
|
let suppress_warnings = quiet || config.output.quiet || structured;
|
|
let warn_dep = |old: &str, new: &str| {
|
|
if !suppress_warnings {
|
|
eprintln!(
|
|
"{}: {} is deprecated; use {} instead.",
|
|
console::style("warn").yellow().bold(),
|
|
console::style(old).bold(),
|
|
console::style(new).bold()
|
|
);
|
|
}
|
|
};
|
|
|
|
// Index mode: explicit --index wins, then deprecated flags
|
|
let effective_index = if no_index {
|
|
warn_dep("--no-index", "--index off");
|
|
IndexMode::Off
|
|
} else if rebuild_index {
|
|
warn_dep("--rebuild-index", "--index rebuild");
|
|
IndexMode::Rebuild
|
|
} else {
|
|
index
|
|
};
|
|
|
|
// Analysis mode: explicit --mode wins, then deprecated flags
|
|
let effective_mode = if ast_only {
|
|
warn_dep("--ast-only", "--mode ast");
|
|
ScanMode::Ast
|
|
} else if cfg_only {
|
|
warn_dep("--cfg-only", "--mode cfg");
|
|
ScanMode::Cfg
|
|
} else if all_targets {
|
|
warn_dep("--all-targets", "--mode full");
|
|
ScanMode::Full
|
|
} else {
|
|
mode
|
|
};
|
|
|
|
// Severity filter: explicit --severity wins, then --high-only
|
|
let severity_filter = if let Some(ref expr) = severity {
|
|
Some(SeverityFilter::parse(expr).map_err(|e| {
|
|
crate::errors::NyxError::Msg(format!("invalid --severity expression: {e}"))
|
|
})?)
|
|
} else if high_only {
|
|
warn_dep("--high-only", "--severity HIGH");
|
|
Some(SeverityFilter::parse("HIGH").unwrap())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Fail-on threshold
|
|
let fail_on_sev = if let Some(ref expr) = fail_on {
|
|
Some(expr.trim().parse::<Severity>().map_err(|e| {
|
|
crate::errors::NyxError::Msg(format!("invalid --fail-on value: {e}"))
|
|
})?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// ── Apply to config ─────────────────────────────────────────
|
|
|
|
match effective_mode {
|
|
ScanMode::Full => config.scanner.mode = AnalysisMode::Full,
|
|
ScanMode::Ast => config.scanner.mode = AnalysisMode::Ast,
|
|
ScanMode::Cfg => config.scanner.mode = AnalysisMode::Cfg,
|
|
ScanMode::Taint => config.scanner.mode = AnalysisMode::Taint,
|
|
}
|
|
|
|
if keep_nonprod_severity {
|
|
config.scanner.include_nonprod = true;
|
|
}
|
|
|
|
if quiet {
|
|
config.output.quiet = true;
|
|
}
|
|
|
|
if no_state {
|
|
config.scanner.enable_state_analysis = false;
|
|
}
|
|
|
|
if no_rank {
|
|
config.output.attack_surface_ranking = false;
|
|
}
|
|
|
|
// Min-score: CLI wins, then config
|
|
if let Some(s) = min_score {
|
|
config.output.min_score = Some(s);
|
|
}
|
|
|
|
// Min-confidence: CLI wins, then config
|
|
if let Some(ref expr) = min_confidence {
|
|
config.output.min_confidence =
|
|
Some(expr.parse::<crate::evidence::Confidence>().map_err(|e| {
|
|
crate::errors::NyxError::Msg(format!("invalid --min-confidence value: {e}"))
|
|
})?);
|
|
}
|
|
|
|
if require_converged {
|
|
config.output.require_converged = true;
|
|
}
|
|
|
|
if show_all {
|
|
config.output.show_all = true;
|
|
}
|
|
if include_quality {
|
|
config.output.include_quality = true;
|
|
}
|
|
// CLI values override config defaults (clap provides defaults)
|
|
config.output.max_low = max_low;
|
|
config.output.max_low_per_file = max_low_per_file;
|
|
config.output.max_low_per_rule = max_low_per_rule;
|
|
config.output.rollup_examples = rollup_examples;
|
|
|
|
// ── Analysis engine toggles: resolve CLI → config ───────────
|
|
// Each pair is a tri-state (flag set ⇒ true, no-flag set ⇒ false,
|
|
// neither ⇒ inherit config default).
|
|
//
|
|
// Application order: profile first (wholesale reset), then
|
|
// individual flags layered on top so users can mix a profile
|
|
// with a targeted override (e.g. `--engine-profile fast
|
|
// --backwards-analysis`).
|
|
let mut engine = config.analysis.engine;
|
|
if let Some(ref prof) = engine_profile {
|
|
engine = prof.apply(engine);
|
|
}
|
|
if constraint_solving {
|
|
engine.constraint_solving = true;
|
|
}
|
|
if no_constraint_solving {
|
|
engine.constraint_solving = false;
|
|
}
|
|
if abstract_interp {
|
|
engine.abstract_interpretation = true;
|
|
}
|
|
if no_abstract_interp {
|
|
engine.abstract_interpretation = false;
|
|
}
|
|
if context_sensitive {
|
|
engine.context_sensitive = true;
|
|
}
|
|
if no_context_sensitive {
|
|
engine.context_sensitive = false;
|
|
}
|
|
if symex {
|
|
engine.symex.enabled = true;
|
|
}
|
|
if no_symex {
|
|
engine.symex.enabled = false;
|
|
}
|
|
if cross_file_symex {
|
|
engine.symex.cross_file = true;
|
|
}
|
|
if no_cross_file_symex {
|
|
engine.symex.cross_file = false;
|
|
}
|
|
if symex_interproc {
|
|
engine.symex.interprocedural = true;
|
|
}
|
|
if no_symex_interproc {
|
|
engine.symex.interprocedural = false;
|
|
}
|
|
if smt {
|
|
engine.symex.smt = true;
|
|
}
|
|
if no_smt {
|
|
engine.symex.smt = false;
|
|
}
|
|
if backwards_analysis {
|
|
engine.backwards_analysis = true;
|
|
}
|
|
if no_backwards_analysis {
|
|
engine.backwards_analysis = false;
|
|
}
|
|
if let Some(ms) = parse_timeout_ms {
|
|
engine.parse_timeout_ms = ms;
|
|
}
|
|
if let Some(n) = max_origins {
|
|
engine.max_origins = n.max(crate::utils::analysis_options::MIN_MAX_ORIGINS);
|
|
}
|
|
if let Some(n) = max_pointsto {
|
|
engine.max_pointsto = n.max(crate::utils::analysis_options::MIN_MAX_POINTSTO);
|
|
}
|
|
config.analysis.engine = engine;
|
|
if engine.parse_timeout_ms == 0 {
|
|
tracing::warn!(
|
|
"parse_timeout_ms = 0 disables tree-sitter parse timeout entirely; \
|
|
this is unsafe for untrusted input."
|
|
);
|
|
}
|
|
if !crate::utils::analysis_options::install(engine) {
|
|
tracing::warn!(
|
|
"analysis-engine runtime already installed; CLI engine flags ignored"
|
|
);
|
|
}
|
|
|
|
// ── --explain-engine: print resolved config and exit ────────
|
|
if explain_engine {
|
|
print_engine_explanation(config, engine_profile);
|
|
return Ok(());
|
|
}
|
|
|
|
let effective_format = format.unwrap_or(config.output.default_format);
|
|
|
|
scan::handle(
|
|
&path,
|
|
effective_index,
|
|
effective_format,
|
|
severity_filter,
|
|
fail_on_sev,
|
|
show_suppressed,
|
|
show_instances.as_deref(),
|
|
database_dir,
|
|
config,
|
|
)?;
|
|
}
|
|
Commands::Index { action } => {
|
|
install_from_config(config);
|
|
index::handle(action, database_dir, config)?;
|
|
}
|
|
Commands::List { verbose } => {
|
|
list::handle(verbose, database_dir)?;
|
|
}
|
|
Commands::Clean { project, all } => {
|
|
clean::handle(project, all, database_dir)?;
|
|
}
|
|
Commands::Config { action } => {
|
|
use crate::cli::ConfigAction;
|
|
match action {
|
|
ConfigAction::Show { all } => self::config::show(config, all)?,
|
|
ConfigAction::Path => self::config::path(config_dir)?,
|
|
ConfigAction::AddRule {
|
|
lang,
|
|
matcher,
|
|
kind,
|
|
cap,
|
|
} => self::config::add_rule(config_dir, &lang, &matcher, &kind, &cap)?,
|
|
ConfigAction::AddTerminator { lang, name } => {
|
|
self::config::add_terminator(config_dir, &lang, &name)?
|
|
}
|
|
}
|
|
}
|
|
Commands::Serve {
|
|
path,
|
|
port,
|
|
host,
|
|
no_browser,
|
|
} => {
|
|
install_from_config(config);
|
|
#[cfg(feature = "serve")]
|
|
{
|
|
serve::handle(
|
|
&path,
|
|
port,
|
|
host.as_deref(),
|
|
no_browser,
|
|
config_dir,
|
|
database_dir,
|
|
config,
|
|
)?;
|
|
}
|
|
#[cfg(not(feature = "serve"))]
|
|
{
|
|
let _ = (path, port, host, no_browser);
|
|
return Err(crate::errors::NyxError::Msg(
|
|
"The `serve` feature is not enabled. Rebuild with `cargo build --features serve`.".into(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Pretty-print the effective analysis-engine configuration for
|
|
/// `nyx scan --explain-engine`. Writes to stdout so it composes with
|
|
/// standard shell redirection and process substitution.
|
|
fn print_engine_explanation(config: &Config, engine_profile: Option<EngineProfile>) {
|
|
use console::style;
|
|
|
|
// Plain-text on/off, padded to 3 chars so the trailing column aligns
|
|
// regardless of which value is rendered. Colour is layered on top ,
|
|
// the visible width stays 3 characters because `console::style` emits
|
|
// zero-width ANSI codes (and nothing at all when NO_COLOR is set).
|
|
fn onoff(b: bool) -> String {
|
|
if b {
|
|
style("on ").green().to_string()
|
|
} else {
|
|
style("off").red().dim().to_string()
|
|
}
|
|
}
|
|
|
|
let engine = config.analysis.engine;
|
|
let scanner = &config.scanner;
|
|
let profile_label = engine_profile
|
|
.map(|p| p.to_string())
|
|
.unwrap_or_else(|| "(none, using config defaults)".to_string());
|
|
let smt_compiled = cfg!(feature = "smt");
|
|
let pipeline_on = matches!(
|
|
config.scanner.mode,
|
|
AnalysisMode::Full | AnalysisMode::Cfg | AnalysisMode::Taint
|
|
);
|
|
|
|
// Layout: 2sp + label (left-aligned, 24w) + space + value + 3sp + flag info.
|
|
// Label width 24 fits the longest entry ("Abstract interpretation:") with
|
|
// a single trailing space before the value column. Numeric rows reuse
|
|
// the same alignment so the value column is consistent across sections.
|
|
let row_flag = |label: &str, on: bool, flags: &str| {
|
|
println!(
|
|
" {:<24} {} {}",
|
|
format!("{label}:"),
|
|
onoff(on),
|
|
style(flags).dim()
|
|
);
|
|
};
|
|
let row_plain = |label: &str, value: &str| {
|
|
println!(" {:<24} {}", format!("{label}:"), value);
|
|
};
|
|
let row_num = |label: &str, value: String, flags: &str| {
|
|
println!(
|
|
" {:<24} {:<10} {}",
|
|
format!("{label}:"),
|
|
value,
|
|
style(flags).dim()
|
|
);
|
|
};
|
|
let section = |title: &str| {
|
|
println!();
|
|
println!(" {}", style(title).cyan().bold());
|
|
};
|
|
|
|
println!("{}", style("Effective engine configuration").white().bold());
|
|
println!(
|
|
" {:<24} {}",
|
|
"Engine profile:",
|
|
style(&profile_label).bold()
|
|
);
|
|
|
|
section("Pipeline");
|
|
row_plain("AST patterns", &onoff(true));
|
|
row_plain("CFG construction", &onoff(pipeline_on));
|
|
row_plain("CFG analysis", &onoff(pipeline_on));
|
|
row_plain("Taint (SSA)", &onoff(pipeline_on));
|
|
row_plain("State analysis", &onoff(scanner.enable_state_analysis));
|
|
row_plain("Auth analysis", &onoff(scanner.enable_auth_analysis));
|
|
|
|
section("Engine toggles");
|
|
row_flag(
|
|
"Abstract interpretation",
|
|
engine.abstract_interpretation,
|
|
"--abstract-interp / NYX_ABSTRACT_INTERP",
|
|
);
|
|
row_flag(
|
|
"Context sensitivity",
|
|
engine.context_sensitive,
|
|
"--context-sensitive / NYX_CONTEXT_SENSITIVE (k=1)",
|
|
);
|
|
row_flag(
|
|
"Constraint solving",
|
|
engine.constraint_solving,
|
|
"--constraint-solving / NYX_CONSTRAINT",
|
|
);
|
|
// Backwards-taint label and value column kept exact-width-compatible
|
|
// with the legacy format so external scripts grepping for
|
|
// "Backwards taint: on" continue to match. The label slot is
|
|
// 24 chars + 1 space → column 25, which lines up with that legacy
|
|
// 9-space gap after "Backwards taint:" (16 chars).
|
|
row_flag(
|
|
"Backwards taint",
|
|
engine.backwards_analysis,
|
|
"--backwards-analysis / NYX_BACKWARDS",
|
|
);
|
|
|
|
section("Symbolic execution");
|
|
row_flag("Symex", engine.symex.enabled, "--symex / NYX_SYMEX");
|
|
row_flag(
|
|
"Cross-file symex",
|
|
engine.symex.cross_file,
|
|
"--cross-file-symex / NYX_CROSS_FILE_SYMEX",
|
|
);
|
|
row_flag(
|
|
"Interproc symex",
|
|
engine.symex.interprocedural,
|
|
"--symex-interproc / NYX_SYMEX_INTERPROC",
|
|
);
|
|
let smt_note = if smt_compiled {
|
|
"--smt"
|
|
} else {
|
|
"--smt (this binary built without `smt` feature)"
|
|
};
|
|
row_flag("SMT (Z3)", engine.symex.smt && smt_compiled, smt_note);
|
|
|
|
section("Limits");
|
|
row_num(
|
|
"Parse timeout",
|
|
format!("{} ms", engine.parse_timeout_ms),
|
|
"--parse-timeout-ms / NYX_PARSE_TIMEOUT_MS (0 disables)",
|
|
);
|
|
row_num(
|
|
"Max taint origins",
|
|
engine.max_origins.to_string(),
|
|
"--max-origins / NYX_MAX_ORIGINS (per-lattice-value cap)",
|
|
);
|
|
row_num(
|
|
"Max points-to set",
|
|
engine.max_pointsto.to_string(),
|
|
"--max-pointsto / NYX_MAX_POINTSTO (per-variable heap cap)",
|
|
);
|
|
println!();
|
|
}
|