nyx/src/utils/config.rs

1788 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::cli::OutputFormat;
use crate::errors::NyxResult;
use crate::labels::Cap;
use crate::patterns::Severity;
use console::style;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use toml;
static DEFAULT_CONFIG_TOML: &str = include_str!("../../default-nyx.conf");
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AnalysisMode {
#[default]
Full,
Ast,
Cfg,
Taint,
}
/// The kind of a custom label rule: source, sanitizer, or sink.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleKind {
Source,
Sanitizer,
Sink,
}
impl fmt::Display for RuleKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Source => write!(f, "source"),
Self::Sanitizer => write!(f, "sanitizer"),
Self::Sink => write!(f, "sink"),
}
}
}
impl FromStr for RuleKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"source" => Ok(Self::Source),
"sanitizer" => Ok(Self::Sanitizer),
"sink" => Ok(Self::Sink),
_ => Err(format!(
"invalid rule kind: {s:?} (expected source, sanitizer, sink)"
)),
}
}
}
/// Named capability for a custom label rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapName {
EnvVar,
HtmlEscape,
ShellEscape,
UrlEncode,
JsonParse,
FileIo,
FmtString,
SqlQuery,
Deserialize,
Ssrf,
CodeExec,
Crypto,
/// Request-bound identifier not yet ownership-checked.
UnauthorizedId,
DataExfil,
LdapInjection,
XpathInjection,
HeaderInjection,
OpenRedirect,
Ssti,
Xxe,
PrototypePollution,
All,
}
impl CapName {
/// Convert to the corresponding `Cap` bitflag.
pub fn to_cap(self) -> Cap {
match self {
Self::EnvVar => Cap::ENV_VAR,
Self::HtmlEscape => Cap::HTML_ESCAPE,
Self::ShellEscape => Cap::SHELL_ESCAPE,
Self::UrlEncode => Cap::URL_ENCODE,
Self::JsonParse => Cap::JSON_PARSE,
Self::FileIo => Cap::FILE_IO,
Self::FmtString => Cap::FMT_STRING,
Self::SqlQuery => Cap::SQL_QUERY,
Self::Deserialize => Cap::DESERIALIZE,
Self::Ssrf => Cap::SSRF,
Self::CodeExec => Cap::CODE_EXEC,
Self::Crypto => Cap::CRYPTO,
Self::UnauthorizedId => Cap::UNAUTHORIZED_ID,
Self::DataExfil => Cap::DATA_EXFIL,
Self::LdapInjection => Cap::LDAP_INJECTION,
Self::XpathInjection => Cap::XPATH_INJECTION,
Self::HeaderInjection => Cap::HEADER_INJECTION,
Self::OpenRedirect => Cap::OPEN_REDIRECT,
Self::Ssti => Cap::SSTI,
Self::Xxe => Cap::XXE,
Self::PrototypePollution => Cap::PROTOTYPE_POLLUTION,
Self::All => Cap::all(),
}
}
}
impl fmt::Display for CapName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EnvVar => write!(f, "env_var"),
Self::HtmlEscape => write!(f, "html_escape"),
Self::ShellEscape => write!(f, "shell_escape"),
Self::UrlEncode => write!(f, "url_encode"),
Self::JsonParse => write!(f, "json_parse"),
Self::FileIo => write!(f, "file_io"),
Self::FmtString => write!(f, "fmt_string"),
Self::SqlQuery => write!(f, "sql_query"),
Self::Deserialize => write!(f, "deserialize"),
Self::Ssrf => write!(f, "ssrf"),
Self::CodeExec => write!(f, "code_exec"),
Self::Crypto => write!(f, "crypto"),
Self::UnauthorizedId => write!(f, "unauthorized_id"),
Self::DataExfil => write!(f, "data_exfil"),
Self::LdapInjection => write!(f, "ldap_injection"),
Self::XpathInjection => write!(f, "xpath_injection"),
Self::HeaderInjection => write!(f, "header_injection"),
Self::OpenRedirect => write!(f, "open_redirect"),
Self::Ssti => write!(f, "ssti"),
Self::Xxe => write!(f, "xxe"),
Self::PrototypePollution => write!(f, "prototype_pollution"),
Self::All => write!(f, "all"),
}
}
}
impl FromStr for CapName {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"env_var" => Ok(Self::EnvVar),
"html_escape" => Ok(Self::HtmlEscape),
"shell_escape" => Ok(Self::ShellEscape),
"url_encode" => Ok(Self::UrlEncode),
"json_parse" => Ok(Self::JsonParse),
"file_io" => Ok(Self::FileIo),
"fmt_string" => Ok(Self::FmtString),
"sql_query" => Ok(Self::SqlQuery),
"deserialize" => Ok(Self::Deserialize),
"ssrf" => Ok(Self::Ssrf),
"code_exec" => Ok(Self::CodeExec),
"crypto" => Ok(Self::Crypto),
"unauthorized_id" => Ok(Self::UnauthorizedId),
"data_exfil" | "data_exfiltration" => Ok(Self::DataExfil),
"ldap_injection" | "ldapi" => Ok(Self::LdapInjection),
"xpath_injection" | "xpathi" => Ok(Self::XpathInjection),
"header_injection" | "crlf" | "response_splitting" => Ok(Self::HeaderInjection),
"open_redirect" | "redirect" => Ok(Self::OpenRedirect),
"ssti" | "template_injection" => Ok(Self::Ssti),
"xxe" => Ok(Self::Xxe),
"prototype_pollution" | "proto_pollution" => Ok(Self::PrototypePollution),
"all" => Ok(Self::All),
_ => Err(format!(
"invalid cap name: {s:?} (expected env_var, html_escape, shell_escape, \
url_encode, json_parse, file_io, fmt_string, sql_query, deserialize, \
ssrf, code_exec, crypto, unauthorized_id, data_exfil, ldap_injection, \
xpath_injection, header_injection, open_redirect, ssti, xxe, \
prototype_pollution, all)"
)),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct ScannerConfig {
/// The analysis mode to use.
pub mode: AnalysisMode,
/// The minimum severity level to output
pub min_severity: Severity,
/// The maximum file size to scan, in megabytes.
pub max_file_size_mb: Option<u64>,
/// File extensions to exclude from scanning.
pub excluded_extensions: Vec<String>,
/// Directories to exclude from scanning.
pub excluded_directories: Vec<String>,
/// Excluded files
pub excluded_files: Vec<String>,
/// RESERVED: not yet wired to walker. Whether to respect the global ignore file.
pub read_global_ignore: bool,
/// Whether to respect VCS ignore files (`.gitignore`, ..) or not.
pub read_vcsignore: bool,
/// Whether to require a `.git` directory to respect gitignore files.
pub require_git_to_read_vcsignore: bool,
/// Whether to limit the search to starting file system or not.
pub one_file_system: bool,
/// Whether to follow symlinks or not.
pub follow_symlinks: bool,
/// Whether to scan hidden files or not.
pub scan_hidden_files: bool,
/// Whether to include findings from non-production paths (tests, vendor,
/// benchmarks, etc.) at their original severity. When false (default),
/// findings in these paths are downgraded by one severity tier.
pub include_nonprod: bool,
/// Enable the state-model dataflow engine for resource lifecycle and
/// auth-state analysis. Default: true.
pub enable_state_analysis: bool,
/// Enable auth-state analysis within the state engine. When false,
/// only resource lifecycle findings (leak, use-after-close, double-close)
/// are produced. Default: true.
pub enable_auth_analysis: bool,
/// When true, per-file panics during analysis are caught and logged
/// as warnings; the scan continues with the remaining files. Default
/// false: a panic aborts the scan, preserving existing behaviour for
/// users who want to catch engine bugs loudly.
pub enable_panic_recovery: bool,
/// Fold `auth_analysis` into the SSA/taint engine using the
/// `Cap::UNAUTHORIZED_ID` cap. When true, request-bound handler
/// parameters seed `UNAUTHORIZED_ID` into the taint state and a
/// complementary set of sink / sanitizer rules participates in the
/// flow. Default `false` while the standalone `auth_analysis`
/// subsystem still carries the stable detection; flipping to `true`
/// enables the taint-based path alongside it.
pub enable_auth_as_taint: bool,
/// 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
/// `Evidence::dynamic_verdict`. Use `--no-verify` (CLI) or set
/// `verify = false` in `nyx.toml` to disable.
///
/// Requires the binary to be built with `--features dynamic`; without
/// that feature the setting has no effect.
///
/// Migration note: existing `nyx.toml` files that already set
/// `verify = false` keep the opt-out behaviour; only the inherited
/// default changes.
#[serde(default = "default_verify")]
pub verify: bool,
/// Extend dynamic verification to findings below `Confidence::Medium`.
///
/// By default only `Confidence >= Medium` findings are verified
/// (§5.1). Set this to `true` (or pass `--verify-all-confidence`)
/// to also verify `Low`-confidence findings. Intended for
/// backfill / corpus-building runs, not production scans.
#[serde(default)]
pub verify_all_confidence: bool,
/// Sandbox backend for dynamic verification.
///
/// `"auto"` (default): docker when available, else process.
/// `"docker"`: require docker; fail if unavailable.
/// `"process"`: in-process runner (same as `--unsafe-sandbox`).
#[serde(default = "default_verify_backend")]
pub verify_backend: String,
}
fn default_verify() -> bool {
true
}
fn default_verify_backend() -> String {
"auto".to_owned()
}
impl Default for ScannerConfig {
fn default() -> Self {
Self {
mode: AnalysisMode::Full,
min_severity: Severity::Low,
max_file_size_mb: Some(16),
excluded_extensions: vec![
"jpg", "png", "gif", "mp4", "avi", "mkv", "zip", "tar", "gz", "exe", "dll", "so",
]
.into_iter()
.map(str::to_owned)
.collect(),
excluded_directories: vec![
"node_modules",
".git",
"target",
".vscode",
".idea",
"build",
"dist",
]
.into_iter()
.map(str::to_owned)
.collect(),
excluded_files: vec![].into_iter().map(str::to_owned).collect(),
read_global_ignore: false,
read_vcsignore: true,
require_git_to_read_vcsignore: true,
one_file_system: false,
follow_symlinks: false,
scan_hidden_files: false,
include_nonprod: false,
enable_state_analysis: true,
enable_auth_analysis: true,
enable_panic_recovery: false,
enable_auth_as_taint: false,
verify: true,
verify_all_confidence: false,
verify_backend: "auto".to_owned(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct DatabaseConfig {
/// RESERVED: custom database path (not yet wired; DB path is computed from project info).
pub path: String,
/// RESERVED: auto-cleanup not yet implemented. Days to keep database files.
pub auto_cleanup_days: u32,
/// RESERVED: size limit not yet implemented. Maximum database size in MiB.
pub max_db_size_mb: u64,
/// Whether to run a VACUUM on startup.
pub vacuum_on_startup: bool,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: String::from(""),
auto_cleanup_days: 30,
max_db_size_mb: 1024,
vacuum_on_startup: false,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct OutputConfig {
/// The default output format.
pub default_format: OutputFormat,
/// Whether to print anything to the console or not.
pub quiet: bool,
/// The maximum number of results to show.
pub max_results: Option<u32>,
/// Enable attack-surface ranking to sort findings by exploitability.
pub attack_surface_ranking: bool,
/// Minimum attack-surface score to include in output.
/// Findings below this threshold are dropped after ranking.
/// `None` means no minimum (all findings shown).
pub min_score: Option<u32>,
/// Minimum confidence level to include in output.
/// `None` means no minimum (all findings shown).
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_confidence_opt"
)]
pub min_confidence: Option<crate::evidence::Confidence>,
/// Drop findings emitted from non-converged analysis.
///
/// When `true`, findings whose engine provenance notes include any
/// `OverReport` (widening) or `Bail` (lowering/parse failure)
/// direction are filtered out before output. `UnderReport`
/// findings, where the result set is a lower bound but each
/// emitted flow is still real, are kept.
///
/// Surfaced via `--require-converged`; intended for strict CI
/// gating where a finding from capped analysis is worse than no
/// finding.
#[serde(default)]
pub require_converged: bool,
/// Include Quality-category findings (excluded by default).
#[serde(default)]
pub include_quality: bool,
/// Show all findings: disables category filtering, rollups, and LOW budgets.
#[serde(default)]
pub show_all: bool,
/// Maximum total LOW findings to show.
#[serde(default = "default_max_low")]
pub max_low: u32,
/// Maximum LOW findings per file.
#[serde(default = "default_max_low_per_file")]
pub max_low_per_file: u32,
/// Maximum LOW findings per rule.
#[serde(default = "default_max_low_per_rule")]
pub max_low_per_rule: u32,
/// Number of example locations to store in rollup findings.
#[serde(default = "default_rollup_examples")]
pub rollup_examples: u32,
}
fn default_max_low() -> u32 {
20
}
fn default_max_low_per_file() -> u32 {
1
}
fn default_max_low_per_rule() -> u32 {
10
}
fn default_rollup_examples() -> u32 {
5
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
default_format: OutputFormat::Console,
quiet: false,
max_results: None,
attack_surface_ranking: true,
min_score: None,
min_confidence: None,
require_converged: false,
include_quality: false,
show_all: false,
max_low: 20,
max_low_per_file: 1,
max_low_per_rule: 10,
rollup_examples: 5,
}
}
}
/// Deserialize an optional Confidence from a TOML string.
fn deserialize_confidence_opt<'de, D>(
deserializer: D,
) -> Result<Option<crate::evidence::Confidence>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(s) => s
.parse::<crate::evidence::Confidence>()
.map(Some)
.map_err(serde::de::Error::custom),
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct PerformanceConfig {
/// The maximum search depth, or `None` if no maximum search depth should be set.
///
/// A depth of `1` includes all files under the current directory, a depth of `2` also includes
/// all files under subdirectories of the current directory, etc.
pub max_depth: Option<usize>,
/// RESERVED: not yet wired to walker. Minimum depth for reported entries.
pub min_depth: Option<usize>,
/// RESERVED: not yet wired to walker. Stop traversing into matching directories.
pub prune: bool,
/// The maximum number of worker threads to use, or `None` to auto-detect.
pub worker_threads: Option<usize>,
/// The maximum number of entries to index in a single chunk.
pub batch_size: usize,
/// Channel capacity = threads × this.
pub channel_multiplier: usize,
/// The stack size for Rayon threads, in bytes.
pub rayon_thread_stack_size: usize,
/// RESERVED: per-file timeout not yet implemented. Timeout in seconds.
pub scan_timeout_secs: Option<u64>,
/// RESERVED: memory limit not yet implemented. Maximum memory in MiB.
pub memory_limit_mb: u64,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
max_depth: None,
min_depth: None,
prune: false,
worker_threads: None,
batch_size: 100usize,
channel_multiplier: 4usize,
rayon_thread_stack_size: 8 * 1024 * 1024, // 8 MiB
scan_timeout_secs: None,
memory_limit_mb: 512,
}
}
}
/// A single user-defined label rule from config.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ConfigLabelRule {
pub matchers: Vec<String>,
/// Rule kind: source, sanitizer, or sink.
pub kind: RuleKind,
/// Capability name (e.g. html_escape, sql_query, all).
pub cap: CapName,
#[serde(default)]
pub case_sensitive: bool,
}
/// Per-language analysis configuration from config file.
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct LanguageAnalysisConfig {
pub rules: Vec<ConfigLabelRule>,
pub terminators: Vec<String>,
pub event_handlers: Vec<String>,
pub auth: AuthAnalysisConfig,
}
fn default_auth_enabled() -> bool {
true
}
/// Per-language authorization-analysis configuration from config file.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(default)]
pub struct AuthAnalysisConfig {
pub enabled: bool,
pub admin_path_patterns: Vec<String>,
pub admin_guard_names: Vec<String>,
pub login_guard_names: Vec<String>,
/// Typed-extractor wrapper names that prove the request passed
/// route-level capability/policy enforcement (e.g. meilisearch's
/// `GuardedData<ActionPolicy<X>, _>`). Per-language defaults set
/// in `auth_analysis::config::build_auth_rules`; user nyx.toml
/// entries are appended. Distinct from `login_guard_names` so the
/// pattern (matched as last-segment + case-insensitive
/// `starts_with`) doesn't pollute regular call recognition.
#[serde(default)]
pub policy_guard_names: Vec<String>,
pub authorization_check_names: Vec<String>,
pub mutation_indicator_names: Vec<String>,
pub read_indicator_names: Vec<String>,
pub token_lookup_names: Vec<String>,
pub token_expiry_fields: Vec<String>,
pub token_recipient_fields: Vec<String>,
/// Types whose instances should never be treated as auth sinks
/// (e.g. `HashMap`, `HashSet`, `Vec`). When a `let` binding's RHS
/// constructs one of these, or an explicit type annotation names
/// one, the bound variable is tagged as non-sink and method calls
/// on it (`map.insert`, `vec.push`, …) are not classified as
/// Read/Mutation operations.
pub non_sink_receiver_types: Vec<String>,
/// Variable-name prefixes that strongly imply a local/in-memory
/// collection, used as a fallback when the type cannot be
/// resolved (e.g. `visited`, `seen`, `counts`). Matched against
/// the first segment of the callee receiver chain.
pub non_sink_receiver_name_prefixes: Vec<String>,
/// Built-in / framework receivers whose first-segment, when
/// matched exactly (case-sensitive), classifies the call as
/// inherently non-data-layer. Used for browser/DOM globals
/// (`document`, `window`, `localStorage`, ...) and stdlib helpers
/// (`Math`, `JSON`, `Date`). Defaults are per-language in
/// `auth_analysis::config::build_auth_rules`; user nyx.toml
/// entries are appended.
#[serde(default)]
pub non_sink_global_receivers: Vec<String>,
/// Method-name allowlist: when the LAST segment of a callee
/// matches (case-sensitive exact), the call is classified as
/// non-sink regardless of receiver. Used for DOM-API methods
/// (`addEventListener`, `getElementById`, `appendChild`, ...).
#[serde(default)]
pub non_sink_method_names: Vec<String>,
/// Receiver-chain first-segment prefixes that classify a call as
/// a realtime publish / broadcast sink (pub/sub bus, websocket
/// channel, event stream). Treated as cross-tenant by default
/// and gated by the ownership check.
pub realtime_receiver_prefixes: Vec<String>,
/// Receiver-chain first-segment prefixes that classify a call as
/// an outbound network sink (HTTP client, RPC caller, webhook
/// dispatcher).
pub outbound_network_receiver_prefixes: Vec<String>,
/// Receiver-chain first-segment prefixes that classify a call as
/// a cross-tenant cache access (Redis / memcache / distributed
/// KV client).
pub cache_receiver_prefixes: Vec<String>,
/// SQL ACL tables. When a literal `SELECT … FROM <T> JOIN <ACL>`
/// query pins rows via `WHERE <ACL>.user_id = ?N`, every returned
/// row is membership-gated and downstream uses of its columns do
/// not need an ownership check. Defaults are set per-language in
/// `auth_analysis::config::build_auth_rules`.
pub acl_tables: Vec<String>,
/// Callee names that, when they appear as the chain root of a
/// chained-call shape (`select(X).filter_by(...)`,
/// `query(X).filter(...)`), anchor the trailing method as a DB
/// query-builder operation. Used to override the chained-call
/// suppression in `classify_sink_class` for SQLAlchemy / similar
/// query-builder idioms whose first call returns an opaque builder
/// object the type tracker cannot resolve. Defaults set per
/// language in `auth_analysis::config::build_auth_rules`.
#[serde(default)]
pub db_query_builder_roots: Vec<String>,
}
impl Default for AuthAnalysisConfig {
fn default() -> Self {
Self {
enabled: default_auth_enabled(),
admin_path_patterns: Vec::new(),
admin_guard_names: Vec::new(),
login_guard_names: Vec::new(),
policy_guard_names: Vec::new(),
authorization_check_names: Vec::new(),
mutation_indicator_names: Vec::new(),
read_indicator_names: Vec::new(),
token_lookup_names: Vec::new(),
token_expiry_fields: Vec::new(),
token_recipient_fields: Vec::new(),
non_sink_receiver_types: Vec::new(),
non_sink_receiver_name_prefixes: Vec::new(),
non_sink_global_receivers: Vec::new(),
non_sink_method_names: Vec::new(),
realtime_receiver_prefixes: Vec::new(),
outbound_network_receiver_prefixes: Vec::new(),
cache_receiver_prefixes: Vec::new(),
acl_tables: Vec::new(),
db_query_builder_roots: Vec::new(),
}
}
}
/// Top-level analysis rules config, keyed by language slug.
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct AnalysisRulesConfig {
pub languages: HashMap<String, LanguageAnalysisConfig>,
/// Rule IDs that have been disabled via the UI.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled_rules: Vec<String>,
/// Engine-pass toggles (constraint solving, abstract interpretation,
/// symex pipeline, parse timeout). Exposed as `[analysis.engine]` in
/// TOML; see [`crate::utils::AnalysisOptions`].
#[serde(default)]
pub engine: crate::utils::AnalysisOptions,
}
/// Configuration for the local web UI server (`nyx serve`).
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct ServerConfig {
/// Whether the serve command is enabled.
pub enabled: bool,
/// Host to bind to (localhost by default for security).
pub host: String,
/// Port to bind to.
pub port: u16,
/// Open browser automatically when serve starts.
pub open_browser: bool,
/// Auto-reload UI when scan results change.
pub auto_reload: bool,
/// Persist scan runs for history view.
pub persist_runs: bool,
/// Maximum number of saved runs to keep.
pub max_saved_runs: u32,
/// Auto-sync triage decisions to `.nyx/triage.json` in the project root.
/// When enabled, triage changes are written to this file so they can be
/// committed to git and shared across team members.
pub triage_sync: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
enabled: true,
host: "127.0.0.1".into(),
port: 9700,
open_browser: true,
auto_reload: true,
persist_runs: true,
max_saved_runs: 50,
triage_sync: true,
}
}
}
/// Configuration for scan run persistence and history.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct RunsConfig {
/// Whether to persist scan run history to disk.
pub persist: bool,
/// Maximum number of runs to keep.
pub max_runs: u32,
/// Save scan logs with each run.
pub save_logs: bool,
/// Save stdout capture with each run.
pub save_stdout: bool,
/// Save code snippets in findings.
pub save_code_snippets: bool,
}
impl Default for RunsConfig {
fn default() -> Self {
Self {
persist: false,
max_runs: 100,
save_logs: false,
save_stdout: false,
save_code_snippets: true,
}
}
}
/// A named scan profile, a partial overlay of scan-related settings.
/// All fields are `Option<T>`: `None` means "don't override".
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(default)]
pub struct ScanProfile {
pub mode: Option<AnalysisMode>,
pub min_severity: Option<Severity>,
pub max_file_size_mb: Option<u64>,
pub include_nonprod: Option<bool>,
pub enable_state_analysis: Option<bool>,
pub enable_auth_analysis: Option<bool>,
pub default_format: Option<OutputFormat>,
pub quiet: Option<bool>,
pub attack_surface_ranking: Option<bool>,
pub max_results: Option<u32>,
pub min_score: Option<u32>,
pub show_all: Option<bool>,
pub include_quality: Option<bool>,
pub worker_threads: Option<usize>,
pub max_depth: Option<usize>,
}
/// Built-in profile definitions.
fn builtin_profile(name: &str) -> Option<ScanProfile> {
Some(match name {
"quick" => ScanProfile {
mode: Some(AnalysisMode::Ast),
min_severity: Some(Severity::Medium),
..Default::default()
},
"full" => ScanProfile {
mode: Some(AnalysisMode::Full),
min_severity: Some(Severity::Low),
enable_state_analysis: Some(true),
enable_auth_analysis: Some(true),
..Default::default()
},
"ci" => ScanProfile {
mode: Some(AnalysisMode::Full),
min_severity: Some(Severity::Medium),
quiet: Some(true),
default_format: Some(OutputFormat::Sarif),
..Default::default()
},
"taint_only" => ScanProfile {
mode: Some(AnalysisMode::Taint),
..Default::default()
},
"conservative_large_repo" => ScanProfile {
mode: Some(AnalysisMode::Ast),
min_severity: Some(Severity::High),
max_file_size_mb: Some(5),
max_depth: Some(10),
..Default::default()
},
_ => return None,
})
}
/// Top-level scanner configuration.
///
/// Loaded from `nyx.conf` (TOML) via [`Config::load`], or constructed in
/// code for embedded use. [`Config::default`] gives conservative defaults:
/// no symlink following, no hidden files, gitignore respected, 10 s parse
/// timeout, all analysis passes on.
///
/// Config sections mirror `nyx.conf` sections:
/// - [`scanner`](Config::scanner): what files to scan, which analysis passes
/// to enable, severity floor
/// - [`output`](Config::output): format, ranking, LOW-finding budgets
/// - [`analysis`](Config::analysis): per-language rules, engine-pass toggles
/// - [`performance`](Config::performance): thread count, depth limit, batch
/// size
/// - [`database`](Config::database): incremental index settings
/// - [`detectors`](Config::detectors): per-detector sensitivity knobs
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub scanner: ScannerConfig,
pub database: DatabaseConfig,
pub output: OutputConfig,
pub performance: PerformanceConfig,
pub analysis: AnalysisRulesConfig,
/// Per-detector knobs ([detectors.*] in nyx.conf). Currently exposes
/// `[detectors.data_exfil]` for cross-boundary leak suppression.
#[serde(default)]
pub detectors: crate::utils::detector_options::DetectorOptions,
pub server: ServerConfig,
pub runs: RunsConfig,
pub profiles: HashMap<String, ScanProfile>,
/// Detected frameworks for the current project, set by the scan pipeline,
/// not persisted to config files.
#[serde(skip)]
pub framework_ctx: Option<crate::utils::project::FrameworkContext>,
/// TS/JS module resolver state, set by the scan pipeline once per scan
/// after the file walk and before pass 1. `None` outside the scan paths
/// (e.g. unit-test direct callers of `analyse_file_fused`); consumers
/// must treat absence as "no resolver hints available, fall back to
/// pre-resolver behaviour" rather than as a hard error.
#[serde(skip)]
pub module_graph: Option<std::sync::Arc<crate::resolve::ModuleGraph>>,
}
impl Config {
/// Load config and return `(config, optional_note)`.
///
/// The note is a formatted status message about which config file was
/// loaded (or that defaults are in use). The caller decides whether to
/// print it based on output format / quiet mode.
pub fn load(config_dir: &Path) -> NyxResult<(Self, Option<String>)> {
let mut config = Config::default();
let default_config_path = config_dir.join("nyx.conf");
if !default_config_path.exists() {
create_example_config(config_dir)?;
}
let user_config_path = config_dir.join("nyx.local");
let note = if user_config_path.exists() {
let user_config_content = fs::read_to_string(&user_config_path)?;
let user_config: Config = toml::from_str(&user_config_content)?;
config = merge_configs(config, user_config);
Some(format!(
"{}: Loaded user config from: {}\n",
style("note").green().bold(),
style(user_config_path.display())
.underlined()
.white()
.bold()
))
} else {
Some(format!(
"{}: Using {} configuration.\n Create file in '{}' to customize.\n",
style("note").green().bold(),
style("default").bold(),
style(user_config_path.display())
.underlined()
.white()
.bold()
))
};
config
.validate()
.map_err(crate::errors::NyxError::ConfigValidation)?;
Ok((config, note))
}
/// Resolve a profile by name: user-defined profiles take precedence over built-ins.
pub fn resolve_profile(&self, name: &str) -> Option<ScanProfile> {
self.profiles
.get(name)
.cloned()
.or_else(|| builtin_profile(name))
}
/// Apply a named profile, overlaying its `Some` fields onto this config.
/// Returns an error if the profile is not found.
pub fn apply_profile(&mut self, name: &str) -> NyxResult<()> {
let profile = self.resolve_profile(name).ok_or_else(|| {
crate::errors::NyxError::Msg(format!(
"unknown profile '{name}'. Built-in profiles: quick, full, ci, taint_only, conservative_large_repo"
))
})?;
if let Some(v) = profile.mode {
self.scanner.mode = v;
}
if let Some(v) = profile.min_severity {
self.scanner.min_severity = v;
}
if let Some(v) = profile.max_file_size_mb {
self.scanner.max_file_size_mb = Some(v);
}
if let Some(v) = profile.include_nonprod {
self.scanner.include_nonprod = v;
}
if let Some(v) = profile.enable_state_analysis {
self.scanner.enable_state_analysis = v;
}
if let Some(v) = profile.enable_auth_analysis {
self.scanner.enable_auth_analysis = v;
}
if let Some(v) = profile.default_format {
self.output.default_format = v;
}
if let Some(v) = profile.quiet {
self.output.quiet = v;
}
if let Some(v) = profile.attack_surface_ranking {
self.output.attack_surface_ranking = v;
}
if let Some(v) = profile.max_results {
self.output.max_results = Some(v);
}
if let Some(v) = profile.min_score {
self.output.min_score = Some(v);
}
if let Some(v) = profile.show_all {
self.output.show_all = v;
}
if let Some(v) = profile.include_quality {
self.output.include_quality = v;
}
if let Some(v) = profile.worker_threads {
self.performance.worker_threads = Some(v);
}
if let Some(v) = profile.max_depth {
self.performance.max_depth = Some(v);
}
Ok(())
}
/// Validate semantic invariants after loading/merging.
/// Returns structured errors suitable for display or UI presentation.
pub fn validate(&self) -> Result<(), Vec<crate::errors::ConfigError>> {
use crate::errors::{ConfigError, ConfigErrorKind};
let mut errors = Vec::new();
// --- server ---
if self.server.port == 0 {
errors.push(ConfigError {
section: "server".into(),
field: "port".into(),
message: "port must be 165535".into(),
kind: ConfigErrorKind::OutOfRange,
});
}
if self.server.host.is_empty() {
errors.push(ConfigError {
section: "server".into(),
field: "host".into(),
message: "host must not be empty".into(),
kind: ConfigErrorKind::EmptyRequired,
});
}
if self.server.persist_runs && self.server.max_saved_runs == 0 {
errors.push(ConfigError {
section: "server".into(),
field: "max_saved_runs".into(),
message: "max_saved_runs must be > 0 when persist_runs is true".into(),
kind: ConfigErrorKind::Conflict,
});
}
// --- runs ---
if self.runs.persist && self.runs.max_runs == 0 {
errors.push(ConfigError {
section: "runs".into(),
field: "max_runs".into(),
message: "max_runs must be > 0 when persist is true".into(),
kind: ConfigErrorKind::Conflict,
});
}
// --- performance ---
if self.performance.batch_size == 0 {
errors.push(ConfigError {
section: "performance".into(),
field: "batch_size".into(),
message: "batch_size must be > 0".into(),
kind: ConfigErrorKind::OutOfRange,
});
}
if self.performance.channel_multiplier == 0 {
errors.push(ConfigError {
section: "performance".into(),
field: "channel_multiplier".into(),
message: "channel_multiplier must be > 0".into(),
kind: ConfigErrorKind::OutOfRange,
});
}
// --- output ---
if self.output.rollup_examples == 0 {
errors.push(ConfigError {
section: "output".into(),
field: "rollup_examples".into(),
message: "rollup_examples must be > 0".into(),
kind: ConfigErrorKind::OutOfRange,
});
}
// --- profiles ---
for name in self.profiles.keys() {
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
errors.push(ConfigError {
section: "profiles".into(),
field: name.clone(),
message: format!(
"profile name '{name}' must contain only alphanumeric characters and underscores"
),
kind: ConfigErrorKind::InvalidValue,
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
fn create_example_config(config_dir: &Path) -> NyxResult<()> {
let example_path = config_dir.join("nyx.conf");
if !example_path.exists() {
fs::write(&example_path, DEFAULT_CONFIG_TOML)?;
tracing::debug!("Example config created at: {}", example_path.display());
}
Ok(())
}
/// Merge user config into default config, preserving defaults where the user didn't
/// supply new exclusions and overriding everything else.
pub(crate) fn merge_configs(mut default: Config, user: Config) -> Config {
// --- ScannerConfig ---
default.scanner.mode = user.scanner.mode;
default.scanner.min_severity = user.scanner.min_severity;
default.scanner.max_file_size_mb = user.scanner.max_file_size_mb;
default.scanner.read_global_ignore = user.scanner.read_global_ignore;
default.scanner.read_vcsignore = user.scanner.read_vcsignore;
default.scanner.require_git_to_read_vcsignore = user.scanner.require_git_to_read_vcsignore;
default.scanner.one_file_system = user.scanner.one_file_system;
default.scanner.follow_symlinks = user.scanner.follow_symlinks;
default.scanner.scan_hidden_files = user.scanner.scan_hidden_files;
default.scanner.include_nonprod = user.scanner.include_nonprod;
default.scanner.enable_state_analysis = user.scanner.enable_state_analysis;
default.scanner.enable_auth_analysis = user.scanner.enable_auth_analysis;
default.scanner.enable_panic_recovery = user.scanner.enable_panic_recovery;
default.scanner.enable_auth_as_taint = user.scanner.enable_auth_as_taint;
// Merge exclusion lists (default ⊔ user), then sort & dedupe
default
.scanner
.excluded_extensions
.extend(user.scanner.excluded_extensions);
default
.scanner
.excluded_directories
.extend(user.scanner.excluded_directories);
default.scanner.excluded_extensions.sort_unstable();
default.scanner.excluded_extensions.dedup();
default.scanner.excluded_directories.sort_unstable();
default.scanner.excluded_directories.dedup();
default
.scanner
.excluded_files
.extend(user.scanner.excluded_files);
default.scanner.excluded_files.sort_unstable();
default.scanner.excluded_files.dedup();
// --- DatabaseConfig ---
default.database.path = user.database.path;
default.database.auto_cleanup_days = user.database.auto_cleanup_days;
default.database.max_db_size_mb = user.database.max_db_size_mb;
default.database.vacuum_on_startup = user.database.vacuum_on_startup;
// --- OutputConfig ---
default.output.default_format = user.output.default_format;
default.output.quiet = user.output.quiet;
default.output.max_results = user.output.max_results;
default.output.attack_surface_ranking = user.output.attack_surface_ranking;
default.output.min_score = user.output.min_score;
default.output.min_confidence = user.output.min_confidence;
default.output.require_converged = user.output.require_converged;
default.output.include_quality = user.output.include_quality;
default.output.show_all = user.output.show_all;
default.output.max_low = user.output.max_low;
default.output.max_low_per_file = user.output.max_low_per_file;
default.output.max_low_per_rule = user.output.max_low_per_rule;
default.output.rollup_examples = user.output.rollup_examples;
// --- PerformanceConfig ---
default.performance.max_depth = user.performance.max_depth;
default.performance.min_depth = user.performance.min_depth;
default.performance.prune = user.performance.prune;
default.performance.worker_threads = user.performance.worker_threads;
default.performance.batch_size = user.performance.batch_size;
default.performance.channel_multiplier = user.performance.channel_multiplier;
default.performance.rayon_thread_stack_size = user.performance.rayon_thread_stack_size;
default.performance.scan_timeout_secs = user.performance.scan_timeout_secs;
default.performance.memory_limit_mb = user.performance.memory_limit_mb;
// --- ServerConfig ---
default.server = user.server;
// --- RunsConfig ---
default.runs = user.runs;
// --- Profiles (user profile with same name fully replaces) ---
for (name, profile) in user.profiles {
default.profiles.insert(name, profile);
}
// --- DetectorOptions ---
// Wholesale replace: each `[detectors.*]` field uses #[serde(default)],
// so any omitted field already inherits the documented defaults during
// user-config deserialization. trusted_destinations is union-merged so
// the user adds to (rather than replaces) any future built-in defaults.
default.detectors.data_exfil.enabled = user.detectors.data_exfil.enabled;
extend_dedup(
&mut default.detectors.data_exfil.trusted_destinations,
user.detectors.data_exfil.trusted_destinations,
);
// --- AnalysisRulesConfig ---
// Engine options: wholesale replace. User's engine block is already
// serde-merged with defaults (via #[serde(default)] per field), so any
// omitted field retains the release default.
default.analysis.engine = user.analysis.engine;
for (lang, user_lang_cfg) in user.analysis.languages {
let entry = default.analysis.languages.entry(lang).or_default();
// Union-merge rules with dedup
for rule in user_lang_cfg.rules {
if !entry.rules.contains(&rule) {
entry.rules.push(rule);
}
}
// Union-merge terminators with dedup
for t in user_lang_cfg.terminators {
if !entry.terminators.contains(&t) {
entry.terminators.push(t);
}
}
// Union-merge event_handlers with dedup
for eh in user_lang_cfg.event_handlers {
if !entry.event_handlers.contains(&eh) {
entry.event_handlers.push(eh);
}
}
entry.auth.enabled = user_lang_cfg.auth.enabled;
extend_dedup(
&mut entry.auth.admin_path_patterns,
user_lang_cfg.auth.admin_path_patterns,
);
extend_dedup(
&mut entry.auth.admin_guard_names,
user_lang_cfg.auth.admin_guard_names,
);
extend_dedup(
&mut entry.auth.login_guard_names,
user_lang_cfg.auth.login_guard_names,
);
extend_dedup(
&mut entry.auth.policy_guard_names,
user_lang_cfg.auth.policy_guard_names,
);
extend_dedup(
&mut entry.auth.authorization_check_names,
user_lang_cfg.auth.authorization_check_names,
);
extend_dedup(
&mut entry.auth.mutation_indicator_names,
user_lang_cfg.auth.mutation_indicator_names,
);
extend_dedup(
&mut entry.auth.read_indicator_names,
user_lang_cfg.auth.read_indicator_names,
);
extend_dedup(
&mut entry.auth.token_lookup_names,
user_lang_cfg.auth.token_lookup_names,
);
extend_dedup(
&mut entry.auth.token_expiry_fields,
user_lang_cfg.auth.token_expiry_fields,
);
extend_dedup(
&mut entry.auth.token_recipient_fields,
user_lang_cfg.auth.token_recipient_fields,
);
extend_dedup(
&mut entry.auth.non_sink_receiver_types,
user_lang_cfg.auth.non_sink_receiver_types,
);
extend_dedup(
&mut entry.auth.non_sink_receiver_name_prefixes,
user_lang_cfg.auth.non_sink_receiver_name_prefixes,
);
extend_dedup(
&mut entry.auth.non_sink_global_receivers,
user_lang_cfg.auth.non_sink_global_receivers,
);
extend_dedup(
&mut entry.auth.non_sink_method_names,
user_lang_cfg.auth.non_sink_method_names,
);
extend_dedup(
&mut entry.auth.realtime_receiver_prefixes,
user_lang_cfg.auth.realtime_receiver_prefixes,
);
extend_dedup(
&mut entry.auth.outbound_network_receiver_prefixes,
user_lang_cfg.auth.outbound_network_receiver_prefixes,
);
extend_dedup(
&mut entry.auth.cache_receiver_prefixes,
user_lang_cfg.auth.cache_receiver_prefixes,
);
extend_dedup(&mut entry.auth.acl_tables, user_lang_cfg.auth.acl_tables);
extend_dedup(
&mut entry.auth.db_query_builder_roots,
user_lang_cfg.auth.db_query_builder_roots,
);
}
default
}
fn extend_dedup(dst: &mut Vec<String>, src: Vec<String>) {
for item in src {
if !dst.contains(&item) {
dst.push(item);
}
}
}
#[test]
fn merge_configs_dedupes_and_keeps_order() {
let mut default_cfg = Config::default();
default_cfg.scanner.excluded_extensions = vec!["rs".into(), "toml".into()];
let mut user_cfg = Config::default();
user_cfg.scanner.excluded_extensions = vec!["jpg".into(), "rs".into()];
let merged = merge_configs(default_cfg, user_cfg);
assert_eq!(
merged.scanner.excluded_extensions,
vec!["jpg", "rs", "toml"]
);
}
#[test]
fn merge_analysis_rules_unions_and_dedupes() {
let mut default_cfg = Config::default();
default_cfg.analysis.languages.insert(
"javascript".into(),
LanguageAnalysisConfig {
rules: vec![ConfigLabelRule {
matchers: vec!["escapeHtml".into()],
kind: RuleKind::Sanitizer,
cap: CapName::HtmlEscape,
case_sensitive: false,
}],
terminators: vec!["process.exit".into()],
event_handlers: vec![],
auth: AuthAnalysisConfig::default(),
},
);
let mut user_cfg = Config::default();
user_cfg.analysis.languages.insert(
"javascript".into(),
LanguageAnalysisConfig {
rules: vec![
ConfigLabelRule {
matchers: vec!["escapeHtml".into()],
kind: RuleKind::Sanitizer,
cap: CapName::HtmlEscape,
case_sensitive: false,
},
ConfigLabelRule {
matchers: vec!["sanitizeUrl".into()],
kind: RuleKind::Sanitizer,
cap: CapName::UrlEncode,
case_sensitive: false,
},
],
terminators: vec!["process.exit".into(), "abort".into()],
event_handlers: vec!["addEventListener".into()],
auth: AuthAnalysisConfig {
enabled: true,
admin_guard_names: vec!["requireAdmin".into()],
token_lookup_names: vec!["findByToken".into()],
..AuthAnalysisConfig::default()
},
},
);
let merged = merge_configs(default_cfg, user_cfg);
let js = merged.analysis.languages.get("javascript").unwrap();
assert_eq!(js.rules.len(), 2); // deduped
assert_eq!(js.terminators, vec!["process.exit", "abort"]);
assert_eq!(js.event_handlers, vec!["addEventListener"]);
assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
}
#[test]
fn analysis_config_toml_roundtrip() {
let toml_str = r#"
[analysis.languages.javascript]
terminators = ["process.exit"]
event_handlers = ["addEventListener"]
[analysis.languages.javascript.auth]
enabled = true
admin_guard_names = ["requireAdmin"]
token_lookup_names = ["findByToken"]
[[analysis.languages.javascript.rules]]
matchers = ["escapeHtml"]
kind = "sanitizer"
cap = "html_escape"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
let js = cfg.analysis.languages.get("javascript").unwrap();
assert_eq!(js.rules.len(), 1);
assert_eq!(js.rules[0].matchers, vec!["escapeHtml"]);
assert_eq!(js.rules[0].kind, RuleKind::Sanitizer);
assert_eq!(js.rules[0].cap, CapName::HtmlEscape);
assert_eq!(js.terminators, vec!["process.exit"]);
assert_eq!(js.event_handlers, vec!["addEventListener"]);
assert!(js.auth.enabled);
assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
}
#[test]
fn analysis_auth_config_toml_roundtrip_supports_typescript_overlay() {
let toml_str = r#"
[analysis.languages.javascript.auth]
enabled = true
admin_guard_names = ["requireAdmin"]
[analysis.languages.typescript.auth]
enabled = true
authorization_check_names = ["requireTypedOwnership"]
token_lookup_names = ["findInviteToken"]
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
let js = cfg.analysis.languages.get("javascript").unwrap();
let ts = cfg.analysis.languages.get("typescript").unwrap();
assert!(js.auth.enabled);
assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
assert!(ts.auth.enabled);
assert_eq!(
ts.auth.authorization_check_names,
vec!["requireTypedOwnership"]
);
assert_eq!(ts.auth.token_lookup_names, vec!["findInviteToken"]);
}
#[test]
fn merge_analysis_rules_preserves_per_language_auth_sections() {
let mut default_cfg = Config::default();
default_cfg.analysis.languages.insert(
"javascript".into(),
LanguageAnalysisConfig {
auth: AuthAnalysisConfig {
admin_guard_names: vec!["requireAdmin".into()],
..AuthAnalysisConfig::default()
},
..LanguageAnalysisConfig::default()
},
);
let mut user_cfg = Config::default();
user_cfg.analysis.languages.insert(
"javascript".into(),
LanguageAnalysisConfig {
auth: AuthAnalysisConfig {
token_lookup_names: vec!["findByToken".into()],
..AuthAnalysisConfig::default()
},
..LanguageAnalysisConfig::default()
},
);
user_cfg.analysis.languages.insert(
"typescript".into(),
LanguageAnalysisConfig {
auth: AuthAnalysisConfig {
authorization_check_names: vec!["requireTypedOwnership".into()],
..AuthAnalysisConfig::default()
},
..LanguageAnalysisConfig::default()
},
);
let merged = merge_configs(default_cfg, user_cfg);
let js = merged.analysis.languages.get("javascript").unwrap();
let ts = merged.analysis.languages.get("typescript").unwrap();
assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
assert_eq!(
ts.auth.authorization_check_names,
vec!["requireTypedOwnership"]
);
}
#[test]
fn load_creates_example_and_reads_user_overrides() {
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path();
let user_toml = r#"
[scanner]
one_file_system = true
excluded_extensions = ["foo"]
[output]
quiet = true
"#;
fs::write(cfg_path.join("nyx.local"), user_toml).unwrap();
let (cfg, _note) = Config::load(cfg_path).expect("Config::load should succeed");
assert!(cfg_path.join("nyx.conf").is_file());
assert!(cfg.scanner.one_file_system);
assert!(cfg.output.quiet);
assert!(cfg.scanner.excluded_extensions.contains(&"foo".to_string()));
assert!(!cfg.scanner.follow_symlinks);
}
// ─── Enum parsing tests ─────────────────────────────────────────────────────
#[test]
fn enum_roundtrip_output_format() {
let toml_str = r#"
[output]
default_format = "json"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.output.default_format, OutputFormat::Json);
let toml_str = r#"
[output]
default_format = "sarif"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
let toml_str = r#"
[output]
default_format = "console"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.output.default_format, OutputFormat::Console);
}
#[test]
fn enum_roundtrip_rule_kind() {
let toml_str = r#"
[[analysis.languages.javascript.rules]]
matchers = ["foo"]
kind = "source"
cap = "all"
[[analysis.languages.javascript.rules]]
matchers = ["bar"]
kind = "sanitizer"
cap = "html_escape"
[[analysis.languages.javascript.rules]]
matchers = ["baz"]
kind = "sink"
cap = "sql_query"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
let js = cfg.analysis.languages.get("javascript").unwrap();
assert_eq!(js.rules[0].kind, RuleKind::Source);
assert_eq!(js.rules[1].kind, RuleKind::Sanitizer);
assert_eq!(js.rules[2].kind, RuleKind::Sink);
}
#[test]
fn enum_roundtrip_cap_name() {
let caps = [
"env_var",
"html_escape",
"shell_escape",
"url_encode",
"json_parse",
"file_io",
"fmt_string",
"sql_query",
"deserialize",
"ssrf",
"code_exec",
"crypto",
"all",
];
for cap_str in caps {
let toml_str = format!(
r#"
[[analysis.languages.rust.rules]]
matchers = ["x"]
kind = "source"
cap = "{cap_str}"
"#
);
let cfg: Config = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("failed to parse cap '{cap_str}': {e}"));
let rs = cfg.analysis.languages.get("rust").unwrap();
assert_eq!(rs.rules[0].cap.to_string(), cap_str);
}
}
#[test]
fn backward_compat_existing_toml() {
// Simulate a typical pre-enum nyx.local that used string values
let toml_str = r#"
[scanner]
mode = "full"
min_severity = "Medium"
[output]
default_format = "console"
quiet = true
[[analysis.languages.javascript.rules]]
matchers = ["escapeHtml"]
kind = "sanitizer"
cap = "html_escape"
[analysis.languages.javascript.auth]
enabled = false
admin_path_patterns = ["/admin/"]
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
assert_eq!(cfg.output.default_format, OutputFormat::Console);
assert_eq!(
cfg.analysis.languages["javascript"].rules[0].kind,
RuleKind::Sanitizer
);
assert_eq!(
cfg.analysis.languages["javascript"].rules[0].cap,
CapName::HtmlEscape
);
assert!(!cfg.analysis.languages["javascript"].auth.enabled);
assert_eq!(
cfg.analysis.languages["javascript"]
.auth
.admin_path_patterns,
vec!["/admin/"]
);
}
#[test]
fn auth_analysis_config_defaults() {
let cfg = AuthAnalysisConfig::default();
assert!(cfg.enabled);
assert!(cfg.admin_path_patterns.is_empty());
assert!(cfg.authorization_check_names.is_empty());
}
// ─── Server and runs config tests ───────────────────────────────────────────
#[test]
fn server_config_defaults() {
let cfg = ServerConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 9700);
assert!(cfg.open_browser);
assert!(cfg.auto_reload);
assert!(cfg.persist_runs);
assert_eq!(cfg.max_saved_runs, 50);
}
#[test]
fn runs_config_defaults() {
let cfg = RunsConfig::default();
assert!(!cfg.persist);
assert_eq!(cfg.max_runs, 100);
assert!(!cfg.save_logs);
assert!(!cfg.save_stdout);
assert!(cfg.save_code_snippets);
}
#[test]
fn server_config_toml_roundtrip() {
let toml_str = r#"
[server]
enabled = false
host = "0.0.0.0"
port = 8080
open_browser = false
auto_reload = false
persist_runs = false
max_saved_runs = 10
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert!(!cfg.server.enabled);
assert_eq!(cfg.server.host, "0.0.0.0");
assert_eq!(cfg.server.port, 8080);
assert!(!cfg.server.open_browser);
assert!(!cfg.server.auto_reload);
assert!(!cfg.server.persist_runs);
assert_eq!(cfg.server.max_saved_runs, 10);
}
#[test]
fn missing_new_sections_use_defaults() {
let toml_str = r#"
[scanner]
mode = "ast"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
// server and runs should have defaults
assert_eq!(cfg.server.port, 9700);
assert!(!cfg.runs.persist);
assert!(cfg.profiles.is_empty());
}
// ─── Profiles tests ─────────────────────────────────────────────────────────
#[test]
fn profile_apply_overrides() {
let mut cfg = Config::default();
cfg.apply_profile("ci").unwrap();
assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
assert_eq!(cfg.scanner.min_severity, Severity::Medium);
assert!(cfg.output.quiet);
assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
}
#[test]
fn profile_not_found_errors() {
let mut cfg = Config::default();
let result = cfg.apply_profile("nonexistent");
assert!(result.is_err());
}
#[test]
fn builtin_profiles_resolve() {
let cfg = Config::default();
assert!(cfg.resolve_profile("quick").is_some());
assert!(cfg.resolve_profile("full").is_some());
assert!(cfg.resolve_profile("ci").is_some());
assert!(cfg.resolve_profile("taint_only").is_some());
assert!(cfg.resolve_profile("conservative_large_repo").is_some());
assert!(cfg.resolve_profile("nonexistent").is_none());
}
#[test]
fn user_profile_overrides_builtin() {
let mut cfg = Config::default();
cfg.profiles.insert(
"ci".into(),
ScanProfile {
mode: Some(AnalysisMode::Ast),
..Default::default()
},
);
let profile = cfg.resolve_profile("ci").unwrap();
// User's ci profile has Ast, not the built-in Full
assert_eq!(profile.mode, Some(AnalysisMode::Ast));
}
#[test]
fn profile_toml_roundtrip() {
let toml_str = r#"
[profiles.my_scan]
mode = "ast"
min_severity = "High"
quiet = true
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
let profile = cfg.profiles.get("my_scan").unwrap();
assert_eq!(profile.mode, Some(AnalysisMode::Ast));
assert_eq!(profile.min_severity, Some(Severity::High));
assert_eq!(profile.quiet, Some(true));
}
// ─── Validation tests ───────────────────────────────────────────────────────
#[test]
fn validate_good_config() {
let cfg = Config::default();
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_zero_port() {
let mut cfg = Config::default();
cfg.server.port = 0;
let err = cfg.validate().unwrap_err();
assert!(err.iter().any(|e| e.field == "port"));
}
#[test]
fn validate_empty_host() {
let mut cfg = Config::default();
cfg.server.host = String::new();
let err = cfg.validate().unwrap_err();
assert!(err.iter().any(|e| e.field == "host"));
}
#[test]
fn validate_zero_batch_size() {
let mut cfg = Config::default();
cfg.performance.batch_size = 0;
let err = cfg.validate().unwrap_err();
assert!(err.iter().any(|e| e.field == "batch_size"));
}
#[test]
fn validate_bad_profile_name() {
let mut cfg = Config::default();
cfg.profiles
.insert("has spaces".into(), ScanProfile::default());
let err = cfg.validate().unwrap_err();
assert!(err.iter().any(|e| e.section == "profiles"));
}
#[test]
fn validate_returns_all_errors() {
let mut cfg = Config::default();
cfg.server.port = 0;
cfg.server.host = String::new();
cfg.performance.batch_size = 0;
let err = cfg.validate().unwrap_err();
assert!(err.len() >= 3);
}
// ─── excluded_files merge test ──────────────────────────────────────────────
#[test]
fn merge_excluded_files_union() {
let mut default_cfg = Config::default();
default_cfg.scanner.excluded_files = vec!["a.rs".into(), "b.rs".into()];
let mut user_cfg = Config::default();
user_cfg.scanner.excluded_files = vec!["b.rs".into(), "c.rs".into()];
let merged = merge_configs(default_cfg, user_cfg);
assert_eq!(merged.scanner.excluded_files, vec!["a.rs", "b.rs", "c.rs"]);
}