1use crate::Severity;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum Profile {
20 Fast,
22 #[default]
24 Balanced,
25 Strict,
27}
28
29impl std::fmt::Display for Profile {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Profile::Fast => write!(f, "fast"),
33 Profile::Balanced => write!(f, "balanced"),
34 Profile::Strict => write!(f, "strict"),
35 }
36 }
37}
38
39impl std::str::FromStr for Profile {
40 type Err = String;
41
42 fn from_str(s: &str) -> Result<Self, Self::Err> {
43 match s.to_lowercase().as_str() {
44 "fast" => Ok(Profile::Fast),
45 "balanced" => Ok(Profile::Balanced),
46 "strict" => Ok(Profile::Strict),
47 _ => Err(format!(
48 "Unknown profile: {}. Use: fast, balanced, strict",
49 s
50 )),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
57#[serde(rename_all = "kebab-case")]
58pub enum BaselineMode {
59 #[default]
61 All,
62 NewOnly,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ScanConfig {
69 #[serde(default)]
71 pub include: Vec<String>,
72
73 #[serde(default)]
75 pub exclude: Vec<String>,
76
77 #[serde(default = "default_max_file_size")]
79 pub max_file_size: usize,
80}
81
82fn default_max_file_size() -> usize {
83 10 * 1024 * 1024
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct RulesConfig {
89 #[serde(default = "default_enable")]
91 pub enable: Vec<String>,
92
93 #[serde(default)]
95 pub disable: Vec<String>,
96
97 #[serde(default)]
100 pub ignore_paths: Vec<String>,
101
102 #[serde(default)]
106 pub ignore_paths_by_rule: HashMap<String, Vec<String>>,
107}
108
109pub const DEFAULT_TEST_IGNORE_PATHS: &[&str] = &[
112 "**/test/**",
116 "**/tests/**",
117 "**/testing/**",
118 "**/spec/**",
119 "**/specs/**",
120 "**/e2e/**",
121 "**/integration/**",
122 "**/integration-tests/**",
123 "**/unit/**",
124 "**/unit-tests/**",
125 "**/__tests__/**",
129 "**/__test__/**",
130 "**/cypress/**",
131 "**/playwright/**",
132 "**/*.test.ts",
134 "**/*.test.js",
135 "**/*.test.tsx",
136 "**/*.test.jsx",
137 "**/*.test.mjs",
138 "**/*.test.cjs",
139 "**/*.spec.ts",
140 "**/*.spec.js",
141 "**/*.spec.tsx",
142 "**/*.spec.jsx",
143 "**/*.spec.mjs",
144 "**/*.spec.cjs",
145 "**/test_*.py",
149 "**/*_test.py",
150 "**/tests_*.py",
151 "**/*_spec.py",
152 "**/conftest.py",
153 "**/pytest_*.py",
154 "**/*_test.go",
158 "**/*_integration_test.go",
159 "**/*_test.rs",
163 "**/benches/**",
164 "**/src/test/**",
168 "**/src/testFixtures/**",
169 "**/src/integrationTest/**",
170 "**/*Test.java",
171 "**/*Tests.java",
172 "**/Test*.java",
173 "**/*TestCase.java",
174 "**/*IT.java",
175 "**/*ITCase.java",
176 "**/*Test.kt",
180 "**/*Tests.kt",
181 "**/Test*.kt",
182];
183
184pub const DEFAULT_EXAMPLE_IGNORE_PATHS: &[&str] = &[
186 "**/examples/**",
188 "**/example/**",
189 "**/sample/**",
190 "**/samples/**",
191 "**/demo/**",
192 "**/demos/**",
193 "**/tutorial/**",
194 "**/tutorials/**",
195 "**/fixtures/**",
197 "**/fixture/**",
198 "**/testdata/**",
199 "**/test_data/**",
200 "**/test-data/**",
201 "**/test-fixtures/**",
202 "**/test_fixtures/**",
203 "**/__fixtures__/**",
204 "**/golden/**",
205 "**/snapshots/**",
206 "**/__snapshots__/**",
207 "**/mocks/**",
209 "**/mock/**",
210 "**/__mocks__/**",
211 "**/stubs/**",
212 "**/stub/**",
213 "**/fakes/**",
214 "**/fake/**",
215 "**/testutil/**",
217 "**/testutils/**",
218 "**/test_utils/**",
219 "**/test-utils/**",
220 "**/testing_utils/**",
221];
222
223pub const RULES_ALWAYS_ENABLED: &[&str] = &[
226 "rust/command-injection",
227 "python/shell-injection",
228 "go/command-injection",
229 "java/command-execution",
230 "js/dynamic-code-execution",
231 "generic/hardcoded-secret",
232];
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
236pub struct RulesetsConfig {
237 #[serde(default)]
239 pub security: Vec<String>,
240
241 #[serde(default)]
243 pub maintainability: Vec<String>,
244
245 #[serde(flatten)]
247 pub custom: HashMap<String, Vec<String>>,
248}
249
250fn default_enable() -> Vec<String> {
251 vec!["*".to_string()]
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ProfileThresholds {
257 #[serde(default = "default_max_function_lines")]
259 pub max_function_lines: usize,
260
261 #[serde(default = "default_max_complexity")]
263 pub max_complexity: usize,
264
265 #[serde(default = "default_max_cognitive_complexity")]
267 pub max_cognitive_complexity: usize,
268
269 #[serde(default = "default_max_file_lines")]
271 pub max_file_lines: usize,
272}
273
274fn default_max_function_lines() -> usize {
275 100
276}
277
278fn default_max_complexity() -> usize {
279 15
280}
281
282fn default_max_cognitive_complexity() -> usize {
283 20
284}
285
286fn default_max_file_lines() -> usize {
287 1000
288}
289
290impl Default for ProfileThresholds {
291 fn default() -> Self {
292 Self {
293 max_function_lines: default_max_function_lines(),
294 max_complexity: default_max_complexity(),
295 max_cognitive_complexity: default_max_cognitive_complexity(),
296 max_file_lines: default_max_file_lines(),
297 }
298 }
299}
300
301impl ProfileThresholds {
302 pub fn for_profile(profile: Profile) -> Self {
304 match profile {
305 Profile::Fast => Self {
306 max_function_lines: 200,
307 max_complexity: 25,
308 max_cognitive_complexity: 35,
309 max_file_lines: 2000,
310 },
311 Profile::Balanced => Self::default(),
312 Profile::Strict => Self {
313 max_function_lines: 50,
314 max_complexity: 10,
315 max_cognitive_complexity: 15,
316 max_file_lines: 500,
317 },
318 }
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, Default)]
324pub struct ProfilesConfig {
325 #[serde(default)]
327 pub default: Profile,
328
329 #[serde(default = "fast_profile_defaults")]
331 pub fast: ProfileThresholds,
332
333 #[serde(default)]
335 pub balanced: ProfileThresholds,
336
337 #[serde(default = "strict_profile_defaults")]
339 pub strict: ProfileThresholds,
340}
341
342fn fast_profile_defaults() -> ProfileThresholds {
343 ProfileThresholds::for_profile(Profile::Fast)
344}
345
346fn strict_profile_defaults() -> ProfileThresholds {
347 ProfileThresholds::for_profile(Profile::Strict)
348}
349
350impl ProfilesConfig {
351 pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
353 match profile {
354 Profile::Fast => &self.fast,
355 Profile::Balanced => &self.balanced,
356 Profile::Strict => &self.strict,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ThresholdOverride {
364 pub path: String,
366
367 #[serde(default)]
369 pub max_function_lines: Option<usize>,
370
371 #[serde(default)]
373 pub max_complexity: Option<usize>,
374
375 #[serde(default)]
377 pub max_cognitive_complexity: Option<usize>,
378
379 #[serde(default)]
381 pub disable_rules: Vec<String>,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, Default)]
386pub struct AllowConfig {
387 #[serde(default)]
389 pub settimeout_string: bool,
390
391 #[serde(default = "default_true")]
393 pub settimeout_function: bool,
394
395 #[serde(default)]
397 pub innerhtml_paths: Vec<String>,
398
399 #[serde(default)]
401 pub eval_paths: Vec<String>,
402
403 #[serde(default)]
405 pub unsafe_rust_paths: Vec<String>,
406
407 #[serde(default)]
409 pub approved_secrets: Vec<String>,
410}
411
412fn default_true() -> bool {
413 true
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
422#[serde(rename_all = "lowercase")]
423pub enum ProviderType {
424 Rma,
426 Pmd,
428 Oxlint,
430 Oxc,
432 RustSec,
434 Gosec,
436 Osv,
438}
439
440impl std::fmt::Display for ProviderType {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 match self {
443 ProviderType::Rma => write!(f, "rma"),
444 ProviderType::Pmd => write!(f, "pmd"),
445 ProviderType::Oxlint => write!(f, "oxlint"),
446 ProviderType::Oxc => write!(f, "oxc"),
447 ProviderType::RustSec => write!(f, "rustsec"),
448 ProviderType::Gosec => write!(f, "gosec"),
449 ProviderType::Osv => write!(f, "osv"),
450 }
451 }
452}
453
454impl std::str::FromStr for ProviderType {
455 type Err = String;
456
457 fn from_str(s: &str) -> Result<Self, Self::Err> {
458 match s.to_lowercase().as_str() {
459 "rma" => Ok(ProviderType::Rma),
460 "pmd" => Ok(ProviderType::Pmd),
461 "oxlint" => Ok(ProviderType::Oxlint),
462 "oxc" => Ok(ProviderType::Oxc),
463 "rustsec" => Ok(ProviderType::RustSec),
464 "gosec" => Ok(ProviderType::Gosec),
465 "osv" => Ok(ProviderType::Osv),
466 _ => Err(format!(
467 "Unknown provider: {}. Available: rma, pmd, oxlint, oxc, rustsec, gosec, osv",
468 s
469 )),
470 }
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ProvidersConfig {
477 #[serde(default = "default_enabled_providers")]
479 pub enabled: Vec<ProviderType>,
480
481 #[serde(default)]
483 pub pmd: PmdProviderConfig,
484
485 #[serde(default)]
487 pub oxlint: OxlintProviderConfig,
488
489 #[serde(default)]
491 pub oxc: OxcProviderConfig,
492
493 #[serde(default)]
495 pub gosec: GosecProviderConfig,
496
497 #[serde(default)]
499 pub osv: OsvProviderConfig,
500}
501
502impl Default for ProvidersConfig {
503 fn default() -> Self {
504 Self {
505 enabled: default_enabled_providers(),
506 pmd: PmdProviderConfig::default(),
507 oxlint: OxlintProviderConfig::default(),
508 oxc: OxcProviderConfig::default(),
509 gosec: GosecProviderConfig::default(),
510 osv: OsvProviderConfig::default(),
511 }
512 }
513}
514
515fn default_enabled_providers() -> Vec<ProviderType> {
516 vec![ProviderType::Rma, ProviderType::Oxc]
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct PmdProviderConfig {
524 #[serde(default)]
526 pub configured: bool,
527
528 #[serde(default = "default_java_path")]
530 pub java_path: String,
531
532 #[serde(default)]
535 pub pmd_path: String,
536
537 #[serde(default = "default_pmd_rulesets")]
539 pub rulesets: Vec<String>,
540
541 #[serde(default = "default_pmd_timeout")]
543 pub timeout_ms: u64,
544
545 #[serde(default = "default_pmd_include_patterns")]
547 pub include_patterns: Vec<String>,
548
549 #[serde(default = "default_pmd_exclude_patterns")]
551 pub exclude_patterns: Vec<String>,
552
553 #[serde(default = "default_pmd_severity_map")]
557 pub severity_map: HashMap<String, Severity>,
558
559 #[serde(default)]
561 pub fail_on_error: bool,
562
563 #[serde(default = "default_pmd_min_priority")]
565 pub min_priority: u8,
566
567 #[serde(default)]
569 pub extra_args: Vec<String>,
570}
571
572impl Default for PmdProviderConfig {
573 fn default() -> Self {
574 Self {
575 configured: false,
576 java_path: default_java_path(),
577 pmd_path: String::new(),
578 rulesets: default_pmd_rulesets(),
579 timeout_ms: default_pmd_timeout(),
580 include_patterns: default_pmd_include_patterns(),
581 exclude_patterns: default_pmd_exclude_patterns(),
582 severity_map: default_pmd_severity_map(),
583 fail_on_error: false,
584 min_priority: default_pmd_min_priority(),
585 extra_args: Vec::new(),
586 }
587 }
588}
589
590fn default_java_path() -> String {
591 "java".to_string()
592}
593
594fn default_pmd_rulesets() -> Vec<String> {
595 vec![
596 "category/java/security.xml".to_string(),
597 "category/java/bestpractices.xml".to_string(),
598 "category/java/errorprone.xml".to_string(),
599 ]
600}
601
602fn default_pmd_timeout() -> u64 {
603 600_000 }
605
606fn default_pmd_include_patterns() -> Vec<String> {
607 vec!["**/*.java".to_string()]
608}
609
610fn default_pmd_exclude_patterns() -> Vec<String> {
611 vec![
612 "**/target/**".to_string(),
613 "**/build/**".to_string(),
614 "**/generated/**".to_string(),
615 "**/out/**".to_string(),
616 "**/.git/**".to_string(),
617 "**/node_modules/**".to_string(),
618 ]
619}
620
621fn default_pmd_severity_map() -> HashMap<String, Severity> {
622 let mut map = HashMap::new();
623 map.insert("1".to_string(), Severity::Critical);
624 map.insert("2".to_string(), Severity::Error);
625 map.insert("3".to_string(), Severity::Warning);
626 map.insert("4".to_string(), Severity::Info);
627 map.insert("5".to_string(), Severity::Info);
628 map
629}
630
631fn default_pmd_min_priority() -> u8 {
632 5 }
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct OxlintProviderConfig {
638 #[serde(default)]
640 pub configured: bool,
641
642 #[serde(default)]
644 pub binary_path: String,
645
646 #[serde(default = "default_oxlint_timeout")]
648 pub timeout_ms: u64,
649
650 #[serde(default)]
652 pub extra_args: Vec<String>,
653}
654
655impl Default for OxlintProviderConfig {
656 fn default() -> Self {
657 Self {
658 configured: false,
659 binary_path: String::new(),
660 timeout_ms: default_oxlint_timeout(),
661 extra_args: Vec::new(),
662 }
663 }
664}
665
666fn default_oxlint_timeout() -> u64 {
667 300_000 }
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct GosecProviderConfig {
673 #[serde(default)]
675 pub configured: bool,
676
677 #[serde(default)]
679 pub binary_path: String,
680
681 #[serde(default = "default_gosec_timeout")]
683 pub timeout_ms: u64,
684
685 #[serde(default)]
687 pub exclude_rules: Vec<String>,
688
689 #[serde(default)]
691 pub include_rules: Vec<String>,
692
693 #[serde(default)]
695 pub extra_args: Vec<String>,
696}
697
698impl Default for GosecProviderConfig {
699 fn default() -> Self {
700 Self {
701 configured: false,
702 binary_path: String::new(),
703 timeout_ms: default_gosec_timeout(),
704 exclude_rules: Vec::new(),
705 include_rules: Vec::new(),
706 extra_args: Vec::new(),
707 }
708 }
709}
710
711fn default_gosec_timeout() -> u64 {
712 300_000 }
714
715#[derive(Debug, Clone, Default, Serialize, Deserialize)]
717pub struct OxcProviderConfig {
718 #[serde(default)]
720 pub configured: bool,
721
722 #[serde(default)]
724 pub enable_rules: Vec<String>,
725
726 #[serde(default)]
728 pub disable_rules: Vec<String>,
729
730 #[serde(default)]
732 pub severity_overrides: HashMap<String, Severity>,
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
737#[serde(rename_all = "lowercase")]
738pub enum OsvEcosystem {
739 #[serde(rename = "crates.io")]
741 CratesIo,
742 Npm,
744 PyPI,
746 Go,
748 Maven,
750}
751
752impl std::fmt::Display for OsvEcosystem {
753 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 match self {
755 OsvEcosystem::CratesIo => write!(f, "crates.io"),
756 OsvEcosystem::Npm => write!(f, "npm"),
757 OsvEcosystem::PyPI => write!(f, "PyPI"),
758 OsvEcosystem::Go => write!(f, "Go"),
759 OsvEcosystem::Maven => write!(f, "Maven"),
760 }
761 }
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct OsvProviderConfig {
767 #[serde(default)]
769 pub configured: bool,
770
771 #[serde(default)]
773 pub include_dev_deps: bool,
774
775 #[serde(default = "default_osv_cache_ttl")]
778 pub cache_ttl: String,
779
780 #[serde(default = "default_osv_ecosystems")]
782 pub enabled_ecosystems: Vec<OsvEcosystem>,
783
784 #[serde(default)]
787 pub severity_overrides: HashMap<String, Severity>,
788
789 #[serde(default)]
792 pub ignore_list: Vec<String>,
793
794 #[serde(default)]
796 pub offline: bool,
797
798 #[serde(default)]
800 pub cache_dir: Option<PathBuf>,
801}
802
803impl Default for OsvProviderConfig {
804 fn default() -> Self {
805 Self {
806 configured: false,
807 include_dev_deps: false,
808 cache_ttl: default_osv_cache_ttl(),
809 enabled_ecosystems: default_osv_ecosystems(),
810 severity_overrides: HashMap::new(),
811 ignore_list: Vec::new(),
812 offline: false,
813 cache_dir: None,
814 }
815 }
816}
817
818fn default_osv_cache_ttl() -> String {
819 "24h".to_string()
820}
821
822fn default_osv_ecosystems() -> Vec<OsvEcosystem> {
823 vec![
824 OsvEcosystem::CratesIo,
825 OsvEcosystem::Npm,
826 OsvEcosystem::PyPI,
827 OsvEcosystem::Go,
828 OsvEcosystem::Maven,
829 ]
830}
831
832#[derive(Debug, Clone, Serialize, Deserialize, Default)]
834pub struct BaselineConfig {
835 #[serde(default = "default_baseline_file")]
837 pub file: PathBuf,
838
839 #[serde(default)]
841 pub mode: BaselineMode,
842}
843
844fn default_baseline_file() -> PathBuf {
845 PathBuf::from(".rma/baseline.json")
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
854pub struct SuppressionConfig {
855 #[serde(default = "default_suppression_database")]
857 pub database: PathBuf,
858
859 #[serde(default = "default_expiration")]
861 pub default_expiration: String,
862
863 #[serde(default)]
865 pub require_ticket: bool,
866
867 #[serde(default = "default_max_expiration")]
869 pub max_expiration: String,
870
871 #[serde(default = "default_enabled")]
873 pub enabled: bool,
874}
875
876impl Default for SuppressionConfig {
877 fn default() -> Self {
878 Self {
879 database: default_suppression_database(),
880 default_expiration: default_expiration(),
881 require_ticket: false,
882 max_expiration: default_max_expiration(),
883 enabled: true,
884 }
885 }
886}
887
888fn default_suppression_database() -> PathBuf {
889 PathBuf::from(".rma/suppressions.db")
890}
891
892fn default_expiration() -> String {
893 "90d".to_string()
894}
895
896fn default_max_expiration() -> String {
897 "365d".to_string()
898}
899
900fn default_enabled() -> bool {
901 true
902}
903
904pub fn parse_expiration_days(s: &str) -> Option<u32> {
906 let s = s.trim().to_lowercase();
907 if let Some(days_str) = s.strip_suffix('d') {
908 days_str.parse().ok()
909 } else if let Some(weeks_str) = s.strip_suffix('w') {
910 weeks_str.parse::<u32>().ok().map(|w| w * 7)
911 } else if let Some(months_str) = s.strip_suffix('m') {
912 months_str.parse::<u32>().ok().map(|m| m * 30)
913 } else {
914 s.parse().ok()
916 }
917}
918
919pub const CURRENT_CONFIG_VERSION: u32 = 1;
921
922#[derive(Debug, Clone, Serialize, Deserialize, Default)]
924pub struct RmaTomlConfig {
925 #[serde(default)]
927 pub config_version: Option<u32>,
928
929 #[serde(default)]
931 pub scan: ScanConfig,
932
933 #[serde(default)]
935 pub rules: RulesConfig,
936
937 #[serde(default)]
939 pub rulesets: RulesetsConfig,
940
941 #[serde(default)]
943 pub profiles: ProfilesConfig,
944
945 #[serde(default)]
947 pub severity: HashMap<String, Severity>,
948
949 #[serde(default)]
951 pub threshold_overrides: Vec<ThresholdOverride>,
952
953 #[serde(default)]
955 pub allow: AllowConfig,
956
957 #[serde(default)]
959 pub baseline: BaselineConfig,
960
961 #[serde(default)]
963 pub providers: ProvidersConfig,
964
965 #[serde(default)]
967 pub suppressions: SuppressionConfig,
968}
969
970#[derive(Debug)]
972pub struct ConfigLoadResult {
973 pub config: RmaTomlConfig,
975 pub version_warning: Option<String>,
977}
978
979impl RmaTomlConfig {
980 pub fn load(path: &Path) -> Result<Self, String> {
982 let result = Self::load_with_validation(path)?;
983 Ok(result.config)
984 }
985
986 pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
988 let content = std::fs::read_to_string(path)
989 .map_err(|e| format!("Failed to read config file: {}", e))?;
990
991 let config: RmaTomlConfig =
992 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
993
994 let version_warning = config.validate_version()?;
996
997 Ok(ConfigLoadResult {
998 config,
999 version_warning,
1000 })
1001 }
1002
1003 fn validate_version(&self) -> Result<Option<String>, String> {
1005 match self.config_version {
1006 Some(CURRENT_CONFIG_VERSION) => Ok(None),
1007 Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
1008 "Unsupported config version: {}. Maximum supported version is {}. \
1009 Please upgrade RMA or use a compatible config format.",
1010 version, CURRENT_CONFIG_VERSION
1011 )),
1012 Some(version) => {
1013 Err(format!(
1015 "Invalid config version: {}. Expected version {}.",
1016 version, CURRENT_CONFIG_VERSION
1017 ))
1018 }
1019 None => Ok(Some(
1020 "Config file is missing 'config_version'. Assuming version 1. \
1021 Add 'config_version = 1' to suppress this warning."
1022 .to_string(),
1023 )),
1024 }
1025 }
1026
1027 pub fn has_version(&self) -> bool {
1029 self.config_version.is_some()
1030 }
1031
1032 pub fn effective_version(&self) -> u32 {
1034 self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
1035 }
1036
1037 pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
1039 let candidates = [
1040 start_path.join("rma.toml"),
1041 start_path.join(".rma/rma.toml"),
1042 start_path.join(".rma.toml"),
1043 ];
1044
1045 for candidate in &candidates {
1046 if candidate.exists()
1047 && let Ok(config) = Self::load(candidate)
1048 {
1049 return Some((candidate.clone(), config));
1050 }
1051 }
1052
1053 let mut current = start_path.to_path_buf();
1055 for _ in 0..5 {
1056 if let Some(parent) = current.parent() {
1057 let config_path = parent.join("rma.toml");
1058 if config_path.exists()
1059 && let Ok(config) = Self::load(&config_path)
1060 {
1061 return Some((config_path, config));
1062 }
1063 current = parent.to_path_buf();
1064 } else {
1065 break;
1066 }
1067 }
1068
1069 None
1070 }
1071
1072 pub fn validate(&self) -> Vec<ConfigWarning> {
1074 let mut warnings = Vec::new();
1075
1076 if self.config_version.is_none() {
1078 warnings.push(ConfigWarning {
1079 level: WarningLevel::Warning,
1080 message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
1081 .to_string(),
1082 });
1083 } else if let Some(version) = self.config_version
1084 && version > CURRENT_CONFIG_VERSION
1085 {
1086 warnings.push(ConfigWarning {
1087 level: WarningLevel::Error,
1088 message: format!(
1089 "Unsupported config version: {}. Maximum supported is {}.",
1090 version, CURRENT_CONFIG_VERSION
1091 ),
1092 });
1093 }
1094
1095 for disabled in &self.rules.disable {
1097 for enabled in &self.rules.enable {
1098 if enabled == disabled {
1099 warnings.push(ConfigWarning {
1100 level: WarningLevel::Warning,
1101 message: format!(
1102 "Rule '{}' is both enabled and disabled (disable takes precedence)",
1103 disabled
1104 ),
1105 });
1106 }
1107 }
1108 }
1109
1110 for (i, override_) in self.threshold_overrides.iter().enumerate() {
1112 if override_.path.is_empty() {
1113 warnings.push(ConfigWarning {
1114 level: WarningLevel::Error,
1115 message: format!("threshold_overrides[{}]: path cannot be empty", i),
1116 });
1117 }
1118 }
1119
1120 if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
1122 warnings.push(ConfigWarning {
1123 level: WarningLevel::Warning,
1124 message: format!(
1125 "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
1126 self.baseline.file.display()
1127 ),
1128 });
1129 }
1130
1131 for rule_id in self.severity.keys() {
1133 if rule_id.is_empty() {
1134 warnings.push(ConfigWarning {
1135 level: WarningLevel::Error,
1136 message: "Empty rule ID in severity overrides".to_string(),
1137 });
1138 }
1139 }
1140
1141 if self.providers.enabled.contains(&ProviderType::Pmd) {
1143 if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
1145 warnings.push(ConfigWarning {
1146 level: WarningLevel::Warning,
1147 message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
1148 });
1149 }
1150
1151 if self.providers.pmd.rulesets.is_empty() {
1153 warnings.push(ConfigWarning {
1154 level: WarningLevel::Warning,
1155 message:
1156 "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
1157 .to_string(),
1158 });
1159 }
1160
1161 for priority in self.providers.pmd.severity_map.keys() {
1163 if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
1164 warnings.push(ConfigWarning {
1165 level: WarningLevel::Warning,
1166 message: format!(
1167 "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
1168 priority
1169 ),
1170 });
1171 }
1172 }
1173 }
1174
1175 if self.providers.enabled.contains(&ProviderType::Oxlint)
1176 && !self.providers.oxlint.configured
1177 {
1178 warnings.push(ConfigWarning {
1179 level: WarningLevel::Warning,
1180 message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
1181 });
1182 }
1183
1184 warnings
1185 }
1186
1187 pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
1189 self.providers.enabled.contains(&provider)
1190 }
1191
1192 pub fn get_enabled_providers(&self) -> &[ProviderType] {
1194 &self.providers.enabled
1195 }
1196
1197 pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
1199 if self.is_provider_enabled(ProviderType::Pmd) {
1200 Some(&self.providers.pmd)
1201 } else {
1202 None
1203 }
1204 }
1205
1206 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
1208 self.is_rule_enabled_with_ruleset(rule_id, None)
1209 }
1210
1211 pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
1216 for pattern in &self.rules.disable {
1218 if Self::matches_pattern(rule_id, pattern) {
1219 return false;
1220 }
1221 }
1222
1223 if let Some(ruleset_name) = ruleset {
1225 let ruleset_rules = self.get_ruleset_rules(ruleset_name);
1226 if !ruleset_rules.is_empty() {
1227 return ruleset_rules
1229 .iter()
1230 .any(|r| Self::matches_pattern(rule_id, r));
1231 }
1232 }
1233
1234 for pattern in &self.rules.enable {
1236 if Self::matches_pattern(rule_id, pattern) {
1237 return true;
1238 }
1239 }
1240
1241 false
1242 }
1243
1244 pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
1246 match name {
1247 "security" => self.rulesets.security.clone(),
1248 "maintainability" => self.rulesets.maintainability.clone(),
1249 _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
1250 }
1251 }
1252
1253 pub fn get_ruleset_names(&self) -> Vec<String> {
1255 let mut names = Vec::new();
1256 if !self.rulesets.security.is_empty() {
1257 names.push("security".to_string());
1258 }
1259 if !self.rulesets.maintainability.is_empty() {
1260 names.push("maintainability".to_string());
1261 }
1262 names.extend(self.rulesets.custom.keys().cloned());
1263 names
1264 }
1265
1266 pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
1268 self.severity.get(rule_id).copied()
1269 }
1270
1271 pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
1273 let mut thresholds = self.profiles.get_thresholds(profile).clone();
1274
1275 let path_str = path.to_string_lossy();
1277 for override_ in &self.threshold_overrides {
1278 if Self::matches_glob(&path_str, &override_.path) {
1279 if let Some(v) = override_.max_function_lines {
1280 thresholds.max_function_lines = v;
1281 }
1282 if let Some(v) = override_.max_complexity {
1283 thresholds.max_complexity = v;
1284 }
1285 if let Some(v) = override_.max_cognitive_complexity {
1286 thresholds.max_cognitive_complexity = v;
1287 }
1288 }
1289 }
1290
1291 thresholds
1292 }
1293
1294 pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
1296 let path_str = path.to_string_lossy();
1297 let allowed_paths = match rule_type {
1298 AllowType::InnerHtml => &self.allow.innerhtml_paths,
1299 AllowType::Eval => &self.allow.eval_paths,
1300 AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
1301 };
1302
1303 for pattern in allowed_paths {
1304 if Self::matches_glob(&path_str, pattern) {
1305 return true;
1306 }
1307 }
1308
1309 false
1310 }
1311
1312 fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
1313 if pattern == "*" {
1314 return true;
1315 }
1316
1317 if let Some(prefix) = pattern.strip_suffix("/*") {
1318 return rule_id.starts_with(prefix);
1319 }
1320
1321 rule_id == pattern
1322 }
1323
1324 fn matches_glob(path: &str, pattern: &str) -> bool {
1325 let pattern = pattern
1327 .replace("**", "§")
1328 .replace('*', "[^/]*")
1329 .replace('§', ".*");
1330 regex::Regex::new(&format!("^{}$", pattern))
1331 .map(|re| re.is_match(path))
1332 .unwrap_or(false)
1333 }
1334
1335 pub fn default_toml(profile: Profile) -> String {
1337 let thresholds = ProfileThresholds::for_profile(profile);
1338
1339 format!(
1340 r#"# RMA Configuration
1341# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
1342
1343# Config format version (required for future compatibility)
1344config_version = 1
1345
1346[scan]
1347# Paths to include in scanning (default: all supported files)
1348include = ["src/**", "lib/**", "scripts/**"]
1349
1350# Paths to exclude from scanning
1351exclude = [
1352 "node_modules/**",
1353 "target/**",
1354 "dist/**",
1355 "build/**",
1356 "vendor/**",
1357 "**/*.min.js",
1358 "**/*.bundle.js",
1359]
1360
1361# Maximum file size to scan (10MB default)
1362max_file_size = 10485760
1363
1364[rules]
1365# Rules to enable (wildcards supported)
1366enable = ["*"]
1367
1368# Rules to disable (takes precedence over enable)
1369disable = []
1370
1371# Global ignore paths - findings in these paths are suppressed for all rules
1372# Supports glob patterns. Uncomment to customize.
1373# ignore_paths = ["**/vendor/**", "**/generated/**"]
1374
1375# Per-rule ignore paths - suppress specific rules in specific paths
1376# Note: Security rules (command-injection, hardcoded-secret, etc.) cannot be
1377# suppressed via path ignores, only via inline comments with reason.
1378# [rules.ignore_paths_by_rule]
1379# "generic/long-function" = ["**/tests/**", "**/examples/**"]
1380# "js/console-log" = ["**/debug/**"]
1381
1382# Default test/example suppressions are automatically applied in --mode pr/ci
1383# This reduces noise from test files. Security rules are NOT suppressed.
1384
1385[profiles]
1386# Default profile: fast, balanced, or strict
1387default = "{profile}"
1388
1389[profiles.fast]
1390max_function_lines = 200
1391max_complexity = 25
1392max_cognitive_complexity = 35
1393max_file_lines = 2000
1394
1395[profiles.balanced]
1396max_function_lines = {max_function_lines}
1397max_complexity = {max_complexity}
1398max_cognitive_complexity = {max_cognitive_complexity}
1399max_file_lines = 1000
1400
1401[profiles.strict]
1402max_function_lines = 50
1403max_complexity = 10
1404max_cognitive_complexity = 15
1405max_file_lines = 500
1406
1407[rulesets]
1408# Named rule groups for targeted scanning
1409security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
1410maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
1411
1412[severity]
1413# Override severity for specific rules
1414# "generic/long-function" = "warning"
1415# "js/innerhtml-xss" = "error"
1416# "rust/unsafe-block" = "warning"
1417
1418# [[threshold_overrides]]
1419# path = "src/legacy/**"
1420# max_function_lines = 300
1421# max_complexity = 30
1422
1423# [[threshold_overrides]]
1424# path = "tests/**"
1425# disable_rules = ["generic/long-function"]
1426
1427[allow]
1428# Approved patterns that won't trigger alerts
1429settimeout_string = false
1430settimeout_function = true
1431innerhtml_paths = []
1432eval_paths = []
1433unsafe_rust_paths = []
1434approved_secrets = []
1435
1436[baseline]
1437# Baseline file for tracking legacy issues
1438file = ".rma/baseline.json"
1439# Mode: "all" or "new-only"
1440mode = "all"
1441
1442# =============================================================================
1443# ANALYSIS PROVIDERS
1444# =============================================================================
1445# RMA supports external analysis providers for extended language coverage.
1446# Providers can be enabled/disabled individually.
1447
1448[providers]
1449# List of enabled providers
1450# Default: ["rma", "oxc"] - built-in rules + native JS/TS linting
1451enabled = ["rma", "oxc"]
1452# To add PMD for Java: enabled = ["rma", "oxc", "pmd"]
1453# To add external Oxlint: enabled = ["rma", "oxc", "oxlint"]
1454
1455# -----------------------------------------------------------------------------
1456# PMD Provider - Java Static Analysis (optional)
1457# -----------------------------------------------------------------------------
1458# PMD provides comprehensive Java security and quality analysis.
1459# Requires: Java runtime and PMD installation
1460#
1461# [providers.pmd]
1462# configured = true
1463# java_path = "java" # Path to java binary
1464# pmd_path = "" # Path to pmd binary (or leave empty to use PATH)
1465# rulesets = [
1466# "category/java/security.xml",
1467# "category/java/bestpractices.xml",
1468# "category/java/errorprone.xml",
1469# ]
1470# timeout_ms = 600000 # 10 minutes timeout
1471# include_patterns = ["**/*.java"]
1472# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1473# fail_on_error = false # Continue scan if PMD fails
1474# min_priority = 5 # Report all priorities (1-5)
1475# extra_args = [] # Additional PMD CLI arguments
1476
1477# [providers.pmd.severity_map]
1478# # Map PMD priority (1-5) to RMA severity
1479# "1" = "critical"
1480# "2" = "error"
1481# "3" = "warning"
1482# "4" = "info"
1483# "5" = "info"
1484
1485# -----------------------------------------------------------------------------
1486# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1487# -----------------------------------------------------------------------------
1488# [providers.oxlint]
1489# configured = true
1490# binary_path = "" # Path to oxlint binary (or leave empty to use PATH)
1491# timeout_ms = 300000 # 5 minutes timeout
1492# extra_args = []
1493"#,
1494 profile = profile,
1495 max_function_lines = thresholds.max_function_lines,
1496 max_complexity = thresholds.max_complexity,
1497 max_cognitive_complexity = thresholds.max_cognitive_complexity,
1498 )
1499 }
1500}
1501
1502#[derive(Debug, Clone, Copy)]
1504pub enum AllowType {
1505 InnerHtml,
1506 Eval,
1507 UnsafeRust,
1508}
1509
1510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1512#[serde(rename_all = "kebab-case")]
1513pub enum ConfigSource {
1514 Default,
1516 ConfigFile,
1518 CliFlag,
1520}
1521
1522impl std::fmt::Display for ConfigSource {
1523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1524 match self {
1525 ConfigSource::Default => write!(f, "default"),
1526 ConfigSource::ConfigFile => write!(f, "config-file"),
1527 ConfigSource::CliFlag => write!(f, "cli-flag"),
1528 }
1529 }
1530}
1531
1532#[derive(Debug, Clone, Serialize, Deserialize)]
1539pub struct EffectiveConfig {
1540 pub config_file: Option<PathBuf>,
1542
1543 pub profile: Profile,
1545
1546 pub profile_source: ConfigSource,
1548
1549 pub thresholds: ProfileThresholds,
1551
1552 pub enabled_rules_count: usize,
1554
1555 pub disabled_rules_count: usize,
1557
1558 pub severity_overrides_count: usize,
1560
1561 pub threshold_override_paths: Vec<String>,
1563
1564 pub baseline_mode: BaselineMode,
1566
1567 pub baseline_mode_source: ConfigSource,
1569
1570 pub exclude_patterns: Vec<String>,
1572
1573 pub include_patterns: Vec<String>,
1575}
1576
1577impl EffectiveConfig {
1578 pub fn resolve(
1580 toml_config: Option<&RmaTomlConfig>,
1581 config_path: Option<&Path>,
1582 cli_profile: Option<Profile>,
1583 cli_baseline_mode: bool,
1584 ) -> Self {
1585 let (profile, profile_source) = if let Some(p) = cli_profile {
1587 (p, ConfigSource::CliFlag)
1588 } else if let Some(cfg) = toml_config {
1589 (cfg.profiles.default, ConfigSource::ConfigFile)
1590 } else {
1591 (Profile::default(), ConfigSource::Default)
1592 };
1593
1594 let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1596 (BaselineMode::NewOnly, ConfigSource::CliFlag)
1597 } else if let Some(cfg) = toml_config {
1598 (cfg.baseline.mode, ConfigSource::ConfigFile)
1599 } else {
1600 (BaselineMode::default(), ConfigSource::Default)
1601 };
1602
1603 let thresholds = toml_config
1605 .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1606 .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1607
1608 let (enabled_rules_count, disabled_rules_count) = toml_config
1610 .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1611 .unwrap_or((1, 0)); let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1615
1616 let threshold_override_paths = toml_config
1618 .map(|cfg| {
1619 cfg.threshold_overrides
1620 .iter()
1621 .map(|o| o.path.clone())
1622 .collect()
1623 })
1624 .unwrap_or_default();
1625
1626 let exclude_patterns = toml_config
1628 .map(|cfg| cfg.scan.exclude.clone())
1629 .unwrap_or_default();
1630
1631 let include_patterns = toml_config
1632 .map(|cfg| cfg.scan.include.clone())
1633 .unwrap_or_default();
1634
1635 Self {
1636 config_file: config_path.map(|p| p.to_path_buf()),
1637 profile,
1638 profile_source,
1639 thresholds,
1640 enabled_rules_count,
1641 disabled_rules_count,
1642 severity_overrides_count,
1643 threshold_override_paths,
1644 baseline_mode,
1645 baseline_mode_source,
1646 exclude_patterns,
1647 include_patterns,
1648 }
1649 }
1650
1651 pub fn to_text(&self) -> String {
1653 let mut out = String::new();
1654
1655 out.push_str("Effective Configuration\n");
1656 out.push_str("═══════════════════════════════════════════════════════════\n\n");
1657
1658 out.push_str(" Config file: ");
1660 match &self.config_file {
1661 Some(p) => out.push_str(&format!("{}\n", p.display())),
1662 None => out.push_str("(none - using defaults)\n"),
1663 }
1664
1665 out.push_str(&format!(
1667 " Profile: {} (from {})\n",
1668 self.profile, self.profile_source
1669 ));
1670
1671 out.push_str("\n Thresholds:\n");
1673 out.push_str(&format!(
1674 " max_function_lines: {}\n",
1675 self.thresholds.max_function_lines
1676 ));
1677 out.push_str(&format!(
1678 " max_complexity: {}\n",
1679 self.thresholds.max_complexity
1680 ));
1681 out.push_str(&format!(
1682 " max_cognitive_complexity: {}\n",
1683 self.thresholds.max_cognitive_complexity
1684 ));
1685 out.push_str(&format!(
1686 " max_file_lines: {}\n",
1687 self.thresholds.max_file_lines
1688 ));
1689
1690 out.push_str("\n Rules:\n");
1692 out.push_str(&format!(
1693 " enabled patterns: {}\n",
1694 self.enabled_rules_count
1695 ));
1696 out.push_str(&format!(
1697 " disabled patterns: {}\n",
1698 self.disabled_rules_count
1699 ));
1700 out.push_str(&format!(
1701 " severity overrides: {}\n",
1702 self.severity_overrides_count
1703 ));
1704
1705 if !self.threshold_override_paths.is_empty() {
1707 out.push_str("\n Threshold overrides:\n");
1708 for path in &self.threshold_override_paths {
1709 out.push_str(&format!(" - {}\n", path));
1710 }
1711 }
1712
1713 out.push_str(&format!(
1715 "\n Baseline mode: {:?} (from {})\n",
1716 self.baseline_mode, self.baseline_mode_source
1717 ));
1718
1719 out
1720 }
1721
1722 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1724 serde_json::to_string_pretty(self)
1725 }
1726}
1727
1728#[derive(Debug, Clone)]
1730pub struct ConfigWarning {
1731 pub level: WarningLevel,
1732 pub message: String,
1733}
1734
1735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1736pub enum WarningLevel {
1737 Warning,
1738 Error,
1739}
1740
1741#[derive(Debug, Clone, PartialEq, Eq)]
1743pub struct InlineSuppression {
1744 pub rule_id: String,
1746 pub reason: Option<String>,
1748 pub line: usize,
1750 pub suppression_type: SuppressionType,
1752}
1753
1754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1756pub enum SuppressionType {
1757 NextLine,
1759 Block,
1761}
1762
1763impl InlineSuppression {
1764 pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1771 let trimmed = line.trim();
1772
1773 let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1775 rest.trim()
1776 } else if let Some(rest) = trimmed.strip_prefix('#') {
1777 rest.trim()
1778 } else {
1779 return None;
1780 };
1781
1782 if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1784 return Self::parse_suppression_body(
1785 rest.trim(),
1786 line_number,
1787 SuppressionType::NextLine,
1788 );
1789 }
1790
1791 if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1793 return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1794 }
1795
1796 None
1797 }
1798
1799 fn parse_suppression_body(
1800 body: &str,
1801 line_number: usize,
1802 suppression_type: SuppressionType,
1803 ) -> Option<Self> {
1804 if body.is_empty() {
1805 return None;
1806 }
1807
1808 let mut parts = body.splitn(2, ' ');
1810 let rule_id = parts.next()?.trim().to_string();
1811
1812 if rule_id.is_empty() {
1813 return None;
1814 }
1815
1816 let reason = parts.next().and_then(|rest| {
1817 if let Some(start) = rest.find("reason=\"") {
1819 let after_quote = &rest[start + 8..];
1820 if let Some(end) = after_quote.find('"') {
1821 return Some(after_quote[..end].to_string());
1822 }
1823 }
1824 None
1825 });
1826
1827 Some(Self {
1828 rule_id,
1829 reason,
1830 line: line_number,
1831 suppression_type,
1832 })
1833 }
1834
1835 pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1837 if self.rule_id != rule_id && self.rule_id != "*" {
1838 return false;
1839 }
1840
1841 match self.suppression_type {
1842 SuppressionType::NextLine => finding_line == self.line + 1,
1843 SuppressionType::Block => finding_line >= self.line,
1844 }
1845 }
1846
1847 pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1849 if require_reason && self.reason.is_none() {
1850 return Err(format!(
1851 "Suppression for '{}' at line {} requires a reason in strict profile",
1852 self.rule_id, self.line
1853 ));
1854 }
1855 Ok(())
1856 }
1857}
1858
1859pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1861 content
1862 .lines()
1863 .enumerate()
1864 .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1865 .collect()
1866}
1867
1868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1880pub struct Fingerprint(String);
1881
1882impl Fingerprint {
1883 pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1885 use sha2::{Digest, Sha256};
1886
1887 let mut hasher = Sha256::new();
1888
1889 hasher.update(rule_id.as_bytes());
1891 hasher.update(b"|");
1892
1893 let normalized_path = file_path
1895 .to_string_lossy()
1896 .replace('\\', "/")
1897 .to_lowercase();
1898 hasher.update(normalized_path.as_bytes());
1899 hasher.update(b"|");
1900
1901 let normalized_snippet = Self::normalize_snippet(snippet);
1903 hasher.update(normalized_snippet.as_bytes());
1904
1905 let hash = hasher.finalize();
1906 Self(format!("sha256:{:x}", hash))
1907 }
1908
1909 fn normalize_snippet(snippet: &str) -> String {
1911 snippet
1912 .split_whitespace()
1913 .collect::<Vec<_>>()
1914 .join(" ")
1915 .trim()
1916 .to_string()
1917 }
1918
1919 pub fn as_str(&self) -> &str {
1921 &self.0
1922 }
1923
1924 pub fn from_string(s: String) -> Self {
1926 Self(s)
1927 }
1928}
1929
1930impl std::fmt::Display for Fingerprint {
1931 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1932 write!(f, "{}", self.0)
1933 }
1934}
1935
1936impl From<Fingerprint> for String {
1937 fn from(fp: Fingerprint) -> String {
1938 fp.0
1939 }
1940}
1941
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1944pub struct BaselineEntry {
1945 pub rule_id: String,
1946 pub file: PathBuf,
1947 #[serde(default)]
1948 pub line: usize,
1949 pub fingerprint: String,
1950 pub first_seen: String,
1951 #[serde(default)]
1952 pub suppressed: bool,
1953 #[serde(default)]
1954 pub comment: Option<String>,
1955}
1956
1957#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1959pub struct Baseline {
1960 pub version: String,
1961 pub created: String,
1962 pub entries: Vec<BaselineEntry>,
1963}
1964
1965impl Baseline {
1966 pub fn new() -> Self {
1967 Self {
1968 version: "1.0".to_string(),
1969 created: chrono::Utc::now().to_rfc3339(),
1970 entries: Vec::new(),
1971 }
1972 }
1973
1974 pub fn load(path: &Path) -> Result<Self, String> {
1975 let content = std::fs::read_to_string(path)
1976 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1977
1978 serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1979 }
1980
1981 pub fn save(&self, path: &Path) -> Result<(), String> {
1982 if let Some(parent) = path.parent() {
1983 std::fs::create_dir_all(parent)
1984 .map_err(|e| format!("Failed to create directory: {}", e))?;
1985 }
1986
1987 let content = serde_json::to_string_pretty(self)
1988 .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1989
1990 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1991 }
1992
1993 pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1995 self.entries
1996 .iter()
1997 .any(|e| e.fingerprint == fingerprint.as_str())
1998 }
1999
2000 pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
2002 self.entries
2003 .iter()
2004 .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
2005 }
2006
2007 pub fn add_with_fingerprint(
2009 &mut self,
2010 rule_id: String,
2011 file: PathBuf,
2012 line: usize,
2013 fingerprint: Fingerprint,
2014 ) {
2015 if !self.contains_fingerprint(&fingerprint) {
2016 self.entries.push(BaselineEntry {
2017 rule_id,
2018 file,
2019 line,
2020 fingerprint: fingerprint.into(),
2021 first_seen: chrono::Utc::now().to_rfc3339(),
2022 suppressed: false,
2023 comment: None,
2024 });
2025 }
2026 }
2027
2028 pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
2030 if !self.contains(&rule_id, &file, &fingerprint) {
2031 self.entries.push(BaselineEntry {
2032 rule_id,
2033 file,
2034 line,
2035 fingerprint,
2036 first_seen: chrono::Utc::now().to_rfc3339(),
2037 suppressed: false,
2038 comment: None,
2039 });
2040 }
2041 }
2042}
2043
2044#[derive(Debug, Clone, Serialize, Deserialize)]
2050pub struct SuppressionResult {
2051 pub suppressed: bool,
2053 pub reason: Option<String>,
2055 pub source: Option<SuppressionSource>,
2057 pub location: Option<String>,
2059}
2060
2061impl SuppressionResult {
2062 pub fn not_suppressed() -> Self {
2064 Self {
2065 suppressed: false,
2066 reason: None,
2067 source: None,
2068 location: None,
2069 }
2070 }
2071
2072 pub fn suppressed(source: SuppressionSource, reason: String, location: String) -> Self {
2074 Self {
2075 suppressed: true,
2076 reason: Some(reason),
2077 source: Some(source),
2078 location: Some(location),
2079 }
2080 }
2081}
2082
2083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2085#[serde(rename_all = "kebab-case")]
2086pub enum SuppressionSource {
2087 Inline,
2089 PathGlobal,
2091 PathRule,
2093 Preset,
2095 Baseline,
2097 Database,
2099}
2100
2101impl std::fmt::Display for SuppressionSource {
2102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2103 match self {
2104 SuppressionSource::Inline => write!(f, "inline"),
2105 SuppressionSource::PathGlobal => write!(f, "path-global"),
2106 SuppressionSource::PathRule => write!(f, "path-rule"),
2107 SuppressionSource::Preset => write!(f, "preset"),
2108 SuppressionSource::Baseline => write!(f, "baseline"),
2109 SuppressionSource::Database => write!(f, "database"),
2110 }
2111 }
2112}
2113
2114pub struct SuppressionEngine {
2124 global_ignore_paths: Vec<String>,
2126 rule_ignore_paths: HashMap<String, Vec<String>>,
2128 use_default_presets: bool,
2130 skip_security_in_tests: bool,
2132 baseline: Option<Baseline>,
2134 global_patterns: Vec<regex::Regex>,
2136 rule_patterns: HashMap<String, Vec<regex::Regex>>,
2138 test_patterns: Vec<regex::Regex>,
2140 example_patterns: Vec<regex::Regex>,
2142 suppression_store: Option<std::sync::Arc<crate::suppression::SuppressionStore>>,
2144}
2145
2146impl SuppressionEngine {
2147 pub fn new(rules_config: &RulesConfig, use_default_presets: bool) -> Self {
2149 let global_patterns = rules_config
2150 .ignore_paths
2151 .iter()
2152 .filter_map(|p| Self::compile_glob(p))
2153 .collect();
2154
2155 let mut rule_patterns = HashMap::new();
2156 for (rule_id, paths) in &rules_config.ignore_paths_by_rule {
2157 let patterns: Vec<regex::Regex> =
2158 paths.iter().filter_map(|p| Self::compile_glob(p)).collect();
2159 if !patterns.is_empty() {
2160 rule_patterns.insert(rule_id.clone(), patterns);
2161 }
2162 }
2163
2164 let test_patterns = if use_default_presets {
2165 DEFAULT_TEST_IGNORE_PATHS
2166 .iter()
2167 .filter_map(|p| Self::compile_glob(p))
2168 .collect()
2169 } else {
2170 Vec::new()
2171 };
2172
2173 let example_patterns = if use_default_presets {
2174 DEFAULT_EXAMPLE_IGNORE_PATHS
2175 .iter()
2176 .filter_map(|p| Self::compile_glob(p))
2177 .collect()
2178 } else {
2179 Vec::new()
2180 };
2181
2182 Self {
2183 global_ignore_paths: rules_config.ignore_paths.clone(),
2184 rule_ignore_paths: rules_config.ignore_paths_by_rule.clone(),
2185 use_default_presets,
2186 skip_security_in_tests: false,
2187 baseline: None,
2188 global_patterns,
2189 rule_patterns,
2190 test_patterns,
2191 example_patterns,
2192 suppression_store: None,
2193 }
2194 }
2195
2196 pub fn with_defaults_only() -> Self {
2198 Self::new(&RulesConfig::default(), true)
2199 }
2200
2201 pub fn with_baseline(mut self, baseline: Baseline) -> Self {
2203 self.baseline = Some(baseline);
2204 self
2205 }
2206
2207 pub fn with_store(mut self, store: crate::suppression::SuppressionStore) -> Self {
2209 self.suppression_store = Some(std::sync::Arc::new(store));
2210 self
2211 }
2212
2213 pub fn with_store_ref(
2215 mut self,
2216 store: std::sync::Arc<crate::suppression::SuppressionStore>,
2217 ) -> Self {
2218 self.suppression_store = Some(store);
2219 self
2220 }
2221
2222 pub fn store(&self) -> Option<&crate::suppression::SuppressionStore> {
2224 self.suppression_store.as_ref().map(|s| s.as_ref())
2225 }
2226
2227 pub fn with_skip_security_in_tests(mut self, skip: bool) -> Self {
2229 self.skip_security_in_tests = skip;
2230 self
2231 }
2232
2233 fn compile_glob(pattern: &str) -> Option<regex::Regex> {
2239 let regex_pattern = pattern
2240 .replace('.', r"\.")
2241 .replace("**", "§")
2242 .replace('*', "[^/]*")
2243 .replace('§', ".*");
2244
2245 let regex_pattern = if let Some(rest) = regex_pattern.strip_prefix(".*/") {
2248 format!("(^|.*/){}", rest)
2250 } else if regex_pattern.starts_with(".*") {
2251 regex_pattern
2253 } else {
2254 format!("^{}", regex_pattern)
2256 };
2257
2258 regex::Regex::new(&format!("(?i){}$", regex_pattern)).ok()
2259 }
2260
2261 fn matches_patterns(path: &str, patterns: &[regex::Regex]) -> bool {
2263 let normalized = path.replace('\\', "/");
2264 patterns.iter().any(|re| re.is_match(&normalized))
2265 }
2266
2267 pub fn is_always_enabled(rule_id: &str) -> bool {
2269 RULES_ALWAYS_ENABLED.iter().any(|r| {
2270 rule_id == *r
2271 || rule_id.starts_with(&format!("{}:", r))
2272 || r.ends_with("*") && rule_id.starts_with(r.trim_end_matches('*'))
2273 })
2274 }
2275
2276 pub fn check(
2287 &self,
2288 rule_id: &str,
2289 file_path: &Path,
2290 finding_line: usize,
2291 inline_suppressions: &[InlineSuppression],
2292 fingerprint: Option<&str>,
2293 ) -> SuppressionResult {
2294 let path_str = file_path.to_string_lossy();
2295
2296 for suppression in inline_suppressions {
2298 if suppression.applies_to(finding_line, rule_id) {
2299 let reason = suppression
2300 .reason
2301 .clone()
2302 .unwrap_or_else(|| "No reason provided".to_string());
2303 return SuppressionResult::suppressed(
2304 SuppressionSource::Inline,
2305 reason,
2306 format!("line {}", suppression.line),
2307 );
2308 }
2309 }
2310
2311 let is_always_enabled = Self::is_always_enabled(rule_id) && !self.skip_security_in_tests;
2314
2315 if !is_always_enabled {
2316 if Self::matches_patterns(&path_str, &self.global_patterns) {
2318 for (i, pattern) in self.global_ignore_paths.iter().enumerate() {
2319 if let Some(re) = self.global_patterns.get(i)
2320 && re.is_match(&path_str.replace('\\', "/"))
2321 {
2322 return SuppressionResult::suppressed(
2323 SuppressionSource::PathGlobal,
2324 format!("Path matches global ignore pattern: {}", pattern),
2325 pattern.clone(),
2326 );
2327 }
2328 }
2329 }
2330
2331 if let Some(patterns) = self.rule_patterns.get(rule_id)
2333 && Self::matches_patterns(&path_str, patterns)
2334 && let Some(rule_paths) = self.rule_ignore_paths.get(rule_id)
2335 {
2336 for (i, pattern) in rule_paths.iter().enumerate() {
2337 if let Some(re) = patterns.get(i)
2338 && re.is_match(&path_str.replace('\\', "/"))
2339 {
2340 return SuppressionResult::suppressed(
2341 SuppressionSource::PathRule,
2342 format!("Path matches rule-specific ignore pattern: {}", pattern),
2343 format!("{}:{}", rule_id, pattern),
2344 );
2345 }
2346 }
2347 }
2348
2349 for (pattern_rule_id, patterns) in &self.rule_patterns {
2351 if pattern_rule_id.ends_with("/*") {
2352 let prefix = pattern_rule_id.trim_end_matches("/*");
2353 if rule_id.starts_with(prefix)
2354 && Self::matches_patterns(&path_str, patterns)
2355 && let Some(rule_paths) = self.rule_ignore_paths.get(pattern_rule_id)
2356 && let Some(pattern) = rule_paths.first()
2357 {
2358 return SuppressionResult::suppressed(
2359 SuppressionSource::PathRule,
2360 format!("Path matches rule-specific ignore pattern: {}", pattern),
2361 format!("{}:{}", pattern_rule_id, pattern),
2362 );
2363 }
2364 }
2365 }
2366
2367 if self.use_default_presets {
2369 if Self::matches_patterns(&path_str, &self.test_patterns) {
2370 return SuppressionResult::suppressed(
2371 SuppressionSource::Preset,
2372 "File is in test directory (suppressed by default preset)".to_string(),
2373 "test-preset".to_string(),
2374 );
2375 }
2376 if Self::matches_patterns(&path_str, &self.example_patterns) {
2377 return SuppressionResult::suppressed(
2378 SuppressionSource::Preset,
2379 "File is in example/fixture directory (suppressed by default preset)"
2380 .to_string(),
2381 "example-preset".to_string(),
2382 );
2383 }
2384 }
2385 }
2386
2387 if let Some(ref baseline) = self.baseline
2389 && let Some(fp) = fingerprint
2390 {
2391 let fingerprint_obj = Fingerprint::from_string(fp.to_string());
2392 if baseline.contains_fingerprint(&fingerprint_obj) {
2393 return SuppressionResult::suppressed(
2394 SuppressionSource::Baseline,
2395 "Finding is in baseline".to_string(),
2396 "baseline".to_string(),
2397 );
2398 }
2399 }
2400
2401 if let Some(ref store) = self.suppression_store
2403 && let Some(fp) = fingerprint
2404 && let Ok(Some(entry)) = store.is_suppressed(fp)
2405 {
2406 return SuppressionResult::suppressed(
2407 SuppressionSource::Database,
2408 entry.reason.clone(),
2409 format!("database:{}", entry.id),
2410 );
2411 }
2412
2413 SuppressionResult::not_suppressed()
2414 }
2415
2416 pub fn should_skip_path(&self, file_path: &Path) -> bool {
2421 let path_str = file_path.to_string_lossy();
2422
2423 if Self::matches_patterns(&path_str, &self.global_patterns) {
2425 return true;
2426 }
2427
2428 if self.use_default_presets {
2430 false
2435 } else {
2436 false
2437 }
2438 }
2439
2440 pub fn add_suppression_metadata(
2442 properties: &mut HashMap<String, serde_json::Value>,
2443 result: &SuppressionResult,
2444 ) {
2445 if result.suppressed {
2446 properties.insert("suppressed".to_string(), serde_json::json!(true));
2447 if let Some(ref reason) = result.reason {
2448 properties.insert("suppression_reason".to_string(), serde_json::json!(reason));
2449 }
2450 if let Some(ref source) = result.source {
2451 properties.insert(
2452 "suppression_source".to_string(),
2453 serde_json::json!(source.to_string()),
2454 );
2455
2456 if *source == SuppressionSource::Database
2458 && let Some(ref location) = result.location
2459 && let Some(id) = location.strip_prefix("database:")
2460 {
2461 properties.insert("suppression_id".to_string(), serde_json::json!(id));
2462 }
2463 }
2464 if let Some(ref location) = result.location {
2465 properties.insert(
2466 "suppression_location".to_string(),
2467 serde_json::json!(location),
2468 );
2469 }
2470 }
2471 }
2472}
2473
2474impl Default for SuppressionEngine {
2475 fn default() -> Self {
2476 Self::new(&RulesConfig::default(), false)
2477 }
2478}
2479
2480#[cfg(test)]
2481mod tests {
2482 use super::*;
2483 use std::str::FromStr;
2484
2485 #[test]
2486 fn test_profile_parsing() {
2487 assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
2488 assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
2489 assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
2490 assert!(Profile::from_str("unknown").is_err());
2491 }
2492
2493 #[test]
2494 fn test_rule_matching() {
2495 assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
2496 assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
2497 assert!(!RmaTomlConfig::matches_pattern(
2498 "generic/long",
2499 "security/*"
2500 ));
2501 assert!(RmaTomlConfig::matches_pattern(
2502 "security/xss",
2503 "security/xss"
2504 ));
2505 }
2506
2507 #[test]
2508 fn test_default_config_parses() {
2509 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2510 let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
2511 assert_eq!(config.profiles.default, Profile::Balanced);
2512 }
2513
2514 #[test]
2515 fn test_thresholds_for_profile() {
2516 let fast = ProfileThresholds::for_profile(Profile::Fast);
2517 let strict = ProfileThresholds::for_profile(Profile::Strict);
2518
2519 assert!(fast.max_function_lines > strict.max_function_lines);
2520 assert!(fast.max_complexity > strict.max_complexity);
2521 }
2522
2523 #[test]
2524 fn test_fingerprint_stable_across_line_changes() {
2525 let fp1 = Fingerprint::generate(
2527 "js/xss-sink",
2528 Path::new("src/app.js"),
2529 "element.textContent = userInput;",
2530 );
2531 let fp2 = Fingerprint::generate(
2532 "js/xss-sink",
2533 Path::new("src/app.js"),
2534 "element.textContent = userInput;",
2535 );
2536
2537 assert_eq!(fp1, fp2);
2538 }
2539
2540 #[test]
2541 fn test_fingerprint_stable_with_whitespace_changes() {
2542 let fp1 = Fingerprint::generate(
2544 "generic/long-function",
2545 Path::new("src/utils.rs"),
2546 "fn very_long_function() {",
2547 );
2548 let fp2 = Fingerprint::generate(
2549 "generic/long-function",
2550 Path::new("src/utils.rs"),
2551 "fn very_long_function() {",
2552 );
2553 let fp3 = Fingerprint::generate(
2554 "generic/long-function",
2555 Path::new("src/utils.rs"),
2556 " fn very_long_function() { ",
2557 );
2558
2559 assert_eq!(fp1, fp2);
2560 assert_eq!(fp2, fp3);
2561 }
2562
2563 #[test]
2564 fn test_fingerprint_different_for_different_rules() {
2565 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2566 let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
2567
2568 assert_ne!(fp1, fp2);
2569 }
2570
2571 #[test]
2572 fn test_fingerprint_different_for_different_files() {
2573 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2574 let fp2 =
2575 Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
2576
2577 assert_ne!(fp1, fp2);
2578 }
2579
2580 #[test]
2581 fn test_fingerprint_path_normalization() {
2582 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
2584 let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
2585
2586 assert_eq!(fp1, fp2);
2587 }
2588
2589 #[test]
2590 fn test_effective_config_precedence() {
2591 let toml_config = RmaTomlConfig::default();
2593 let effective = EffectiveConfig::resolve(
2594 Some(&toml_config),
2595 Some(Path::new("rma.toml")),
2596 Some(Profile::Strict), false,
2598 );
2599
2600 assert_eq!(effective.profile, Profile::Strict);
2601 assert_eq!(effective.profile_source, ConfigSource::CliFlag);
2602 }
2603
2604 #[test]
2605 fn test_effective_config_defaults() {
2606 let effective = EffectiveConfig::resolve(None, None, None, false);
2608
2609 assert_eq!(effective.profile, Profile::Balanced);
2610 assert_eq!(effective.profile_source, ConfigSource::Default);
2611 assert!(effective.config_file.is_none());
2612 }
2613
2614 #[test]
2615 fn test_effective_config_from_file() {
2616 let mut toml_config = RmaTomlConfig::default();
2618 toml_config.profiles.default = Profile::Fast;
2619
2620 let effective =
2621 EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
2622
2623 assert_eq!(effective.profile, Profile::Fast);
2624 assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
2625 }
2626
2627 #[test]
2628 fn test_config_version_missing_warns() {
2629 let toml = r#"
2630[profiles]
2631default = "balanced"
2632"#;
2633 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2634 assert!(config.config_version.is_none());
2635 assert!(!config.has_version());
2636 assert_eq!(config.effective_version(), 1);
2637
2638 let warnings = config.validate();
2639 assert!(
2640 warnings
2641 .iter()
2642 .any(|w| w.message.contains("Missing 'config_version'"))
2643 );
2644 }
2645
2646 #[test]
2647 fn test_config_version_1_ok() {
2648 let toml = r#"
2649config_version = 1
2650
2651[profiles]
2652default = "balanced"
2653"#;
2654 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2655 assert_eq!(config.config_version, Some(1));
2656 assert!(config.has_version());
2657 assert_eq!(config.effective_version(), 1);
2658
2659 let warnings = config.validate();
2660 assert!(
2661 !warnings
2662 .iter()
2663 .any(|w| w.message.contains("config_version"))
2664 );
2665 }
2666
2667 #[test]
2668 fn test_config_version_999_fails() {
2669 let toml = r#"
2670config_version = 999
2671
2672[profiles]
2673default = "balanced"
2674"#;
2675 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2676 assert_eq!(config.config_version, Some(999));
2677
2678 let warnings = config.validate();
2679 let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
2680 assert!(error.is_some());
2681 assert!(
2682 error
2683 .unwrap()
2684 .message
2685 .contains("Unsupported config version: 999")
2686 );
2687 }
2688
2689 #[test]
2690 fn test_default_toml_includes_version() {
2691 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2692 assert!(toml.contains("config_version = 1"));
2693
2694 let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
2696 assert_eq!(config.config_version, Some(1));
2697 }
2698
2699 #[test]
2700 fn test_ruleset_security() {
2701 let toml = r#"
2702config_version = 1
2703
2704[rulesets]
2705security = ["js/innerhtml-xss", "js/timer-string-eval"]
2706maintainability = ["generic/long-function"]
2707
2708[rules]
2709enable = ["*"]
2710"#;
2711 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2712
2713 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2715 assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2716 assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
2717
2718 assert!(config.is_rule_enabled("generic/long-function"));
2720 }
2721
2722 #[test]
2723 fn test_ruleset_with_disable() {
2724 let toml = r#"
2725config_version = 1
2726
2727[rulesets]
2728security = ["js/innerhtml-xss", "js/timer-string-eval"]
2729
2730[rules]
2731enable = ["*"]
2732disable = ["js/timer-string-eval"]
2733"#;
2734 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2735
2736 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2738 assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2739 }
2740
2741 #[test]
2742 fn test_get_ruleset_names() {
2743 let toml = r#"
2744config_version = 1
2745
2746[rulesets]
2747security = ["js/innerhtml-xss"]
2748maintainability = ["generic/long-function"]
2749"#;
2750 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2751 let names = config.get_ruleset_names();
2752
2753 assert!(names.contains(&"security".to_string()));
2754 assert!(names.contains(&"maintainability".to_string()));
2755 }
2756
2757 #[test]
2758 fn test_default_toml_includes_rulesets() {
2759 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2760 assert!(toml.contains("[rulesets]"));
2761 assert!(toml.contains("security = "));
2762 assert!(toml.contains("maintainability = "));
2763 }
2764
2765 #[test]
2766 fn test_inline_suppression_next_line() {
2767 let suppression = InlineSuppression::parse(
2768 "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
2769 10,
2770 );
2771 assert!(suppression.is_some());
2772 let s = suppression.unwrap();
2773 assert_eq!(s.rule_id, "js/innerhtml-xss");
2774 assert_eq!(s.reason, Some("sanitized input".to_string()));
2775 assert_eq!(s.line, 10);
2776 assert_eq!(s.suppression_type, SuppressionType::NextLine);
2777
2778 assert!(s.applies_to(11, "js/innerhtml-xss"));
2780 assert!(!s.applies_to(12, "js/innerhtml-xss"));
2782 assert!(!s.applies_to(11, "js/console-log"));
2784 }
2785
2786 #[test]
2787 fn test_inline_suppression_block() {
2788 let suppression = InlineSuppression::parse(
2789 "// rma-ignore generic/long-function reason=\"legacy code\"",
2790 5,
2791 );
2792 assert!(suppression.is_some());
2793 let s = suppression.unwrap();
2794 assert_eq!(s.rule_id, "generic/long-function");
2795 assert_eq!(s.suppression_type, SuppressionType::Block);
2796
2797 assert!(s.applies_to(5, "generic/long-function"));
2799 assert!(s.applies_to(10, "generic/long-function"));
2800 assert!(s.applies_to(100, "generic/long-function"));
2801 }
2802
2803 #[test]
2804 fn test_inline_suppression_without_reason() {
2805 let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
2806 assert!(suppression.is_some());
2807 let s = suppression.unwrap();
2808 assert_eq!(s.rule_id, "js/console-log");
2809 assert!(s.reason.is_none());
2810 }
2811
2812 #[test]
2813 fn test_inline_suppression_python_style() {
2814 let suppression = InlineSuppression::parse(
2815 "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
2816 3,
2817 );
2818 assert!(suppression.is_some());
2819 let s = suppression.unwrap();
2820 assert_eq!(s.rule_id, "python/hardcoded-secret");
2821 assert_eq!(s.reason, Some("test data".to_string()));
2822 }
2823
2824 #[test]
2825 fn test_inline_suppression_validation_strict() {
2826 let s = InlineSuppression {
2827 rule_id: "js/xss".to_string(),
2828 reason: None,
2829 line: 1,
2830 suppression_type: SuppressionType::NextLine,
2831 };
2832
2833 assert!(s.validate(true).is_err());
2835 assert!(s.validate(false).is_ok());
2837
2838 let s_with_reason = InlineSuppression {
2839 rule_id: "js/xss".to_string(),
2840 reason: Some("approved".to_string()),
2841 line: 1,
2842 suppression_type: SuppressionType::NextLine,
2843 };
2844
2845 assert!(s_with_reason.validate(true).is_ok());
2847 assert!(s_with_reason.validate(false).is_ok());
2848 }
2849
2850 #[test]
2851 fn test_parse_inline_suppressions() {
2852 let content = r#"
2853function foo() {
2854 // rma-ignore-next-line js/console-log reason="debugging"
2855 console.log("test");
2856
2857 // rma-ignore generic/long-function reason="complex algorithm"
2858 // ... lots of code ...
2859}
2860"#;
2861 let suppressions = parse_inline_suppressions(content);
2862 assert_eq!(suppressions.len(), 2);
2863 assert_eq!(suppressions[0].rule_id, "js/console-log");
2864 assert_eq!(suppressions[1].rule_id, "generic/long-function");
2865 }
2866
2867 #[test]
2868 fn test_suppression_does_not_affect_other_rules() {
2869 let suppression = InlineSuppression::parse(
2870 "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2871 10,
2872 )
2873 .unwrap();
2874
2875 assert!(suppression.applies_to(11, "js/innerhtml-xss"));
2877 assert!(!suppression.applies_to(11, "js/console-log"));
2879 assert!(!suppression.applies_to(11, "generic/long-function"));
2880 }
2881
2882 #[test]
2887 fn test_suppression_engine_global_path_ignore() {
2888 let rules_config = RulesConfig {
2889 ignore_paths: vec!["**/vendor/**".to_string(), "**/generated/**".to_string()],
2890 ..Default::default()
2891 };
2892
2893 let engine = SuppressionEngine::new(&rules_config, false);
2894
2895 let result = engine.check(
2897 "generic/long-function",
2898 Path::new("src/vendor/lib.js"),
2899 10,
2900 &[],
2901 None,
2902 );
2903 assert!(result.suppressed);
2904 assert_eq!(result.source, Some(SuppressionSource::PathGlobal));
2905
2906 let result = engine.check(
2908 "generic/long-function",
2909 Path::new("src/app.js"),
2910 10,
2911 &[],
2912 None,
2913 );
2914 assert!(!result.suppressed);
2915 }
2916
2917 #[test]
2918 fn test_suppression_engine_per_rule_path_ignore() {
2919 let rules_config = RulesConfig {
2920 ignore_paths_by_rule: HashMap::from([(
2921 "generic/long-function".to_string(),
2922 vec!["**/tests/**".to_string()],
2923 )]),
2924 ..Default::default()
2925 };
2926
2927 let engine = SuppressionEngine::new(&rules_config, false);
2928
2929 let result = engine.check(
2931 "generic/long-function",
2932 Path::new("src/tests/test_app.js"),
2933 10,
2934 &[],
2935 None,
2936 );
2937 assert!(result.suppressed);
2938 assert_eq!(result.source, Some(SuppressionSource::PathRule));
2939
2940 let result = engine.check(
2942 "js/console-log",
2943 Path::new("src/tests/test_app.js"),
2944 10,
2945 &[],
2946 None,
2947 );
2948 assert!(!result.suppressed);
2949 }
2950
2951 #[test]
2952 fn test_suppression_engine_inline_suppression() {
2953 let rules_config = RulesConfig::default();
2954 let engine = SuppressionEngine::new(&rules_config, false);
2955
2956 let inline_suppressions = vec![InlineSuppression {
2957 rule_id: "js/console-log".to_string(),
2958 reason: Some("debug output".to_string()),
2959 line: 10,
2960 suppression_type: SuppressionType::NextLine,
2961 }];
2962
2963 let result = engine.check(
2965 "js/console-log",
2966 Path::new("src/app.js"),
2967 11, &inline_suppressions,
2969 None,
2970 );
2971 assert!(result.suppressed);
2972 assert_eq!(result.source, Some(SuppressionSource::Inline));
2973 assert_eq!(result.reason, Some("debug output".to_string()));
2974
2975 let result = engine.check(
2977 "js/console-log",
2978 Path::new("src/app.js"),
2979 12,
2980 &inline_suppressions,
2981 None,
2982 );
2983 assert!(!result.suppressed);
2984 }
2985
2986 #[test]
2987 fn test_suppression_engine_default_presets() {
2988 let rules_config = RulesConfig::default();
2989 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
2993 "generic/long-function",
2994 Path::new("src/tests/test_app.rs"),
2995 10,
2996 &[],
2997 None,
2998 );
2999 assert!(result.suppressed);
3000 assert_eq!(result.source, Some(SuppressionSource::Preset));
3001
3002 let result = engine.check(
3004 "js/console-log",
3005 Path::new("src/app.test.ts"),
3006 10,
3007 &[],
3008 None,
3009 );
3010 assert!(result.suppressed);
3011
3012 let result = engine.check(
3014 "generic/long-function",
3015 Path::new("examples/demo.rs"),
3016 10,
3017 &[],
3018 None,
3019 );
3020 assert!(result.suppressed);
3021
3022 let result = engine.check(
3024 "generic/long-function",
3025 Path::new("src/lib.rs"),
3026 10,
3027 &[],
3028 None,
3029 );
3030 assert!(!result.suppressed);
3031 }
3032
3033 #[test]
3034 fn test_suppression_engine_security_rules_not_suppressed_by_preset() {
3035 let rules_config = RulesConfig::default();
3036 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
3040 "rust/command-injection",
3041 Path::new("src/tests/test_app.rs"),
3042 10,
3043 &[],
3044 None,
3045 );
3046 assert!(!result.suppressed);
3047
3048 let result = engine.check(
3049 "generic/hardcoded-secret",
3050 Path::new("examples/demo.py"),
3051 10,
3052 &[],
3053 None,
3054 );
3055 assert!(!result.suppressed);
3056
3057 let result = engine.check(
3058 "python/shell-injection",
3059 Path::new("tests/test_shell.py"),
3060 10,
3061 &[],
3062 None,
3063 );
3064 assert!(!result.suppressed);
3065 }
3066
3067 #[test]
3068 fn test_suppression_engine_security_rules_can_be_suppressed_inline() {
3069 let rules_config = RulesConfig::default();
3070 let engine = SuppressionEngine::new(&rules_config, true);
3071
3072 let inline_suppressions = vec![InlineSuppression {
3073 rule_id: "rust/command-injection".to_string(),
3074 reason: Some("sanitized input validated upstream".to_string()),
3075 line: 10,
3076 suppression_type: SuppressionType::NextLine,
3077 }];
3078
3079 let result = engine.check(
3081 "rust/command-injection",
3082 Path::new("src/app.rs"),
3083 11,
3084 &inline_suppressions,
3085 None,
3086 );
3087 assert!(result.suppressed);
3088 assert_eq!(result.source, Some(SuppressionSource::Inline));
3089 }
3090
3091 #[test]
3092 fn test_suppression_engine_is_always_enabled() {
3093 assert!(SuppressionEngine::is_always_enabled(
3094 "rust/command-injection"
3095 ));
3096 assert!(SuppressionEngine::is_always_enabled(
3097 "python/shell-injection"
3098 ));
3099 assert!(SuppressionEngine::is_always_enabled(
3100 "generic/hardcoded-secret"
3101 ));
3102 assert!(SuppressionEngine::is_always_enabled("go/command-injection"));
3103 assert!(SuppressionEngine::is_always_enabled(
3104 "java/command-execution"
3105 ));
3106 assert!(SuppressionEngine::is_always_enabled(
3107 "js/dynamic-code-execution"
3108 ));
3109
3110 assert!(!SuppressionEngine::is_always_enabled(
3112 "generic/long-function"
3113 ));
3114 assert!(!SuppressionEngine::is_always_enabled("js/console-log"));
3115 assert!(!SuppressionEngine::is_always_enabled("rust/unsafe-block"));
3116 }
3117
3118 #[test]
3119 fn test_suppression_engine_add_metadata() {
3120 let result = SuppressionResult::suppressed(
3121 SuppressionSource::Inline,
3122 "debug output".to_string(),
3123 "line 10".to_string(),
3124 );
3125
3126 let mut properties = HashMap::new();
3127 SuppressionEngine::add_suppression_metadata(&mut properties, &result);
3128
3129 assert_eq!(properties.get("suppressed"), Some(&serde_json::json!(true)));
3130 assert_eq!(
3131 properties.get("suppression_reason"),
3132 Some(&serde_json::json!("debug output"))
3133 );
3134 assert_eq!(
3135 properties.get("suppression_source"),
3136 Some(&serde_json::json!("inline"))
3137 );
3138 assert_eq!(
3139 properties.get("suppression_location"),
3140 Some(&serde_json::json!("line 10"))
3141 );
3142 }
3143
3144 #[test]
3145 fn test_suppression_result_not_suppressed() {
3146 let result = SuppressionResult::not_suppressed();
3147 assert!(!result.suppressed);
3148 assert!(result.reason.is_none());
3149 assert!(result.source.is_none());
3150 assert!(result.location.is_none());
3151 }
3152
3153 #[test]
3154 fn test_rules_config_with_ignore_paths() {
3155 let toml = r#"
3156config_version = 1
3157
3158[rules]
3159enable = ["*"]
3160disable = []
3161ignore_paths = ["**/vendor/**", "**/generated/**"]
3162
3163[rules.ignore_paths_by_rule]
3164"generic/long-function" = ["**/tests/**", "**/examples/**"]
3165"js/console-log" = ["**/debug/**"]
3166"#;
3167 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
3168
3169 assert_eq!(config.rules.ignore_paths.len(), 2);
3170 assert!(
3171 config
3172 .rules
3173 .ignore_paths
3174 .contains(&"**/vendor/**".to_string())
3175 );
3176 assert!(
3177 config
3178 .rules
3179 .ignore_paths
3180 .contains(&"**/generated/**".to_string())
3181 );
3182
3183 assert_eq!(config.rules.ignore_paths_by_rule.len(), 2);
3184 assert!(
3185 config
3186 .rules
3187 .ignore_paths_by_rule
3188 .contains_key("generic/long-function")
3189 );
3190 assert!(
3191 config
3192 .rules
3193 .ignore_paths_by_rule
3194 .contains_key("js/console-log")
3195 );
3196 }
3197}