Skip to main content

nyx_scanner/utils/
config.rs

1use crate::cli::OutputFormat;
2use crate::errors::NyxResult;
3use crate::labels::Cap;
4use crate::patterns::Severity;
5use console::style;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::fs;
10use std::path::Path;
11use std::str::FromStr;
12use toml;
13
14static DEFAULT_CONFIG_TOML: &str = include_str!("../../default-nyx.conf");
15
16#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum AnalysisMode {
19    #[default]
20    Full,
21    Ast,
22    Cfg,
23    Taint,
24}
25
26/// The kind of a custom label rule: source, sanitizer, or sink.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum RuleKind {
30    Source,
31    Sanitizer,
32    Sink,
33}
34
35impl fmt::Display for RuleKind {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Source => write!(f, "source"),
39            Self::Sanitizer => write!(f, "sanitizer"),
40            Self::Sink => write!(f, "sink"),
41        }
42    }
43}
44
45impl FromStr for RuleKind {
46    type Err = String;
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_ascii_lowercase().as_str() {
49            "source" => Ok(Self::Source),
50            "sanitizer" => Ok(Self::Sanitizer),
51            "sink" => Ok(Self::Sink),
52            _ => Err(format!(
53                "invalid rule kind: {s:?} (expected source, sanitizer, sink)"
54            )),
55        }
56    }
57}
58
59/// Named capability for a custom label rule.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum CapName {
63    EnvVar,
64    HtmlEscape,
65    ShellEscape,
66    UrlEncode,
67    JsonParse,
68    FileIo,
69    FmtString,
70    SqlQuery,
71    Deserialize,
72    Ssrf,
73    CodeExec,
74    Crypto,
75    /// Request-bound identifier not yet ownership-checked.
76    UnauthorizedId,
77    All,
78}
79
80impl CapName {
81    /// Convert to the corresponding `Cap` bitflag.
82    pub fn to_cap(self) -> Cap {
83        match self {
84            Self::EnvVar => Cap::ENV_VAR,
85            Self::HtmlEscape => Cap::HTML_ESCAPE,
86            Self::ShellEscape => Cap::SHELL_ESCAPE,
87            Self::UrlEncode => Cap::URL_ENCODE,
88            Self::JsonParse => Cap::JSON_PARSE,
89            Self::FileIo => Cap::FILE_IO,
90            Self::FmtString => Cap::FMT_STRING,
91            Self::SqlQuery => Cap::SQL_QUERY,
92            Self::Deserialize => Cap::DESERIALIZE,
93            Self::Ssrf => Cap::SSRF,
94            Self::CodeExec => Cap::CODE_EXEC,
95            Self::Crypto => Cap::CRYPTO,
96            Self::UnauthorizedId => Cap::UNAUTHORIZED_ID,
97            Self::All => Cap::all(),
98        }
99    }
100}
101
102impl fmt::Display for CapName {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::EnvVar => write!(f, "env_var"),
106            Self::HtmlEscape => write!(f, "html_escape"),
107            Self::ShellEscape => write!(f, "shell_escape"),
108            Self::UrlEncode => write!(f, "url_encode"),
109            Self::JsonParse => write!(f, "json_parse"),
110            Self::FileIo => write!(f, "file_io"),
111            Self::FmtString => write!(f, "fmt_string"),
112            Self::SqlQuery => write!(f, "sql_query"),
113            Self::Deserialize => write!(f, "deserialize"),
114            Self::Ssrf => write!(f, "ssrf"),
115            Self::CodeExec => write!(f, "code_exec"),
116            Self::Crypto => write!(f, "crypto"),
117            Self::UnauthorizedId => write!(f, "unauthorized_id"),
118            Self::All => write!(f, "all"),
119        }
120    }
121}
122
123impl FromStr for CapName {
124    type Err = String;
125    fn from_str(s: &str) -> Result<Self, Self::Err> {
126        match s.to_ascii_lowercase().as_str() {
127            "env_var" => Ok(Self::EnvVar),
128            "html_escape" => Ok(Self::HtmlEscape),
129            "shell_escape" => Ok(Self::ShellEscape),
130            "url_encode" => Ok(Self::UrlEncode),
131            "json_parse" => Ok(Self::JsonParse),
132            "file_io" => Ok(Self::FileIo),
133            "fmt_string" => Ok(Self::FmtString),
134            "sql_query" => Ok(Self::SqlQuery),
135            "deserialize" => Ok(Self::Deserialize),
136            "ssrf" => Ok(Self::Ssrf),
137            "code_exec" => Ok(Self::CodeExec),
138            "crypto" => Ok(Self::Crypto),
139            "unauthorized_id" => Ok(Self::UnauthorizedId),
140            "all" => Ok(Self::All),
141            _ => Err(format!(
142                "invalid cap name: {s:?} (expected env_var, html_escape, shell_escape, \
143                 url_encode, json_parse, file_io, fmt_string, sql_query, deserialize, \
144                 ssrf, code_exec, crypto, unauthorized_id, all)"
145            )),
146        }
147    }
148}
149
150#[derive(Debug, Serialize, Deserialize, Clone)]
151#[serde(default)]
152pub struct ScannerConfig {
153    /// The analysis mode to use.
154    pub mode: AnalysisMode,
155
156    /// The minimum severity level to output
157    pub min_severity: Severity,
158
159    /// The maximum file size to scan, in megabytes.
160    pub max_file_size_mb: Option<u64>,
161
162    /// File extensions to exclude from scanning.
163    pub excluded_extensions: Vec<String>,
164
165    /// Directories to exclude from scanning.
166    pub excluded_directories: Vec<String>,
167
168    /// Excluded files
169    pub excluded_files: Vec<String>,
170
171    /// RESERVED: not yet wired to walker. Whether to respect the global ignore file.
172    pub read_global_ignore: bool,
173
174    /// Whether to respect VCS ignore files (`.gitignore`, ..) or not.
175    pub read_vcsignore: bool,
176
177    /// Whether to require a `.git` directory to respect gitignore files.
178    pub require_git_to_read_vcsignore: bool,
179
180    /// Whether to limit the search to starting file system or not.
181    pub one_file_system: bool,
182
183    /// Whether to follow symlinks or not.
184    pub follow_symlinks: bool,
185
186    /// Whether to scan hidden files or not.
187    pub scan_hidden_files: bool,
188
189    /// Whether to include findings from non-production paths (tests, vendor,
190    /// benchmarks, etc.) at their original severity.  When false (default),
191    /// findings in these paths are downgraded by one severity tier.
192    pub include_nonprod: bool,
193
194    /// Enable the state-model dataflow engine for resource lifecycle and
195    /// auth-state analysis.  Default: true.
196    pub enable_state_analysis: bool,
197
198    /// Enable auth-state analysis within the state engine.  When false,
199    /// only resource lifecycle findings (leak, use-after-close, double-close)
200    /// are produced.  Default: true.
201    pub enable_auth_analysis: bool,
202
203    /// When true, per-file panics during analysis are caught and logged
204    /// as warnings; the scan continues with the remaining files.  Default
205    /// false: a panic aborts the scan, preserving existing behaviour for
206    /// users who want to catch engine bugs loudly.
207    pub enable_panic_recovery: bool,
208
209    /// Fold `auth_analysis` into the SSA/taint engine using the
210    /// `Cap::UNAUTHORIZED_ID` cap.  When true, request-bound handler
211    /// parameters seed `UNAUTHORIZED_ID` into the taint state and a
212    /// complementary set of sink / sanitizer rules participates in the
213    /// flow.  Default `false` while the standalone `auth_analysis`
214    /// subsystem still carries the stable detection; flipping to `true`
215    /// enables the taint-based path alongside it.
216    pub enable_auth_as_taint: bool,
217}
218impl Default for ScannerConfig {
219    fn default() -> Self {
220        Self {
221            mode: AnalysisMode::Full,
222            min_severity: Severity::Low,
223            max_file_size_mb: Some(16),
224            excluded_extensions: vec![
225                "jpg", "png", "gif", "mp4", "avi", "mkv", "zip", "tar", "gz", "exe", "dll", "so",
226            ]
227            .into_iter()
228            .map(str::to_owned)
229            .collect(),
230            excluded_directories: vec![
231                "node_modules",
232                ".git",
233                "target",
234                ".vscode",
235                ".idea",
236                "build",
237                "dist",
238            ]
239            .into_iter()
240            .map(str::to_owned)
241            .collect(),
242            excluded_files: vec![].into_iter().map(str::to_owned).collect(),
243            read_global_ignore: false,
244            read_vcsignore: true,
245            require_git_to_read_vcsignore: true,
246            one_file_system: false,
247            follow_symlinks: false,
248            scan_hidden_files: false,
249            include_nonprod: false,
250            enable_state_analysis: true,
251            enable_auth_analysis: true,
252            enable_panic_recovery: false,
253            enable_auth_as_taint: false,
254        }
255    }
256}
257
258#[derive(Debug, Serialize, Deserialize, Clone)]
259#[serde(default)]
260pub struct DatabaseConfig {
261    /// RESERVED: custom database path (not yet wired; DB path is computed from project info).
262    pub path: String,
263
264    /// RESERVED: auto-cleanup not yet implemented. Days to keep database files.
265    pub auto_cleanup_days: u32,
266
267    /// RESERVED: size limit not yet implemented. Maximum database size in MiB.
268    pub max_db_size_mb: u64,
269
270    /// Whether to run a VACUUM on startup.
271    pub vacuum_on_startup: bool,
272}
273impl Default for DatabaseConfig {
274    fn default() -> Self {
275        Self {
276            path: String::from(""),
277            auto_cleanup_days: 30,
278            max_db_size_mb: 1024,
279            vacuum_on_startup: false,
280        }
281    }
282}
283
284#[derive(Debug, Serialize, Deserialize, Clone)]
285#[serde(default)]
286pub struct OutputConfig {
287    /// The default output format.
288    pub default_format: OutputFormat,
289
290    /// Whether to print anything to the console or not.
291    pub quiet: bool,
292
293    /// The maximum number of results to show.
294    pub max_results: Option<u32>,
295
296    /// Enable attack-surface ranking to sort findings by exploitability.
297    pub attack_surface_ranking: bool,
298
299    /// Minimum attack-surface score to include in output.
300    /// Findings below this threshold are dropped after ranking.
301    /// `None` means no minimum (all findings shown).
302    pub min_score: Option<u32>,
303
304    /// Minimum confidence level to include in output.
305    /// `None` means no minimum (all findings shown).
306    #[serde(
307        default,
308        skip_serializing_if = "Option::is_none",
309        deserialize_with = "deserialize_confidence_opt"
310    )]
311    pub min_confidence: Option<crate::evidence::Confidence>,
312
313    /// Drop findings emitted from non-converged analysis.
314    ///
315    /// When `true`, findings whose engine provenance notes include any
316    /// `OverReport` (widening) or `Bail` (lowering/parse failure)
317    /// direction are filtered out before output.  `UnderReport`
318    /// findings, where the result set is a lower bound but each
319    /// emitted flow is still real, are kept.
320    ///
321    /// Surfaced via `--require-converged`; intended for strict CI
322    /// gating where a finding from capped analysis is worse than no
323    /// finding.
324    #[serde(default)]
325    pub require_converged: bool,
326
327    /// Include Quality-category findings (excluded by default).
328    #[serde(default)]
329    pub include_quality: bool,
330
331    /// Show all findings: disables category filtering, rollups, and LOW budgets.
332    #[serde(default)]
333    pub show_all: bool,
334
335    /// Maximum total LOW findings to show.
336    #[serde(default = "default_max_low")]
337    pub max_low: u32,
338
339    /// Maximum LOW findings per file.
340    #[serde(default = "default_max_low_per_file")]
341    pub max_low_per_file: u32,
342
343    /// Maximum LOW findings per rule.
344    #[serde(default = "default_max_low_per_rule")]
345    pub max_low_per_rule: u32,
346
347    /// Number of example locations to store in rollup findings.
348    #[serde(default = "default_rollup_examples")]
349    pub rollup_examples: u32,
350}
351
352fn default_max_low() -> u32 {
353    20
354}
355fn default_max_low_per_file() -> u32 {
356    1
357}
358fn default_max_low_per_rule() -> u32 {
359    10
360}
361fn default_rollup_examples() -> u32 {
362    5
363}
364
365impl Default for OutputConfig {
366    fn default() -> Self {
367        Self {
368            default_format: OutputFormat::Console,
369            quiet: false,
370            max_results: None,
371            attack_surface_ranking: true,
372            min_score: None,
373            min_confidence: None,
374            require_converged: false,
375            include_quality: false,
376            show_all: false,
377            max_low: 20,
378            max_low_per_file: 1,
379            max_low_per_rule: 10,
380            rollup_examples: 5,
381        }
382    }
383}
384
385/// Deserialize an optional Confidence from a TOML string.
386fn deserialize_confidence_opt<'de, D>(
387    deserializer: D,
388) -> Result<Option<crate::evidence::Confidence>, D::Error>
389where
390    D: serde::Deserializer<'de>,
391{
392    let opt: Option<String> = Option::deserialize(deserializer)?;
393    match opt {
394        None => Ok(None),
395        Some(s) => s
396            .parse::<crate::evidence::Confidence>()
397            .map(Some)
398            .map_err(serde::de::Error::custom),
399    }
400}
401
402#[derive(Debug, Serialize, Deserialize, Clone)]
403#[serde(default)]
404pub struct PerformanceConfig {
405    /// The maximum search depth, or `None` if no maximum search depth should be set.
406    ///
407    /// A depth of `1` includes all files under the current directory, a depth of `2` also includes
408    /// all files under subdirectories of the current directory, etc.
409    pub max_depth: Option<usize>,
410
411    /// RESERVED: not yet wired to walker. Minimum depth for reported entries.
412    pub min_depth: Option<usize>,
413
414    /// RESERVED: not yet wired to walker. Stop traversing into matching directories.
415    pub prune: bool,
416
417    /// The maximum number of worker threads to use, or `None` to auto-detect.
418    pub worker_threads: Option<usize>,
419
420    /// The maximum number of entries to index in a single chunk.
421    pub batch_size: usize,
422
423    /// Channel capacity = threads × this.
424    pub channel_multiplier: usize,
425
426    /// The stack size for Rayon threads, in bytes.
427    pub rayon_thread_stack_size: usize,
428
429    /// RESERVED: per-file timeout not yet implemented. Timeout in seconds.
430    pub scan_timeout_secs: Option<u64>,
431
432    /// RESERVED: memory limit not yet implemented. Maximum memory in MiB.
433    pub memory_limit_mb: u64,
434}
435
436impl Default for PerformanceConfig {
437    fn default() -> Self {
438        Self {
439            max_depth: None,
440            min_depth: None,
441            prune: false,
442            worker_threads: None,
443            batch_size: 100usize,
444            channel_multiplier: 4usize,
445            rayon_thread_stack_size: 8 * 1024 * 1024, // 8 MiB
446            scan_timeout_secs: None,
447            memory_limit_mb: 512,
448        }
449    }
450}
451
452/// A single user-defined label rule from config.
453#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
454pub struct ConfigLabelRule {
455    pub matchers: Vec<String>,
456    /// Rule kind: source, sanitizer, or sink.
457    pub kind: RuleKind,
458    /// Capability name (e.g. html_escape, sql_query, all).
459    pub cap: CapName,
460    #[serde(default)]
461    pub case_sensitive: bool,
462}
463
464/// Per-language analysis configuration from config file.
465#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
466#[serde(default)]
467pub struct LanguageAnalysisConfig {
468    pub rules: Vec<ConfigLabelRule>,
469    pub terminators: Vec<String>,
470    pub event_handlers: Vec<String>,
471    pub auth: AuthAnalysisConfig,
472}
473
474fn default_auth_enabled() -> bool {
475    true
476}
477
478/// Per-language authorization-analysis configuration from config file.
479#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
480#[serde(default)]
481pub struct AuthAnalysisConfig {
482    pub enabled: bool,
483    pub admin_path_patterns: Vec<String>,
484    pub admin_guard_names: Vec<String>,
485    pub login_guard_names: Vec<String>,
486    /// Typed-extractor wrapper names that prove the request passed
487    /// route-level capability/policy enforcement (e.g. meilisearch's
488    /// `GuardedData<ActionPolicy<X>, _>`).  Per-language defaults set
489    /// in `auth_analysis::config::build_auth_rules`; user nyx.toml
490    /// entries are appended.  Distinct from `login_guard_names` so the
491    /// pattern (matched as last-segment + case-insensitive
492    /// `starts_with`) doesn't pollute regular call recognition.
493    #[serde(default)]
494    pub policy_guard_names: Vec<String>,
495    pub authorization_check_names: Vec<String>,
496    pub mutation_indicator_names: Vec<String>,
497    pub read_indicator_names: Vec<String>,
498    pub token_lookup_names: Vec<String>,
499    pub token_expiry_fields: Vec<String>,
500    pub token_recipient_fields: Vec<String>,
501    /// Types whose instances should never be treated as auth sinks
502    /// (e.g. `HashMap`, `HashSet`, `Vec`).  When a `let` binding's RHS
503    /// constructs one of these, or an explicit type annotation names
504    /// one, the bound variable is tagged as non-sink and method calls
505    /// on it (`map.insert`, `vec.push`, …) are not classified as
506    /// Read/Mutation operations.
507    pub non_sink_receiver_types: Vec<String>,
508    /// Variable-name prefixes that strongly imply a local/in-memory
509    /// collection, used as a fallback when the type cannot be
510    /// resolved (e.g. `visited`, `seen`, `counts`).  Matched against
511    /// the first segment of the callee receiver chain.
512    pub non_sink_receiver_name_prefixes: Vec<String>,
513    /// Built-in / framework receivers whose first-segment, when
514    /// matched exactly (case-sensitive), classifies the call as
515    /// inherently non-data-layer.  Used for browser/DOM globals
516    /// (`document`, `window`, `localStorage`, ...) and stdlib helpers
517    /// (`Math`, `JSON`, `Date`).  Defaults are per-language in
518    /// `auth_analysis::config::build_auth_rules`; user nyx.toml
519    /// entries are appended.
520    #[serde(default)]
521    pub non_sink_global_receivers: Vec<String>,
522    /// Method-name allowlist: when the LAST segment of a callee
523    /// matches (case-sensitive exact), the call is classified as
524    /// non-sink regardless of receiver.  Used for DOM-API methods
525    /// (`addEventListener`, `getElementById`, `appendChild`, ...).
526    #[serde(default)]
527    pub non_sink_method_names: Vec<String>,
528    /// Receiver-chain first-segment prefixes that classify a call as
529    /// a realtime publish / broadcast sink (pub/sub bus, websocket
530    /// channel, event stream).  Treated as cross-tenant by default
531    /// and gated by the ownership check.
532    pub realtime_receiver_prefixes: Vec<String>,
533    /// Receiver-chain first-segment prefixes that classify a call as
534    /// an outbound network sink (HTTP client, RPC caller, webhook
535    /// dispatcher).
536    pub outbound_network_receiver_prefixes: Vec<String>,
537    /// Receiver-chain first-segment prefixes that classify a call as
538    /// a cross-tenant cache access (Redis / memcache / distributed
539    /// KV client).
540    pub cache_receiver_prefixes: Vec<String>,
541    /// SQL ACL tables.  When a literal `SELECT … FROM <T> JOIN <ACL>`
542    /// query pins rows via `WHERE <ACL>.user_id = ?N`, every returned
543    /// row is membership-gated and downstream uses of its columns do
544    /// not need an ownership check.  Defaults are set per-language in
545    /// `auth_analysis::config::build_auth_rules`.
546    pub acl_tables: Vec<String>,
547}
548
549impl Default for AuthAnalysisConfig {
550    fn default() -> Self {
551        Self {
552            enabled: default_auth_enabled(),
553            admin_path_patterns: Vec::new(),
554            admin_guard_names: Vec::new(),
555            login_guard_names: Vec::new(),
556            policy_guard_names: Vec::new(),
557            authorization_check_names: Vec::new(),
558            mutation_indicator_names: Vec::new(),
559            read_indicator_names: Vec::new(),
560            token_lookup_names: Vec::new(),
561            token_expiry_fields: Vec::new(),
562            token_recipient_fields: Vec::new(),
563            non_sink_receiver_types: Vec::new(),
564            non_sink_receiver_name_prefixes: Vec::new(),
565            non_sink_global_receivers: Vec::new(),
566            non_sink_method_names: Vec::new(),
567            realtime_receiver_prefixes: Vec::new(),
568            outbound_network_receiver_prefixes: Vec::new(),
569            cache_receiver_prefixes: Vec::new(),
570            acl_tables: Vec::new(),
571        }
572    }
573}
574
575/// Top-level analysis rules config, keyed by language slug.
576#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
577#[serde(default)]
578pub struct AnalysisRulesConfig {
579    pub languages: HashMap<String, LanguageAnalysisConfig>,
580    /// Rule IDs that have been disabled via the UI.
581    #[serde(default, skip_serializing_if = "Vec::is_empty")]
582    pub disabled_rules: Vec<String>,
583    /// Engine-pass toggles (constraint solving, abstract interpretation,
584    /// symex pipeline, parse timeout).  Exposed as `[analysis.engine]` in
585    /// TOML; see [`crate::utils::AnalysisOptions`].
586    #[serde(default)]
587    pub engine: crate::utils::AnalysisOptions,
588}
589
590/// Configuration for the local web UI server (`nyx serve`).
591#[derive(Debug, Serialize, Deserialize, Clone)]
592#[serde(default)]
593pub struct ServerConfig {
594    /// Whether the serve command is enabled.
595    pub enabled: bool,
596    /// Host to bind to (localhost by default for security).
597    pub host: String,
598    /// Port to bind to.
599    pub port: u16,
600    /// Open browser automatically when serve starts.
601    pub open_browser: bool,
602    /// Auto-reload UI when scan results change.
603    pub auto_reload: bool,
604    /// Persist scan runs for history view.
605    pub persist_runs: bool,
606    /// Maximum number of saved runs to keep.
607    pub max_saved_runs: u32,
608    /// Auto-sync triage decisions to `.nyx/triage.json` in the project root.
609    /// When enabled, triage changes are written to this file so they can be
610    /// committed to git and shared across team members.
611    pub triage_sync: bool,
612}
613
614impl Default for ServerConfig {
615    fn default() -> Self {
616        Self {
617            enabled: true,
618            host: "127.0.0.1".into(),
619            port: 9700,
620            open_browser: true,
621            auto_reload: true,
622            persist_runs: true,
623            max_saved_runs: 50,
624            triage_sync: true,
625        }
626    }
627}
628
629/// Configuration for scan run persistence and history.
630#[derive(Debug, Serialize, Deserialize, Clone)]
631#[serde(default)]
632pub struct RunsConfig {
633    /// Whether to persist scan run history to disk.
634    pub persist: bool,
635    /// Maximum number of runs to keep.
636    pub max_runs: u32,
637    /// Save scan logs with each run.
638    pub save_logs: bool,
639    /// Save stdout capture with each run.
640    pub save_stdout: bool,
641    /// Save code snippets in findings.
642    pub save_code_snippets: bool,
643}
644
645impl Default for RunsConfig {
646    fn default() -> Self {
647        Self {
648            persist: false,
649            max_runs: 100,
650            save_logs: false,
651            save_stdout: false,
652            save_code_snippets: true,
653        }
654    }
655}
656
657/// A named scan profile, a partial overlay of scan-related settings.
658/// All fields are `Option<T>`: `None` means "don't override".
659#[derive(Debug, Serialize, Deserialize, Clone, Default)]
660#[serde(default)]
661pub struct ScanProfile {
662    pub mode: Option<AnalysisMode>,
663    pub min_severity: Option<Severity>,
664    pub max_file_size_mb: Option<u64>,
665    pub include_nonprod: Option<bool>,
666    pub enable_state_analysis: Option<bool>,
667    pub enable_auth_analysis: Option<bool>,
668    pub default_format: Option<OutputFormat>,
669    pub quiet: Option<bool>,
670    pub attack_surface_ranking: Option<bool>,
671    pub max_results: Option<u32>,
672    pub min_score: Option<u32>,
673    pub show_all: Option<bool>,
674    pub include_quality: Option<bool>,
675    pub worker_threads: Option<usize>,
676    pub max_depth: Option<usize>,
677}
678
679/// Built-in profile definitions.
680fn builtin_profile(name: &str) -> Option<ScanProfile> {
681    Some(match name {
682        "quick" => ScanProfile {
683            mode: Some(AnalysisMode::Ast),
684            min_severity: Some(Severity::Medium),
685            ..Default::default()
686        },
687        "full" => ScanProfile {
688            mode: Some(AnalysisMode::Full),
689            min_severity: Some(Severity::Low),
690            enable_state_analysis: Some(true),
691            enable_auth_analysis: Some(true),
692            ..Default::default()
693        },
694        "ci" => ScanProfile {
695            mode: Some(AnalysisMode::Full),
696            min_severity: Some(Severity::Medium),
697            quiet: Some(true),
698            default_format: Some(OutputFormat::Sarif),
699            ..Default::default()
700        },
701        "taint_only" => ScanProfile {
702            mode: Some(AnalysisMode::Taint),
703            ..Default::default()
704        },
705        "conservative_large_repo" => ScanProfile {
706            mode: Some(AnalysisMode::Ast),
707            min_severity: Some(Severity::High),
708            max_file_size_mb: Some(5),
709            max_depth: Some(10),
710            ..Default::default()
711        },
712        _ => return None,
713    })
714}
715
716/// Top-level scanner configuration.
717///
718/// Loaded from `nyx.conf` (TOML) via [`Config::load`], or constructed in
719/// code for embedded use. [`Config::default`] gives conservative defaults:
720/// no symlink following, no hidden files, gitignore respected, 10 s parse
721/// timeout, all analysis passes on.
722///
723/// Config sections mirror `nyx.conf` sections:
724/// - [`scanner`](Config::scanner): what files to scan, which analysis passes
725///   to enable, severity floor
726/// - [`output`](Config::output): format, ranking, LOW-finding budgets
727/// - [`analysis`](Config::analysis): per-language rules, engine-pass toggles
728/// - [`performance`](Config::performance): thread count, depth limit, batch
729///   size
730/// - [`database`](Config::database): incremental index settings
731/// - [`detectors`](Config::detectors): per-detector sensitivity knobs
732#[derive(Debug, Serialize, Deserialize, Clone)]
733#[serde(default)]
734#[derive(Default)]
735pub struct Config {
736    pub scanner: ScannerConfig,
737    pub database: DatabaseConfig,
738    pub output: OutputConfig,
739    pub performance: PerformanceConfig,
740    pub analysis: AnalysisRulesConfig,
741    /// Per-detector knobs ([detectors.*] in nyx.conf).  Currently exposes
742    /// `[detectors.data_exfil]` for cross-boundary leak suppression.
743    #[serde(default)]
744    pub detectors: crate::utils::detector_options::DetectorOptions,
745    pub server: ServerConfig,
746    pub runs: RunsConfig,
747    pub profiles: HashMap<String, ScanProfile>,
748    /// Detected frameworks for the current project, set by the scan pipeline,
749    /// not persisted to config files.
750    #[serde(skip)]
751    pub framework_ctx: Option<crate::utils::project::FrameworkContext>,
752}
753
754impl Config {
755    /// Load config and return `(config, optional_note)`.
756    ///
757    /// The note is a formatted status message about which config file was
758    /// loaded (or that defaults are in use).  The caller decides whether to
759    /// print it based on output format / quiet mode.
760    pub fn load(config_dir: &Path) -> NyxResult<(Self, Option<String>)> {
761        let mut config = Config::default();
762
763        let default_config_path = config_dir.join("nyx.conf");
764        if !default_config_path.exists() {
765            create_example_config(config_dir)?;
766        }
767
768        let user_config_path = config_dir.join("nyx.local");
769        let note = if user_config_path.exists() {
770            let user_config_content = fs::read_to_string(&user_config_path)?;
771            let user_config: Config = toml::from_str(&user_config_content)?;
772
773            config = merge_configs(config, user_config);
774
775            Some(format!(
776                "{}: Loaded user config from: {}\n",
777                style("note").green().bold(),
778                style(user_config_path.display())
779                    .underlined()
780                    .white()
781                    .bold()
782            ))
783        } else {
784            Some(format!(
785                "{}: Using {} configuration.\n      Create file in '{}' to customize.\n",
786                style("note").green().bold(),
787                style("default").bold(),
788                style(user_config_path.display())
789                    .underlined()
790                    .white()
791                    .bold()
792            ))
793        };
794
795        config
796            .validate()
797            .map_err(crate::errors::NyxError::ConfigValidation)?;
798
799        Ok((config, note))
800    }
801
802    /// Resolve a profile by name: user-defined profiles take precedence over built-ins.
803    pub fn resolve_profile(&self, name: &str) -> Option<ScanProfile> {
804        self.profiles
805            .get(name)
806            .cloned()
807            .or_else(|| builtin_profile(name))
808    }
809
810    /// Apply a named profile, overlaying its `Some` fields onto this config.
811    /// Returns an error if the profile is not found.
812    pub fn apply_profile(&mut self, name: &str) -> NyxResult<()> {
813        let profile = self.resolve_profile(name).ok_or_else(|| {
814            crate::errors::NyxError::Msg(format!(
815                "unknown profile '{name}'. Built-in profiles: quick, full, ci, taint_only, conservative_large_repo"
816            ))
817        })?;
818
819        if let Some(v) = profile.mode {
820            self.scanner.mode = v;
821        }
822        if let Some(v) = profile.min_severity {
823            self.scanner.min_severity = v;
824        }
825        if let Some(v) = profile.max_file_size_mb {
826            self.scanner.max_file_size_mb = Some(v);
827        }
828        if let Some(v) = profile.include_nonprod {
829            self.scanner.include_nonprod = v;
830        }
831        if let Some(v) = profile.enable_state_analysis {
832            self.scanner.enable_state_analysis = v;
833        }
834        if let Some(v) = profile.enable_auth_analysis {
835            self.scanner.enable_auth_analysis = v;
836        }
837        if let Some(v) = profile.default_format {
838            self.output.default_format = v;
839        }
840        if let Some(v) = profile.quiet {
841            self.output.quiet = v;
842        }
843        if let Some(v) = profile.attack_surface_ranking {
844            self.output.attack_surface_ranking = v;
845        }
846        if let Some(v) = profile.max_results {
847            self.output.max_results = Some(v);
848        }
849        if let Some(v) = profile.min_score {
850            self.output.min_score = Some(v);
851        }
852        if let Some(v) = profile.show_all {
853            self.output.show_all = v;
854        }
855        if let Some(v) = profile.include_quality {
856            self.output.include_quality = v;
857        }
858        if let Some(v) = profile.worker_threads {
859            self.performance.worker_threads = Some(v);
860        }
861        if let Some(v) = profile.max_depth {
862            self.performance.max_depth = Some(v);
863        }
864
865        Ok(())
866    }
867
868    /// Validate semantic invariants after loading/merging.
869    /// Returns structured errors suitable for display or UI presentation.
870    pub fn validate(&self) -> Result<(), Vec<crate::errors::ConfigError>> {
871        use crate::errors::{ConfigError, ConfigErrorKind};
872        let mut errors = Vec::new();
873
874        // --- server ---
875        if self.server.port == 0 {
876            errors.push(ConfigError {
877                section: "server".into(),
878                field: "port".into(),
879                message: "port must be 1–65535".into(),
880                kind: ConfigErrorKind::OutOfRange,
881            });
882        }
883        if self.server.host.is_empty() {
884            errors.push(ConfigError {
885                section: "server".into(),
886                field: "host".into(),
887                message: "host must not be empty".into(),
888                kind: ConfigErrorKind::EmptyRequired,
889            });
890        }
891        if self.server.persist_runs && self.server.max_saved_runs == 0 {
892            errors.push(ConfigError {
893                section: "server".into(),
894                field: "max_saved_runs".into(),
895                message: "max_saved_runs must be > 0 when persist_runs is true".into(),
896                kind: ConfigErrorKind::Conflict,
897            });
898        }
899
900        // --- runs ---
901        if self.runs.persist && self.runs.max_runs == 0 {
902            errors.push(ConfigError {
903                section: "runs".into(),
904                field: "max_runs".into(),
905                message: "max_runs must be > 0 when persist is true".into(),
906                kind: ConfigErrorKind::Conflict,
907            });
908        }
909
910        // --- performance ---
911        if self.performance.batch_size == 0 {
912            errors.push(ConfigError {
913                section: "performance".into(),
914                field: "batch_size".into(),
915                message: "batch_size must be > 0".into(),
916                kind: ConfigErrorKind::OutOfRange,
917            });
918        }
919        if self.performance.channel_multiplier == 0 {
920            errors.push(ConfigError {
921                section: "performance".into(),
922                field: "channel_multiplier".into(),
923                message: "channel_multiplier must be > 0".into(),
924                kind: ConfigErrorKind::OutOfRange,
925            });
926        }
927
928        // --- output ---
929        if self.output.rollup_examples == 0 {
930            errors.push(ConfigError {
931                section: "output".into(),
932                field: "rollup_examples".into(),
933                message: "rollup_examples must be > 0".into(),
934                kind: ConfigErrorKind::OutOfRange,
935            });
936        }
937
938        // --- profiles ---
939        for name in self.profiles.keys() {
940            if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
941                errors.push(ConfigError {
942                    section: "profiles".into(),
943                    field: name.clone(),
944                    message: format!(
945                        "profile name '{name}' must contain only alphanumeric characters and underscores"
946                    ),
947                    kind: ConfigErrorKind::InvalidValue,
948                });
949            }
950        }
951
952        if errors.is_empty() {
953            Ok(())
954        } else {
955            Err(errors)
956        }
957    }
958}
959
960fn create_example_config(config_dir: &Path) -> NyxResult<()> {
961    let example_path = config_dir.join("nyx.conf");
962    if !example_path.exists() {
963        fs::write(&example_path, DEFAULT_CONFIG_TOML)?;
964        tracing::debug!("Example config created at: {}", example_path.display());
965    }
966    Ok(())
967}
968
969/// Merge user config into default config, preserving defaults where the user didn't
970/// supply new exclusions and overriding everything else.
971pub(crate) fn merge_configs(mut default: Config, user: Config) -> Config {
972    // --- ScannerConfig ---
973    default.scanner.mode = user.scanner.mode;
974    default.scanner.min_severity = user.scanner.min_severity;
975    default.scanner.max_file_size_mb = user.scanner.max_file_size_mb;
976    default.scanner.read_global_ignore = user.scanner.read_global_ignore;
977    default.scanner.read_vcsignore = user.scanner.read_vcsignore;
978    default.scanner.require_git_to_read_vcsignore = user.scanner.require_git_to_read_vcsignore;
979    default.scanner.one_file_system = user.scanner.one_file_system;
980    default.scanner.follow_symlinks = user.scanner.follow_symlinks;
981    default.scanner.scan_hidden_files = user.scanner.scan_hidden_files;
982    default.scanner.include_nonprod = user.scanner.include_nonprod;
983    default.scanner.enable_state_analysis = user.scanner.enable_state_analysis;
984    default.scanner.enable_auth_analysis = user.scanner.enable_auth_analysis;
985    default.scanner.enable_panic_recovery = user.scanner.enable_panic_recovery;
986    default.scanner.enable_auth_as_taint = user.scanner.enable_auth_as_taint;
987
988    // Merge exclusion lists (default ⊔ user), then sort & dedupe
989    default
990        .scanner
991        .excluded_extensions
992        .extend(user.scanner.excluded_extensions);
993    default
994        .scanner
995        .excluded_directories
996        .extend(user.scanner.excluded_directories);
997    default.scanner.excluded_extensions.sort_unstable();
998    default.scanner.excluded_extensions.dedup();
999    default.scanner.excluded_directories.sort_unstable();
1000    default.scanner.excluded_directories.dedup();
1001    default
1002        .scanner
1003        .excluded_files
1004        .extend(user.scanner.excluded_files);
1005    default.scanner.excluded_files.sort_unstable();
1006    default.scanner.excluded_files.dedup();
1007
1008    // --- DatabaseConfig ---
1009    default.database.path = user.database.path;
1010    default.database.auto_cleanup_days = user.database.auto_cleanup_days;
1011    default.database.max_db_size_mb = user.database.max_db_size_mb;
1012    default.database.vacuum_on_startup = user.database.vacuum_on_startup;
1013
1014    // --- OutputConfig ---
1015    default.output.default_format = user.output.default_format;
1016    default.output.quiet = user.output.quiet;
1017    default.output.max_results = user.output.max_results;
1018    default.output.attack_surface_ranking = user.output.attack_surface_ranking;
1019    default.output.min_score = user.output.min_score;
1020    default.output.min_confidence = user.output.min_confidence;
1021    default.output.require_converged = user.output.require_converged;
1022    default.output.include_quality = user.output.include_quality;
1023    default.output.show_all = user.output.show_all;
1024    default.output.max_low = user.output.max_low;
1025    default.output.max_low_per_file = user.output.max_low_per_file;
1026    default.output.max_low_per_rule = user.output.max_low_per_rule;
1027    default.output.rollup_examples = user.output.rollup_examples;
1028
1029    // --- PerformanceConfig ---
1030    default.performance.max_depth = user.performance.max_depth;
1031    default.performance.min_depth = user.performance.min_depth;
1032    default.performance.prune = user.performance.prune;
1033    default.performance.worker_threads = user.performance.worker_threads;
1034    default.performance.batch_size = user.performance.batch_size;
1035    default.performance.channel_multiplier = user.performance.channel_multiplier;
1036    default.performance.rayon_thread_stack_size = user.performance.rayon_thread_stack_size;
1037    default.performance.scan_timeout_secs = user.performance.scan_timeout_secs;
1038    default.performance.memory_limit_mb = user.performance.memory_limit_mb;
1039
1040    // --- ServerConfig ---
1041    default.server = user.server;
1042
1043    // --- RunsConfig ---
1044    default.runs = user.runs;
1045
1046    // --- Profiles (user profile with same name fully replaces) ---
1047    for (name, profile) in user.profiles {
1048        default.profiles.insert(name, profile);
1049    }
1050
1051    // --- DetectorOptions ---
1052    // Wholesale replace: each `[detectors.*]` field uses #[serde(default)],
1053    // so any omitted field already inherits the documented defaults during
1054    // user-config deserialization.  trusted_destinations is union-merged so
1055    // the user adds to (rather than replaces) any future built-in defaults.
1056    default.detectors.data_exfil.enabled = user.detectors.data_exfil.enabled;
1057    extend_dedup(
1058        &mut default.detectors.data_exfil.trusted_destinations,
1059        user.detectors.data_exfil.trusted_destinations,
1060    );
1061
1062    // --- AnalysisRulesConfig ---
1063    // Engine options: wholesale replace.  User's engine block is already
1064    // serde-merged with defaults (via #[serde(default)] per field), so any
1065    // omitted field retains the release default.
1066    default.analysis.engine = user.analysis.engine;
1067    for (lang, user_lang_cfg) in user.analysis.languages {
1068        let entry = default.analysis.languages.entry(lang).or_default();
1069
1070        // Union-merge rules with dedup
1071        for rule in user_lang_cfg.rules {
1072            if !entry.rules.contains(&rule) {
1073                entry.rules.push(rule);
1074            }
1075        }
1076
1077        // Union-merge terminators with dedup
1078        for t in user_lang_cfg.terminators {
1079            if !entry.terminators.contains(&t) {
1080                entry.terminators.push(t);
1081            }
1082        }
1083
1084        // Union-merge event_handlers with dedup
1085        for eh in user_lang_cfg.event_handlers {
1086            if !entry.event_handlers.contains(&eh) {
1087                entry.event_handlers.push(eh);
1088            }
1089        }
1090
1091        entry.auth.enabled = user_lang_cfg.auth.enabled;
1092        extend_dedup(
1093            &mut entry.auth.admin_path_patterns,
1094            user_lang_cfg.auth.admin_path_patterns,
1095        );
1096        extend_dedup(
1097            &mut entry.auth.admin_guard_names,
1098            user_lang_cfg.auth.admin_guard_names,
1099        );
1100        extend_dedup(
1101            &mut entry.auth.login_guard_names,
1102            user_lang_cfg.auth.login_guard_names,
1103        );
1104        extend_dedup(
1105            &mut entry.auth.policy_guard_names,
1106            user_lang_cfg.auth.policy_guard_names,
1107        );
1108        extend_dedup(
1109            &mut entry.auth.authorization_check_names,
1110            user_lang_cfg.auth.authorization_check_names,
1111        );
1112        extend_dedup(
1113            &mut entry.auth.mutation_indicator_names,
1114            user_lang_cfg.auth.mutation_indicator_names,
1115        );
1116        extend_dedup(
1117            &mut entry.auth.read_indicator_names,
1118            user_lang_cfg.auth.read_indicator_names,
1119        );
1120        extend_dedup(
1121            &mut entry.auth.token_lookup_names,
1122            user_lang_cfg.auth.token_lookup_names,
1123        );
1124        extend_dedup(
1125            &mut entry.auth.token_expiry_fields,
1126            user_lang_cfg.auth.token_expiry_fields,
1127        );
1128        extend_dedup(
1129            &mut entry.auth.token_recipient_fields,
1130            user_lang_cfg.auth.token_recipient_fields,
1131        );
1132        extend_dedup(
1133            &mut entry.auth.non_sink_receiver_types,
1134            user_lang_cfg.auth.non_sink_receiver_types,
1135        );
1136        extend_dedup(
1137            &mut entry.auth.non_sink_receiver_name_prefixes,
1138            user_lang_cfg.auth.non_sink_receiver_name_prefixes,
1139        );
1140        extend_dedup(
1141            &mut entry.auth.non_sink_global_receivers,
1142            user_lang_cfg.auth.non_sink_global_receivers,
1143        );
1144        extend_dedup(
1145            &mut entry.auth.non_sink_method_names,
1146            user_lang_cfg.auth.non_sink_method_names,
1147        );
1148        extend_dedup(
1149            &mut entry.auth.realtime_receiver_prefixes,
1150            user_lang_cfg.auth.realtime_receiver_prefixes,
1151        );
1152        extend_dedup(
1153            &mut entry.auth.outbound_network_receiver_prefixes,
1154            user_lang_cfg.auth.outbound_network_receiver_prefixes,
1155        );
1156        extend_dedup(
1157            &mut entry.auth.cache_receiver_prefixes,
1158            user_lang_cfg.auth.cache_receiver_prefixes,
1159        );
1160        extend_dedup(&mut entry.auth.acl_tables, user_lang_cfg.auth.acl_tables);
1161    }
1162
1163    default
1164}
1165
1166fn extend_dedup(dst: &mut Vec<String>, src: Vec<String>) {
1167    for item in src {
1168        if !dst.contains(&item) {
1169            dst.push(item);
1170        }
1171    }
1172}
1173
1174#[test]
1175fn merge_configs_dedupes_and_keeps_order() {
1176    let mut default_cfg = Config::default();
1177    default_cfg.scanner.excluded_extensions = vec!["rs".into(), "toml".into()];
1178
1179    let mut user_cfg = Config::default();
1180    user_cfg.scanner.excluded_extensions = vec!["jpg".into(), "rs".into()];
1181
1182    let merged = merge_configs(default_cfg, user_cfg);
1183
1184    assert_eq!(
1185        merged.scanner.excluded_extensions,
1186        vec!["jpg", "rs", "toml"]
1187    );
1188}
1189
1190#[test]
1191fn merge_analysis_rules_unions_and_dedupes() {
1192    let mut default_cfg = Config::default();
1193    default_cfg.analysis.languages.insert(
1194        "javascript".into(),
1195        LanguageAnalysisConfig {
1196            rules: vec![ConfigLabelRule {
1197                matchers: vec!["escapeHtml".into()],
1198                kind: RuleKind::Sanitizer,
1199                cap: CapName::HtmlEscape,
1200                case_sensitive: false,
1201            }],
1202            terminators: vec!["process.exit".into()],
1203            event_handlers: vec![],
1204            auth: AuthAnalysisConfig::default(),
1205        },
1206    );
1207
1208    let mut user_cfg = Config::default();
1209    user_cfg.analysis.languages.insert(
1210        "javascript".into(),
1211        LanguageAnalysisConfig {
1212            rules: vec![
1213                ConfigLabelRule {
1214                    matchers: vec!["escapeHtml".into()],
1215                    kind: RuleKind::Sanitizer,
1216                    cap: CapName::HtmlEscape,
1217                    case_sensitive: false,
1218                },
1219                ConfigLabelRule {
1220                    matchers: vec!["sanitizeUrl".into()],
1221                    kind: RuleKind::Sanitizer,
1222                    cap: CapName::UrlEncode,
1223                    case_sensitive: false,
1224                },
1225            ],
1226            terminators: vec!["process.exit".into(), "abort".into()],
1227            event_handlers: vec!["addEventListener".into()],
1228            auth: AuthAnalysisConfig {
1229                enabled: true,
1230                admin_guard_names: vec!["requireAdmin".into()],
1231                token_lookup_names: vec!["findByToken".into()],
1232                ..AuthAnalysisConfig::default()
1233            },
1234        },
1235    );
1236
1237    let merged = merge_configs(default_cfg, user_cfg);
1238    let js = merged.analysis.languages.get("javascript").unwrap();
1239    assert_eq!(js.rules.len(), 2); // deduped
1240    assert_eq!(js.terminators, vec!["process.exit", "abort"]);
1241    assert_eq!(js.event_handlers, vec!["addEventListener"]);
1242    assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1243    assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1244}
1245
1246#[test]
1247fn analysis_config_toml_roundtrip() {
1248    let toml_str = r#"
1249[analysis.languages.javascript]
1250terminators = ["process.exit"]
1251event_handlers = ["addEventListener"]
1252
1253[analysis.languages.javascript.auth]
1254enabled = true
1255admin_guard_names = ["requireAdmin"]
1256token_lookup_names = ["findByToken"]
1257
1258[[analysis.languages.javascript.rules]]
1259matchers = ["escapeHtml"]
1260kind = "sanitizer"
1261cap = "html_escape"
1262    "#;
1263    let cfg: Config = toml::from_str(toml_str).unwrap();
1264    let js = cfg.analysis.languages.get("javascript").unwrap();
1265    assert_eq!(js.rules.len(), 1);
1266    assert_eq!(js.rules[0].matchers, vec!["escapeHtml"]);
1267    assert_eq!(js.rules[0].kind, RuleKind::Sanitizer);
1268    assert_eq!(js.rules[0].cap, CapName::HtmlEscape);
1269    assert_eq!(js.terminators, vec!["process.exit"]);
1270    assert_eq!(js.event_handlers, vec!["addEventListener"]);
1271    assert!(js.auth.enabled);
1272    assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1273    assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1274}
1275
1276#[test]
1277fn analysis_auth_config_toml_roundtrip_supports_typescript_overlay() {
1278    let toml_str = r#"
1279[analysis.languages.javascript.auth]
1280enabled = true
1281admin_guard_names = ["requireAdmin"]
1282
1283[analysis.languages.typescript.auth]
1284enabled = true
1285authorization_check_names = ["requireTypedOwnership"]
1286token_lookup_names = ["findInviteToken"]
1287    "#;
1288    let cfg: Config = toml::from_str(toml_str).unwrap();
1289    let js = cfg.analysis.languages.get("javascript").unwrap();
1290    let ts = cfg.analysis.languages.get("typescript").unwrap();
1291    assert!(js.auth.enabled);
1292    assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1293    assert!(ts.auth.enabled);
1294    assert_eq!(
1295        ts.auth.authorization_check_names,
1296        vec!["requireTypedOwnership"]
1297    );
1298    assert_eq!(ts.auth.token_lookup_names, vec!["findInviteToken"]);
1299}
1300
1301#[test]
1302fn merge_analysis_rules_preserves_per_language_auth_sections() {
1303    let mut default_cfg = Config::default();
1304    default_cfg.analysis.languages.insert(
1305        "javascript".into(),
1306        LanguageAnalysisConfig {
1307            auth: AuthAnalysisConfig {
1308                admin_guard_names: vec!["requireAdmin".into()],
1309                ..AuthAnalysisConfig::default()
1310            },
1311            ..LanguageAnalysisConfig::default()
1312        },
1313    );
1314
1315    let mut user_cfg = Config::default();
1316    user_cfg.analysis.languages.insert(
1317        "javascript".into(),
1318        LanguageAnalysisConfig {
1319            auth: AuthAnalysisConfig {
1320                token_lookup_names: vec!["findByToken".into()],
1321                ..AuthAnalysisConfig::default()
1322            },
1323            ..LanguageAnalysisConfig::default()
1324        },
1325    );
1326    user_cfg.analysis.languages.insert(
1327        "typescript".into(),
1328        LanguageAnalysisConfig {
1329            auth: AuthAnalysisConfig {
1330                authorization_check_names: vec!["requireTypedOwnership".into()],
1331                ..AuthAnalysisConfig::default()
1332            },
1333            ..LanguageAnalysisConfig::default()
1334        },
1335    );
1336
1337    let merged = merge_configs(default_cfg, user_cfg);
1338    let js = merged.analysis.languages.get("javascript").unwrap();
1339    let ts = merged.analysis.languages.get("typescript").unwrap();
1340
1341    assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1342    assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1343    assert_eq!(
1344        ts.auth.authorization_check_names,
1345        vec!["requireTypedOwnership"]
1346    );
1347}
1348
1349#[test]
1350fn load_creates_example_and_reads_user_overrides() {
1351    let cfg_dir = tempfile::tempdir().unwrap();
1352    let cfg_path = cfg_dir.path();
1353
1354    let user_toml = r#"
1355        [scanner]
1356        one_file_system = true
1357        excluded_extensions = ["foo"]
1358
1359        [output]
1360        quiet = true
1361    "#;
1362    fs::write(cfg_path.join("nyx.local"), user_toml).unwrap();
1363
1364    let (cfg, _note) = Config::load(cfg_path).expect("Config::load should succeed");
1365
1366    assert!(cfg_path.join("nyx.conf").is_file());
1367
1368    assert!(cfg.scanner.one_file_system);
1369    assert!(cfg.output.quiet);
1370    assert!(cfg.scanner.excluded_extensions.contains(&"foo".to_string()));
1371
1372    assert!(!cfg.scanner.follow_symlinks);
1373}
1374
1375// ─── Enum parsing tests ─────────────────────────────────────────────────────
1376
1377#[test]
1378fn enum_roundtrip_output_format() {
1379    let toml_str = r#"
1380        [output]
1381        default_format = "json"
1382    "#;
1383    let cfg: Config = toml::from_str(toml_str).unwrap();
1384    assert_eq!(cfg.output.default_format, OutputFormat::Json);
1385
1386    let toml_str = r#"
1387        [output]
1388        default_format = "sarif"
1389    "#;
1390    let cfg: Config = toml::from_str(toml_str).unwrap();
1391    assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
1392
1393    let toml_str = r#"
1394        [output]
1395        default_format = "console"
1396    "#;
1397    let cfg: Config = toml::from_str(toml_str).unwrap();
1398    assert_eq!(cfg.output.default_format, OutputFormat::Console);
1399}
1400
1401#[test]
1402fn enum_roundtrip_rule_kind() {
1403    let toml_str = r#"
1404        [[analysis.languages.javascript.rules]]
1405        matchers = ["foo"]
1406        kind = "source"
1407        cap = "all"
1408
1409        [[analysis.languages.javascript.rules]]
1410        matchers = ["bar"]
1411        kind = "sanitizer"
1412        cap = "html_escape"
1413
1414        [[analysis.languages.javascript.rules]]
1415        matchers = ["baz"]
1416        kind = "sink"
1417        cap = "sql_query"
1418    "#;
1419    let cfg: Config = toml::from_str(toml_str).unwrap();
1420    let js = cfg.analysis.languages.get("javascript").unwrap();
1421    assert_eq!(js.rules[0].kind, RuleKind::Source);
1422    assert_eq!(js.rules[1].kind, RuleKind::Sanitizer);
1423    assert_eq!(js.rules[2].kind, RuleKind::Sink);
1424}
1425
1426#[test]
1427fn enum_roundtrip_cap_name() {
1428    let caps = [
1429        "env_var",
1430        "html_escape",
1431        "shell_escape",
1432        "url_encode",
1433        "json_parse",
1434        "file_io",
1435        "fmt_string",
1436        "sql_query",
1437        "deserialize",
1438        "ssrf",
1439        "code_exec",
1440        "crypto",
1441        "all",
1442    ];
1443    for cap_str in caps {
1444        let toml_str = format!(
1445            r#"
1446            [[analysis.languages.rust.rules]]
1447            matchers = ["x"]
1448            kind = "source"
1449            cap = "{cap_str}"
1450            "#
1451        );
1452        let cfg: Config = toml::from_str(&toml_str)
1453            .unwrap_or_else(|e| panic!("failed to parse cap '{cap_str}': {e}"));
1454        let rs = cfg.analysis.languages.get("rust").unwrap();
1455        assert_eq!(rs.rules[0].cap.to_string(), cap_str);
1456    }
1457}
1458
1459#[test]
1460fn backward_compat_existing_toml() {
1461    // Simulate a typical pre-enum nyx.local that used string values
1462    let toml_str = r#"
1463        [scanner]
1464        mode = "full"
1465        min_severity = "Medium"
1466
1467        [output]
1468        default_format = "console"
1469        quiet = true
1470
1471        [[analysis.languages.javascript.rules]]
1472        matchers = ["escapeHtml"]
1473        kind = "sanitizer"
1474        cap = "html_escape"
1475
1476        [analysis.languages.javascript.auth]
1477        enabled = false
1478        admin_path_patterns = ["/admin/"]
1479    "#;
1480    let cfg: Config = toml::from_str(toml_str).unwrap();
1481    assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
1482    assert_eq!(cfg.output.default_format, OutputFormat::Console);
1483    assert_eq!(
1484        cfg.analysis.languages["javascript"].rules[0].kind,
1485        RuleKind::Sanitizer
1486    );
1487    assert_eq!(
1488        cfg.analysis.languages["javascript"].rules[0].cap,
1489        CapName::HtmlEscape
1490    );
1491    assert!(!cfg.analysis.languages["javascript"].auth.enabled);
1492    assert_eq!(
1493        cfg.analysis.languages["javascript"]
1494            .auth
1495            .admin_path_patterns,
1496        vec!["/admin/"]
1497    );
1498}
1499
1500#[test]
1501fn auth_analysis_config_defaults() {
1502    let cfg = AuthAnalysisConfig::default();
1503    assert!(cfg.enabled);
1504    assert!(cfg.admin_path_patterns.is_empty());
1505    assert!(cfg.authorization_check_names.is_empty());
1506}
1507
1508// ─── Server and runs config tests ───────────────────────────────────────────
1509
1510#[test]
1511fn server_config_defaults() {
1512    let cfg = ServerConfig::default();
1513    assert!(cfg.enabled);
1514    assert_eq!(cfg.host, "127.0.0.1");
1515    assert_eq!(cfg.port, 9700);
1516    assert!(cfg.open_browser);
1517    assert!(cfg.auto_reload);
1518    assert!(cfg.persist_runs);
1519    assert_eq!(cfg.max_saved_runs, 50);
1520}
1521
1522#[test]
1523fn runs_config_defaults() {
1524    let cfg = RunsConfig::default();
1525    assert!(!cfg.persist);
1526    assert_eq!(cfg.max_runs, 100);
1527    assert!(!cfg.save_logs);
1528    assert!(!cfg.save_stdout);
1529    assert!(cfg.save_code_snippets);
1530}
1531
1532#[test]
1533fn server_config_toml_roundtrip() {
1534    let toml_str = r#"
1535        [server]
1536        enabled = false
1537        host = "0.0.0.0"
1538        port = 8080
1539        open_browser = false
1540        auto_reload = false
1541        persist_runs = false
1542        max_saved_runs = 10
1543    "#;
1544    let cfg: Config = toml::from_str(toml_str).unwrap();
1545    assert!(!cfg.server.enabled);
1546    assert_eq!(cfg.server.host, "0.0.0.0");
1547    assert_eq!(cfg.server.port, 8080);
1548    assert!(!cfg.server.open_browser);
1549    assert!(!cfg.server.auto_reload);
1550    assert!(!cfg.server.persist_runs);
1551    assert_eq!(cfg.server.max_saved_runs, 10);
1552}
1553
1554#[test]
1555fn missing_new_sections_use_defaults() {
1556    let toml_str = r#"
1557        [scanner]
1558        mode = "ast"
1559    "#;
1560    let cfg: Config = toml::from_str(toml_str).unwrap();
1561    // server and runs should have defaults
1562    assert_eq!(cfg.server.port, 9700);
1563    assert!(!cfg.runs.persist);
1564    assert!(cfg.profiles.is_empty());
1565}
1566
1567// ─── Profiles tests ─────────────────────────────────────────────────────────
1568
1569#[test]
1570fn profile_apply_overrides() {
1571    let mut cfg = Config::default();
1572    cfg.apply_profile("ci").unwrap();
1573    assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
1574    assert_eq!(cfg.scanner.min_severity, Severity::Medium);
1575    assert!(cfg.output.quiet);
1576    assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
1577}
1578
1579#[test]
1580fn profile_not_found_errors() {
1581    let mut cfg = Config::default();
1582    let result = cfg.apply_profile("nonexistent");
1583    assert!(result.is_err());
1584}
1585
1586#[test]
1587fn builtin_profiles_resolve() {
1588    let cfg = Config::default();
1589    assert!(cfg.resolve_profile("quick").is_some());
1590    assert!(cfg.resolve_profile("full").is_some());
1591    assert!(cfg.resolve_profile("ci").is_some());
1592    assert!(cfg.resolve_profile("taint_only").is_some());
1593    assert!(cfg.resolve_profile("conservative_large_repo").is_some());
1594    assert!(cfg.resolve_profile("nonexistent").is_none());
1595}
1596
1597#[test]
1598fn user_profile_overrides_builtin() {
1599    let mut cfg = Config::default();
1600    cfg.profiles.insert(
1601        "ci".into(),
1602        ScanProfile {
1603            mode: Some(AnalysisMode::Ast),
1604            ..Default::default()
1605        },
1606    );
1607    let profile = cfg.resolve_profile("ci").unwrap();
1608    // User's ci profile has Ast, not the built-in Full
1609    assert_eq!(profile.mode, Some(AnalysisMode::Ast));
1610}
1611
1612#[test]
1613fn profile_toml_roundtrip() {
1614    let toml_str = r#"
1615        [profiles.my_scan]
1616        mode = "ast"
1617        min_severity = "High"
1618        quiet = true
1619    "#;
1620    let cfg: Config = toml::from_str(toml_str).unwrap();
1621    let profile = cfg.profiles.get("my_scan").unwrap();
1622    assert_eq!(profile.mode, Some(AnalysisMode::Ast));
1623    assert_eq!(profile.min_severity, Some(Severity::High));
1624    assert_eq!(profile.quiet, Some(true));
1625}
1626
1627// ─── Validation tests ───────────────────────────────────────────────────────
1628
1629#[test]
1630fn validate_good_config() {
1631    let cfg = Config::default();
1632    assert!(cfg.validate().is_ok());
1633}
1634
1635#[test]
1636fn validate_zero_port() {
1637    let mut cfg = Config::default();
1638    cfg.server.port = 0;
1639    let err = cfg.validate().unwrap_err();
1640    assert!(err.iter().any(|e| e.field == "port"));
1641}
1642
1643#[test]
1644fn validate_empty_host() {
1645    let mut cfg = Config::default();
1646    cfg.server.host = String::new();
1647    let err = cfg.validate().unwrap_err();
1648    assert!(err.iter().any(|e| e.field == "host"));
1649}
1650
1651#[test]
1652fn validate_zero_batch_size() {
1653    let mut cfg = Config::default();
1654    cfg.performance.batch_size = 0;
1655    let err = cfg.validate().unwrap_err();
1656    assert!(err.iter().any(|e| e.field == "batch_size"));
1657}
1658
1659#[test]
1660fn validate_bad_profile_name() {
1661    let mut cfg = Config::default();
1662    cfg.profiles
1663        .insert("has spaces".into(), ScanProfile::default());
1664    let err = cfg.validate().unwrap_err();
1665    assert!(err.iter().any(|e| e.section == "profiles"));
1666}
1667
1668#[test]
1669fn validate_returns_all_errors() {
1670    let mut cfg = Config::default();
1671    cfg.server.port = 0;
1672    cfg.server.host = String::new();
1673    cfg.performance.batch_size = 0;
1674    let err = cfg.validate().unwrap_err();
1675    assert!(err.len() >= 3);
1676}
1677
1678// ─── excluded_files merge test ──────────────────────────────────────────────
1679
1680#[test]
1681fn merge_excluded_files_union() {
1682    let mut default_cfg = Config::default();
1683    default_cfg.scanner.excluded_files = vec!["a.rs".into(), "b.rs".into()];
1684
1685    let mut user_cfg = Config::default();
1686    user_cfg.scanner.excluded_files = vec!["b.rs".into(), "c.rs".into()];
1687
1688    let merged = merge_configs(default_cfg, user_cfg);
1689    assert_eq!(merged.scanner.excluded_files, vec!["a.rs", "b.rs", "c.rs"]);
1690}