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 DEFAULT_VENDOR_IGNORE_PATHS: &[&str] = &[
226 "**/vendor/**",
230 "**/vendors/**",
231 "**/third_party/**",
232 "**/third-party/**",
233 "**/thirdparty/**",
234 "**/external/**",
235 "**/externals/**",
236 "**/deps/**",
237 "**/lib/**/*.min.js",
238 "**/libs/**/*.min.js",
239 "**/node_modules/**",
243 "**/bower_components/**",
244 "**/jspm_packages/**",
245 "**/dist/**",
249 "**/build/**",
250 "**/out/**",
251 "**/output/**",
252 "**/.next/**",
253 "**/.nuxt/**",
254 "**/.output/**",
255 "**/target/**",
256 "**/*.min.js",
260 "**/*.min.css",
261 "**/*.bundle.js",
262 "**/*.bundle.css",
263 "**/*-bundle.js",
264 "**/*-min.js",
265 "**/*.packed.js",
266 "**/*.compiled.js",
267 "**/jquery*.js",
271 "**/angular*.js",
272 "**/react*.production*.js",
273 "**/vue*.js",
274 "**/lodash*.js",
275 "**/underscore*.js",
276 "**/backbone*.js",
277 "**/bootstrap*.js",
278 "**/moment*.js",
279 "**/d3*.js",
280 "**/chart*.js",
281 "**/highcharts*.js",
282 "**/livereload*.js",
283 "**/socket.io*.js",
284 "**/polyfill*.js",
285 "**/static/**/vendor/**",
289 "**/static/**/lib/**",
290 "**/static/**/libs/**",
291 "**/public/**/vendor/**",
292 "**/public/**/lib/**",
293 "**/public/**/libs/**",
294 "**/assets/**/vendor/**",
295 "**/assets/**/lib/**",
296 "**/assets/**/libs/**",
297 "**/resources/**/vendor/**",
298 "**/resources/**/lib/**",
299 "**/resources/**/libs/**",
300 "**/resources/**/*.js",
304 "**/_vendor/**",
308 "**/site-packages/**",
309 "**/go/pkg/**",
313 "**/bundle/**",
317 "**/.cache/**",
321 "**/.parcel-cache/**",
322 "**/.turbo/**",
323 "**/.vite/**",
324 "**/cache/**",
325];
326
327pub const RULES_ALWAYS_ENABLED: &[&str] = &[
330 "rust/command-injection",
331 "python/shell-injection",
332 "go/command-injection",
333 "java/command-execution",
334 "js/dynamic-code-execution",
335 "generic/hardcoded-secret",
336];
337
338#[derive(Debug, Clone, Serialize, Deserialize, Default)]
340pub struct RulesetsConfig {
341 #[serde(default)]
343 pub security: Vec<String>,
344
345 #[serde(default)]
347 pub maintainability: Vec<String>,
348
349 #[serde(flatten)]
351 pub custom: HashMap<String, Vec<String>>,
352}
353
354fn default_enable() -> Vec<String> {
355 vec!["*".to_string()]
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct ProfileThresholds {
361 #[serde(default = "default_max_function_lines")]
363 pub max_function_lines: usize,
364
365 #[serde(default = "default_max_complexity")]
367 pub max_complexity: usize,
368
369 #[serde(default = "default_max_cognitive_complexity")]
371 pub max_cognitive_complexity: usize,
372
373 #[serde(default = "default_max_file_lines")]
375 pub max_file_lines: usize,
376}
377
378fn default_max_function_lines() -> usize {
379 100
380}
381
382fn default_max_complexity() -> usize {
383 15
384}
385
386fn default_max_cognitive_complexity() -> usize {
387 20
388}
389
390fn default_max_file_lines() -> usize {
391 1000
392}
393
394impl Default for ProfileThresholds {
395 fn default() -> Self {
396 Self {
397 max_function_lines: default_max_function_lines(),
398 max_complexity: default_max_complexity(),
399 max_cognitive_complexity: default_max_cognitive_complexity(),
400 max_file_lines: default_max_file_lines(),
401 }
402 }
403}
404
405impl ProfileThresholds {
406 pub fn for_profile(profile: Profile) -> Self {
408 match profile {
409 Profile::Fast => Self {
410 max_function_lines: 200,
411 max_complexity: 25,
412 max_cognitive_complexity: 35,
413 max_file_lines: 2000,
414 },
415 Profile::Balanced => Self::default(),
416 Profile::Strict => Self {
417 max_function_lines: 50,
418 max_complexity: 10,
419 max_cognitive_complexity: 15,
420 max_file_lines: 500,
421 },
422 }
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, Default)]
428pub struct ProfilesConfig {
429 #[serde(default)]
431 pub default: Profile,
432
433 #[serde(default = "fast_profile_defaults")]
435 pub fast: ProfileThresholds,
436
437 #[serde(default)]
439 pub balanced: ProfileThresholds,
440
441 #[serde(default = "strict_profile_defaults")]
443 pub strict: ProfileThresholds,
444}
445
446fn fast_profile_defaults() -> ProfileThresholds {
447 ProfileThresholds::for_profile(Profile::Fast)
448}
449
450fn strict_profile_defaults() -> ProfileThresholds {
451 ProfileThresholds::for_profile(Profile::Strict)
452}
453
454impl ProfilesConfig {
455 pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
457 match profile {
458 Profile::Fast => &self.fast,
459 Profile::Balanced => &self.balanced,
460 Profile::Strict => &self.strict,
461 }
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct ThresholdOverride {
468 pub path: String,
470
471 #[serde(default)]
473 pub max_function_lines: Option<usize>,
474
475 #[serde(default)]
477 pub max_complexity: Option<usize>,
478
479 #[serde(default)]
481 pub max_cognitive_complexity: Option<usize>,
482
483 #[serde(default)]
485 pub disable_rules: Vec<String>,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize, Default)]
490pub struct AllowConfig {
491 #[serde(default)]
493 pub settimeout_string: bool,
494
495 #[serde(default = "default_true")]
497 pub settimeout_function: bool,
498
499 #[serde(default)]
501 pub innerhtml_paths: Vec<String>,
502
503 #[serde(default)]
505 pub eval_paths: Vec<String>,
506
507 #[serde(default)]
509 pub unsafe_rust_paths: Vec<String>,
510
511 #[serde(default)]
513 pub approved_secrets: Vec<String>,
514}
515
516fn default_true() -> bool {
517 true
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
526#[serde(rename_all = "lowercase")]
527pub enum ProviderType {
528 Rma,
530 Pmd,
532 Oxlint,
534 Oxc,
536 RustSec,
538 Gosec,
540 Osv,
542}
543
544impl std::fmt::Display for ProviderType {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 match self {
547 ProviderType::Rma => write!(f, "rma"),
548 ProviderType::Pmd => write!(f, "pmd"),
549 ProviderType::Oxlint => write!(f, "oxlint"),
550 ProviderType::Oxc => write!(f, "oxc"),
551 ProviderType::RustSec => write!(f, "rustsec"),
552 ProviderType::Gosec => write!(f, "gosec"),
553 ProviderType::Osv => write!(f, "osv"),
554 }
555 }
556}
557
558impl std::str::FromStr for ProviderType {
559 type Err = String;
560
561 fn from_str(s: &str) -> Result<Self, Self::Err> {
562 match s.to_lowercase().as_str() {
563 "rma" => Ok(ProviderType::Rma),
564 "pmd" => Ok(ProviderType::Pmd),
565 "oxlint" => Ok(ProviderType::Oxlint),
566 "oxc" => Ok(ProviderType::Oxc),
567 "rustsec" => Ok(ProviderType::RustSec),
568 "gosec" => Ok(ProviderType::Gosec),
569 "osv" => Ok(ProviderType::Osv),
570 _ => Err(format!(
571 "Unknown provider: {}. Available: rma, pmd, oxlint, oxc, rustsec, gosec, osv",
572 s
573 )),
574 }
575 }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ProvidersConfig {
581 #[serde(default = "default_enabled_providers")]
583 pub enabled: Vec<ProviderType>,
584
585 #[serde(default)]
587 pub pmd: PmdProviderConfig,
588
589 #[serde(default)]
591 pub oxlint: OxlintProviderConfig,
592
593 #[serde(default)]
595 pub oxc: OxcProviderConfig,
596
597 #[serde(default)]
599 pub gosec: GosecProviderConfig,
600
601 #[serde(default)]
603 pub osv: OsvProviderConfig,
604}
605
606impl Default for ProvidersConfig {
607 fn default() -> Self {
608 Self {
609 enabled: default_enabled_providers(),
610 pmd: PmdProviderConfig::default(),
611 oxlint: OxlintProviderConfig::default(),
612 oxc: OxcProviderConfig::default(),
613 gosec: GosecProviderConfig::default(),
614 osv: OsvProviderConfig::default(),
615 }
616 }
617}
618
619fn default_enabled_providers() -> Vec<ProviderType> {
620 vec![ProviderType::Rma, ProviderType::Oxc]
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct PmdProviderConfig {
628 #[serde(default)]
630 pub configured: bool,
631
632 #[serde(default = "default_java_path")]
634 pub java_path: String,
635
636 #[serde(default)]
639 pub pmd_path: String,
640
641 #[serde(default = "default_pmd_rulesets")]
643 pub rulesets: Vec<String>,
644
645 #[serde(default = "default_pmd_timeout")]
647 pub timeout_ms: u64,
648
649 #[serde(default = "default_pmd_include_patterns")]
651 pub include_patterns: Vec<String>,
652
653 #[serde(default = "default_pmd_exclude_patterns")]
655 pub exclude_patterns: Vec<String>,
656
657 #[serde(default = "default_pmd_severity_map")]
661 pub severity_map: HashMap<String, Severity>,
662
663 #[serde(default)]
665 pub fail_on_error: bool,
666
667 #[serde(default = "default_pmd_min_priority")]
669 pub min_priority: u8,
670
671 #[serde(default)]
673 pub extra_args: Vec<String>,
674}
675
676impl Default for PmdProviderConfig {
677 fn default() -> Self {
678 Self {
679 configured: false,
680 java_path: default_java_path(),
681 pmd_path: String::new(),
682 rulesets: default_pmd_rulesets(),
683 timeout_ms: default_pmd_timeout(),
684 include_patterns: default_pmd_include_patterns(),
685 exclude_patterns: default_pmd_exclude_patterns(),
686 severity_map: default_pmd_severity_map(),
687 fail_on_error: false,
688 min_priority: default_pmd_min_priority(),
689 extra_args: Vec::new(),
690 }
691 }
692}
693
694fn default_java_path() -> String {
695 "java".to_string()
696}
697
698fn default_pmd_rulesets() -> Vec<String> {
699 vec![
700 "category/java/security.xml".to_string(),
701 "category/java/bestpractices.xml".to_string(),
702 "category/java/errorprone.xml".to_string(),
703 ]
704}
705
706fn default_pmd_timeout() -> u64 {
707 600_000 }
709
710fn default_pmd_include_patterns() -> Vec<String> {
711 vec!["**/*.java".to_string()]
712}
713
714fn default_pmd_exclude_patterns() -> Vec<String> {
715 vec![
716 "**/target/**".to_string(),
717 "**/build/**".to_string(),
718 "**/generated/**".to_string(),
719 "**/out/**".to_string(),
720 "**/.git/**".to_string(),
721 "**/node_modules/**".to_string(),
722 ]
723}
724
725fn default_pmd_severity_map() -> HashMap<String, Severity> {
726 let mut map = HashMap::new();
727 map.insert("1".to_string(), Severity::Critical);
728 map.insert("2".to_string(), Severity::Error);
729 map.insert("3".to_string(), Severity::Warning);
730 map.insert("4".to_string(), Severity::Info);
731 map.insert("5".to_string(), Severity::Info);
732 map
733}
734
735fn default_pmd_min_priority() -> u8 {
736 5 }
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct OxlintProviderConfig {
742 #[serde(default)]
744 pub configured: bool,
745
746 #[serde(default)]
748 pub binary_path: String,
749
750 #[serde(default = "default_oxlint_timeout")]
752 pub timeout_ms: u64,
753
754 #[serde(default)]
756 pub extra_args: Vec<String>,
757}
758
759impl Default for OxlintProviderConfig {
760 fn default() -> Self {
761 Self {
762 configured: false,
763 binary_path: String::new(),
764 timeout_ms: default_oxlint_timeout(),
765 extra_args: Vec::new(),
766 }
767 }
768}
769
770fn default_oxlint_timeout() -> u64 {
771 300_000 }
773
774#[derive(Debug, Clone, Serialize, Deserialize)]
776pub struct GosecProviderConfig {
777 #[serde(default)]
779 pub configured: bool,
780
781 #[serde(default)]
783 pub binary_path: String,
784
785 #[serde(default = "default_gosec_timeout")]
787 pub timeout_ms: u64,
788
789 #[serde(default)]
791 pub exclude_rules: Vec<String>,
792
793 #[serde(default)]
795 pub include_rules: Vec<String>,
796
797 #[serde(default)]
799 pub extra_args: Vec<String>,
800}
801
802impl Default for GosecProviderConfig {
803 fn default() -> Self {
804 Self {
805 configured: false,
806 binary_path: String::new(),
807 timeout_ms: default_gosec_timeout(),
808 exclude_rules: Vec::new(),
809 include_rules: Vec::new(),
810 extra_args: Vec::new(),
811 }
812 }
813}
814
815fn default_gosec_timeout() -> u64 {
816 300_000 }
818
819#[derive(Debug, Clone, Default, Serialize, Deserialize)]
821pub struct OxcProviderConfig {
822 #[serde(default)]
824 pub configured: bool,
825
826 #[serde(default)]
828 pub enable_rules: Vec<String>,
829
830 #[serde(default)]
832 pub disable_rules: Vec<String>,
833
834 #[serde(default)]
836 pub severity_overrides: HashMap<String, Severity>,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
841#[serde(rename_all = "lowercase")]
842pub enum OsvEcosystem {
843 #[serde(rename = "crates.io")]
845 CratesIo,
846 Npm,
848 PyPI,
850 Go,
852 Maven,
854}
855
856impl std::fmt::Display for OsvEcosystem {
857 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
858 match self {
859 OsvEcosystem::CratesIo => write!(f, "crates.io"),
860 OsvEcosystem::Npm => write!(f, "npm"),
861 OsvEcosystem::PyPI => write!(f, "PyPI"),
862 OsvEcosystem::Go => write!(f, "Go"),
863 OsvEcosystem::Maven => write!(f, "Maven"),
864 }
865 }
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct OsvProviderConfig {
871 #[serde(default)]
873 pub configured: bool,
874
875 #[serde(default)]
877 pub include_dev_deps: bool,
878
879 #[serde(default = "default_osv_cache_ttl")]
882 pub cache_ttl: String,
883
884 #[serde(default = "default_osv_ecosystems")]
886 pub enabled_ecosystems: Vec<OsvEcosystem>,
887
888 #[serde(default)]
891 pub severity_overrides: HashMap<String, Severity>,
892
893 #[serde(default)]
896 pub ignore_list: Vec<String>,
897
898 #[serde(default)]
900 pub offline: bool,
901
902 #[serde(default)]
904 pub cache_dir: Option<PathBuf>,
905}
906
907impl Default for OsvProviderConfig {
908 fn default() -> Self {
909 Self {
910 configured: false,
911 include_dev_deps: false,
912 cache_ttl: default_osv_cache_ttl(),
913 enabled_ecosystems: default_osv_ecosystems(),
914 severity_overrides: HashMap::new(),
915 ignore_list: Vec::new(),
916 offline: false,
917 cache_dir: None,
918 }
919 }
920}
921
922fn default_osv_cache_ttl() -> String {
923 "24h".to_string()
924}
925
926fn default_osv_ecosystems() -> Vec<OsvEcosystem> {
927 vec![
928 OsvEcosystem::CratesIo,
929 OsvEcosystem::Npm,
930 OsvEcosystem::PyPI,
931 OsvEcosystem::Go,
932 OsvEcosystem::Maven,
933 ]
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize, Default)]
938pub struct BaselineConfig {
939 #[serde(default = "default_baseline_file")]
941 pub file: PathBuf,
942
943 #[serde(default)]
945 pub mode: BaselineMode,
946}
947
948fn default_baseline_file() -> PathBuf {
949 PathBuf::from(".rma/baseline.json")
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
958pub struct SuppressionConfig {
959 #[serde(default = "default_suppression_database")]
961 pub database: PathBuf,
962
963 #[serde(default = "default_expiration")]
965 pub default_expiration: String,
966
967 #[serde(default)]
969 pub require_ticket: bool,
970
971 #[serde(default = "default_max_expiration")]
973 pub max_expiration: String,
974
975 #[serde(default = "default_enabled")]
977 pub enabled: bool,
978}
979
980impl Default for SuppressionConfig {
981 fn default() -> Self {
982 Self {
983 database: default_suppression_database(),
984 default_expiration: default_expiration(),
985 require_ticket: false,
986 max_expiration: default_max_expiration(),
987 enabled: true,
988 }
989 }
990}
991
992fn default_suppression_database() -> PathBuf {
993 PathBuf::from(".rma/suppressions.db")
994}
995
996fn default_expiration() -> String {
997 "90d".to_string()
998}
999
1000fn default_max_expiration() -> String {
1001 "365d".to_string()
1002}
1003
1004fn default_enabled() -> bool {
1005 true
1006}
1007
1008pub fn parse_expiration_days(s: &str) -> Option<u32> {
1010 let s = s.trim().to_lowercase();
1011 if let Some(days_str) = s.strip_suffix('d') {
1012 days_str.parse().ok()
1013 } else if let Some(weeks_str) = s.strip_suffix('w') {
1014 weeks_str.parse::<u32>().ok().map(|w| w * 7)
1015 } else if let Some(months_str) = s.strip_suffix('m') {
1016 months_str.parse::<u32>().ok().map(|m| m * 30)
1017 } else {
1018 s.parse().ok()
1020 }
1021}
1022
1023pub const CURRENT_CONFIG_VERSION: u32 = 1;
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1028pub struct RmaTomlConfig {
1029 #[serde(default)]
1031 pub config_version: Option<u32>,
1032
1033 #[serde(default)]
1035 pub scan: ScanConfig,
1036
1037 #[serde(default)]
1039 pub rules: RulesConfig,
1040
1041 #[serde(default)]
1043 pub rulesets: RulesetsConfig,
1044
1045 #[serde(default)]
1047 pub profiles: ProfilesConfig,
1048
1049 #[serde(default)]
1051 pub severity: HashMap<String, Severity>,
1052
1053 #[serde(default)]
1055 pub threshold_overrides: Vec<ThresholdOverride>,
1056
1057 #[serde(default)]
1059 pub allow: AllowConfig,
1060
1061 #[serde(default)]
1063 pub baseline: BaselineConfig,
1064
1065 #[serde(default)]
1067 pub providers: ProvidersConfig,
1068
1069 #[serde(default)]
1071 pub suppressions: SuppressionConfig,
1072}
1073
1074#[derive(Debug)]
1076pub struct ConfigLoadResult {
1077 pub config: RmaTomlConfig,
1079 pub version_warning: Option<String>,
1081}
1082
1083impl RmaTomlConfig {
1084 pub fn load(path: &Path) -> Result<Self, String> {
1086 let result = Self::load_with_validation(path)?;
1087 Ok(result.config)
1088 }
1089
1090 pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
1092 let content = std::fs::read_to_string(path)
1093 .map_err(|e| format!("Failed to read config file: {}", e))?;
1094
1095 let config: RmaTomlConfig =
1096 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1097
1098 let version_warning = config.validate_version()?;
1100
1101 Ok(ConfigLoadResult {
1102 config,
1103 version_warning,
1104 })
1105 }
1106
1107 fn validate_version(&self) -> Result<Option<String>, String> {
1109 match self.config_version {
1110 Some(CURRENT_CONFIG_VERSION) => Ok(None),
1111 Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
1112 "Unsupported config version: {}. Maximum supported version is {}. \
1113 Please upgrade RMA or use a compatible config format.",
1114 version, CURRENT_CONFIG_VERSION
1115 )),
1116 Some(version) => {
1117 Err(format!(
1119 "Invalid config version: {}. Expected version {}.",
1120 version, CURRENT_CONFIG_VERSION
1121 ))
1122 }
1123 None => Ok(Some(
1124 "Config file is missing 'config_version'. Assuming version 1. \
1125 Add 'config_version = 1' to suppress this warning."
1126 .to_string(),
1127 )),
1128 }
1129 }
1130
1131 pub fn has_version(&self) -> bool {
1133 self.config_version.is_some()
1134 }
1135
1136 pub fn effective_version(&self) -> u32 {
1138 self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
1139 }
1140
1141 pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
1143 let candidates = [
1144 start_path.join("rma.toml"),
1145 start_path.join(".rma/rma.toml"),
1146 start_path.join(".rma.toml"),
1147 ];
1148
1149 for candidate in &candidates {
1150 if candidate.exists()
1151 && let Ok(config) = Self::load(candidate)
1152 {
1153 return Some((candidate.clone(), config));
1154 }
1155 }
1156
1157 let mut current = start_path.to_path_buf();
1159 for _ in 0..5 {
1160 if let Some(parent) = current.parent() {
1161 let config_path = parent.join("rma.toml");
1162 if config_path.exists()
1163 && let Ok(config) = Self::load(&config_path)
1164 {
1165 return Some((config_path, config));
1166 }
1167 current = parent.to_path_buf();
1168 } else {
1169 break;
1170 }
1171 }
1172
1173 None
1174 }
1175
1176 pub fn validate(&self) -> Vec<ConfigWarning> {
1178 let mut warnings = Vec::new();
1179
1180 if self.config_version.is_none() {
1182 warnings.push(ConfigWarning {
1183 level: WarningLevel::Warning,
1184 message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
1185 .to_string(),
1186 });
1187 } else if let Some(version) = self.config_version
1188 && version > CURRENT_CONFIG_VERSION
1189 {
1190 warnings.push(ConfigWarning {
1191 level: WarningLevel::Error,
1192 message: format!(
1193 "Unsupported config version: {}. Maximum supported is {}.",
1194 version, CURRENT_CONFIG_VERSION
1195 ),
1196 });
1197 }
1198
1199 for disabled in &self.rules.disable {
1201 for enabled in &self.rules.enable {
1202 if enabled == disabled {
1203 warnings.push(ConfigWarning {
1204 level: WarningLevel::Warning,
1205 message: format!(
1206 "Rule '{}' is both enabled and disabled (disable takes precedence)",
1207 disabled
1208 ),
1209 });
1210 }
1211 }
1212 }
1213
1214 for (i, override_) in self.threshold_overrides.iter().enumerate() {
1216 if override_.path.is_empty() {
1217 warnings.push(ConfigWarning {
1218 level: WarningLevel::Error,
1219 message: format!("threshold_overrides[{}]: path cannot be empty", i),
1220 });
1221 }
1222 }
1223
1224 if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
1226 warnings.push(ConfigWarning {
1227 level: WarningLevel::Warning,
1228 message: format!(
1229 "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
1230 self.baseline.file.display()
1231 ),
1232 });
1233 }
1234
1235 for rule_id in self.severity.keys() {
1237 if rule_id.is_empty() {
1238 warnings.push(ConfigWarning {
1239 level: WarningLevel::Error,
1240 message: "Empty rule ID in severity overrides".to_string(),
1241 });
1242 }
1243 }
1244
1245 if self.providers.enabled.contains(&ProviderType::Pmd) {
1247 if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
1249 warnings.push(ConfigWarning {
1250 level: WarningLevel::Warning,
1251 message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
1252 });
1253 }
1254
1255 if self.providers.pmd.rulesets.is_empty() {
1257 warnings.push(ConfigWarning {
1258 level: WarningLevel::Warning,
1259 message:
1260 "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
1261 .to_string(),
1262 });
1263 }
1264
1265 for priority in self.providers.pmd.severity_map.keys() {
1267 if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
1268 warnings.push(ConfigWarning {
1269 level: WarningLevel::Warning,
1270 message: format!(
1271 "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
1272 priority
1273 ),
1274 });
1275 }
1276 }
1277 }
1278
1279 if self.providers.enabled.contains(&ProviderType::Oxlint)
1280 && !self.providers.oxlint.configured
1281 {
1282 warnings.push(ConfigWarning {
1283 level: WarningLevel::Warning,
1284 message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
1285 });
1286 }
1287
1288 warnings
1289 }
1290
1291 pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
1293 self.providers.enabled.contains(&provider)
1294 }
1295
1296 pub fn get_enabled_providers(&self) -> &[ProviderType] {
1298 &self.providers.enabled
1299 }
1300
1301 pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
1303 if self.is_provider_enabled(ProviderType::Pmd) {
1304 Some(&self.providers.pmd)
1305 } else {
1306 None
1307 }
1308 }
1309
1310 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
1312 self.is_rule_enabled_with_ruleset(rule_id, None)
1313 }
1314
1315 pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
1320 for pattern in &self.rules.disable {
1322 if Self::matches_pattern(rule_id, pattern) {
1323 return false;
1324 }
1325 }
1326
1327 if let Some(ruleset_name) = ruleset {
1329 let ruleset_rules = self.get_ruleset_rules(ruleset_name);
1330 if !ruleset_rules.is_empty() {
1331 return ruleset_rules
1333 .iter()
1334 .any(|r| Self::matches_pattern(rule_id, r));
1335 }
1336 }
1337
1338 for pattern in &self.rules.enable {
1340 if Self::matches_pattern(rule_id, pattern) {
1341 return true;
1342 }
1343 }
1344
1345 false
1346 }
1347
1348 pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
1350 match name {
1351 "security" => self.rulesets.security.clone(),
1352 "maintainability" => self.rulesets.maintainability.clone(),
1353 _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
1354 }
1355 }
1356
1357 pub fn get_ruleset_names(&self) -> Vec<String> {
1359 let mut names = Vec::new();
1360 if !self.rulesets.security.is_empty() {
1361 names.push("security".to_string());
1362 }
1363 if !self.rulesets.maintainability.is_empty() {
1364 names.push("maintainability".to_string());
1365 }
1366 names.extend(self.rulesets.custom.keys().cloned());
1367 names
1368 }
1369
1370 pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
1372 self.severity.get(rule_id).copied()
1373 }
1374
1375 pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
1377 let mut thresholds = self.profiles.get_thresholds(profile).clone();
1378
1379 let path_str = path.to_string_lossy();
1381 for override_ in &self.threshold_overrides {
1382 if Self::matches_glob(&path_str, &override_.path) {
1383 if let Some(v) = override_.max_function_lines {
1384 thresholds.max_function_lines = v;
1385 }
1386 if let Some(v) = override_.max_complexity {
1387 thresholds.max_complexity = v;
1388 }
1389 if let Some(v) = override_.max_cognitive_complexity {
1390 thresholds.max_cognitive_complexity = v;
1391 }
1392 }
1393 }
1394
1395 thresholds
1396 }
1397
1398 pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
1400 let path_str = path.to_string_lossy();
1401 let allowed_paths = match rule_type {
1402 AllowType::InnerHtml => &self.allow.innerhtml_paths,
1403 AllowType::Eval => &self.allow.eval_paths,
1404 AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
1405 };
1406
1407 for pattern in allowed_paths {
1408 if Self::matches_glob(&path_str, pattern) {
1409 return true;
1410 }
1411 }
1412
1413 false
1414 }
1415
1416 fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
1417 if pattern == "*" {
1418 return true;
1419 }
1420
1421 if let Some(prefix) = pattern.strip_suffix("/*") {
1422 return rule_id.starts_with(prefix);
1423 }
1424
1425 rule_id == pattern
1426 }
1427
1428 fn matches_glob(path: &str, pattern: &str) -> bool {
1429 let pattern = pattern
1431 .replace("**", "§")
1432 .replace('*', "[^/]*")
1433 .replace('§', ".*");
1434 regex::Regex::new(&format!("^{}$", pattern))
1435 .map(|re| re.is_match(path))
1436 .unwrap_or(false)
1437 }
1438
1439 pub fn default_toml(profile: Profile) -> String {
1441 let thresholds = ProfileThresholds::for_profile(profile);
1442
1443 format!(
1444 r#"# RMA Configuration
1445# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
1446
1447# Config format version (required for future compatibility)
1448config_version = 1
1449
1450[scan]
1451# Paths to include in scanning (default: all supported files)
1452include = ["src/**", "lib/**", "scripts/**"]
1453
1454# Paths to exclude from scanning
1455exclude = [
1456 "node_modules/**",
1457 "target/**",
1458 "dist/**",
1459 "build/**",
1460 "vendor/**",
1461 "**/*.min.js",
1462 "**/*.bundle.js",
1463]
1464
1465# Maximum file size to scan (10MB default)
1466max_file_size = 10485760
1467
1468[rules]
1469# Rules to enable (wildcards supported)
1470enable = ["*"]
1471
1472# Rules to disable (takes precedence over enable)
1473disable = []
1474
1475# Global ignore paths - findings in these paths are suppressed for all rules
1476# Supports glob patterns. Uncomment to customize.
1477# ignore_paths = ["**/vendor/**", "**/generated/**"]
1478
1479# Per-rule ignore paths - suppress specific rules in specific paths
1480# Note: Security rules (command-injection, hardcoded-secret, etc.) cannot be
1481# suppressed via path ignores, only via inline comments with reason.
1482# [rules.ignore_paths_by_rule]
1483# "generic/long-function" = ["**/tests/**", "**/examples/**"]
1484# "js/console-log" = ["**/debug/**"]
1485
1486# Default test/example suppressions are automatically applied in --mode pr/ci
1487# This reduces noise from test files. Security rules are NOT suppressed.
1488
1489[profiles]
1490# Default profile: fast, balanced, or strict
1491default = "{profile}"
1492
1493[profiles.fast]
1494max_function_lines = 200
1495max_complexity = 25
1496max_cognitive_complexity = 35
1497max_file_lines = 2000
1498
1499[profiles.balanced]
1500max_function_lines = {max_function_lines}
1501max_complexity = {max_complexity}
1502max_cognitive_complexity = {max_cognitive_complexity}
1503max_file_lines = 1000
1504
1505[profiles.strict]
1506max_function_lines = 50
1507max_complexity = 10
1508max_cognitive_complexity = 15
1509max_file_lines = 500
1510
1511[rulesets]
1512# Named rule groups for targeted scanning
1513security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
1514maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
1515
1516[severity]
1517# Override severity for specific rules
1518# "generic/long-function" = "warning"
1519# "js/innerhtml-xss" = "error"
1520# "rust/unsafe-block" = "warning"
1521
1522# [[threshold_overrides]]
1523# path = "src/legacy/**"
1524# max_function_lines = 300
1525# max_complexity = 30
1526
1527# [[threshold_overrides]]
1528# path = "tests/**"
1529# disable_rules = ["generic/long-function"]
1530
1531[allow]
1532# Approved patterns that won't trigger alerts
1533settimeout_string = false
1534settimeout_function = true
1535innerhtml_paths = []
1536eval_paths = []
1537unsafe_rust_paths = []
1538approved_secrets = []
1539
1540[baseline]
1541# Baseline file for tracking legacy issues
1542file = ".rma/baseline.json"
1543# Mode: "all" or "new-only"
1544mode = "all"
1545
1546# =============================================================================
1547# ANALYSIS PROVIDERS
1548# =============================================================================
1549# RMA supports external analysis providers for extended language coverage.
1550# Providers can be enabled/disabled individually.
1551
1552[providers]
1553# List of enabled providers
1554# Default: ["rma", "oxc"] - built-in rules + native JS/TS linting
1555enabled = ["rma", "oxc"]
1556# To add PMD for Java: enabled = ["rma", "oxc", "pmd"]
1557# To add external Oxlint: enabled = ["rma", "oxc", "oxlint"]
1558
1559# -----------------------------------------------------------------------------
1560# PMD Provider - Java Static Analysis (optional)
1561# -----------------------------------------------------------------------------
1562# PMD provides comprehensive Java security and quality analysis.
1563# Requires: Java runtime and PMD installation
1564#
1565# [providers.pmd]
1566# configured = true
1567# java_path = "java" # Path to java binary
1568# pmd_path = "" # Path to pmd binary (or leave empty to use PATH)
1569# rulesets = [
1570# "category/java/security.xml",
1571# "category/java/bestpractices.xml",
1572# "category/java/errorprone.xml",
1573# ]
1574# timeout_ms = 600000 # 10 minutes timeout
1575# include_patterns = ["**/*.java"]
1576# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1577# fail_on_error = false # Continue scan if PMD fails
1578# min_priority = 5 # Report all priorities (1-5)
1579# extra_args = [] # Additional PMD CLI arguments
1580
1581# [providers.pmd.severity_map]
1582# # Map PMD priority (1-5) to RMA severity
1583# "1" = "critical"
1584# "2" = "error"
1585# "3" = "warning"
1586# "4" = "info"
1587# "5" = "info"
1588
1589# -----------------------------------------------------------------------------
1590# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1591# -----------------------------------------------------------------------------
1592# [providers.oxlint]
1593# configured = true
1594# binary_path = "" # Path to oxlint binary (or leave empty to use PATH)
1595# timeout_ms = 300000 # 5 minutes timeout
1596# extra_args = []
1597"#,
1598 profile = profile,
1599 max_function_lines = thresholds.max_function_lines,
1600 max_complexity = thresholds.max_complexity,
1601 max_cognitive_complexity = thresholds.max_cognitive_complexity,
1602 )
1603 }
1604}
1605
1606#[derive(Debug, Clone, Copy)]
1608pub enum AllowType {
1609 InnerHtml,
1610 Eval,
1611 UnsafeRust,
1612}
1613
1614#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1616#[serde(rename_all = "kebab-case")]
1617pub enum ConfigSource {
1618 Default,
1620 ConfigFile,
1622 CliFlag,
1624}
1625
1626impl std::fmt::Display for ConfigSource {
1627 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1628 match self {
1629 ConfigSource::Default => write!(f, "default"),
1630 ConfigSource::ConfigFile => write!(f, "config-file"),
1631 ConfigSource::CliFlag => write!(f, "cli-flag"),
1632 }
1633 }
1634}
1635
1636#[derive(Debug, Clone, Serialize, Deserialize)]
1643pub struct EffectiveConfig {
1644 pub config_file: Option<PathBuf>,
1646
1647 pub profile: Profile,
1649
1650 pub profile_source: ConfigSource,
1652
1653 pub thresholds: ProfileThresholds,
1655
1656 pub enabled_rules_count: usize,
1658
1659 pub disabled_rules_count: usize,
1661
1662 pub severity_overrides_count: usize,
1664
1665 pub threshold_override_paths: Vec<String>,
1667
1668 pub baseline_mode: BaselineMode,
1670
1671 pub baseline_mode_source: ConfigSource,
1673
1674 pub exclude_patterns: Vec<String>,
1676
1677 pub include_patterns: Vec<String>,
1679}
1680
1681impl EffectiveConfig {
1682 pub fn resolve(
1684 toml_config: Option<&RmaTomlConfig>,
1685 config_path: Option<&Path>,
1686 cli_profile: Option<Profile>,
1687 cli_baseline_mode: bool,
1688 ) -> Self {
1689 let (profile, profile_source) = if let Some(p) = cli_profile {
1691 (p, ConfigSource::CliFlag)
1692 } else if let Some(cfg) = toml_config {
1693 (cfg.profiles.default, ConfigSource::ConfigFile)
1694 } else {
1695 (Profile::default(), ConfigSource::Default)
1696 };
1697
1698 let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1700 (BaselineMode::NewOnly, ConfigSource::CliFlag)
1701 } else if let Some(cfg) = toml_config {
1702 (cfg.baseline.mode, ConfigSource::ConfigFile)
1703 } else {
1704 (BaselineMode::default(), ConfigSource::Default)
1705 };
1706
1707 let thresholds = toml_config
1709 .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1710 .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1711
1712 let (enabled_rules_count, disabled_rules_count) = toml_config
1714 .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1715 .unwrap_or((1, 0)); let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1719
1720 let threshold_override_paths = toml_config
1722 .map(|cfg| {
1723 cfg.threshold_overrides
1724 .iter()
1725 .map(|o| o.path.clone())
1726 .collect()
1727 })
1728 .unwrap_or_default();
1729
1730 let exclude_patterns = toml_config
1732 .map(|cfg| cfg.scan.exclude.clone())
1733 .unwrap_or_default();
1734
1735 let include_patterns = toml_config
1736 .map(|cfg| cfg.scan.include.clone())
1737 .unwrap_or_default();
1738
1739 Self {
1740 config_file: config_path.map(|p| p.to_path_buf()),
1741 profile,
1742 profile_source,
1743 thresholds,
1744 enabled_rules_count,
1745 disabled_rules_count,
1746 severity_overrides_count,
1747 threshold_override_paths,
1748 baseline_mode,
1749 baseline_mode_source,
1750 exclude_patterns,
1751 include_patterns,
1752 }
1753 }
1754
1755 pub fn to_text(&self) -> String {
1757 let mut out = String::new();
1758
1759 out.push_str("Effective Configuration\n");
1760 out.push_str("═══════════════════════════════════════════════════════════\n\n");
1761
1762 out.push_str(" Config file: ");
1764 match &self.config_file {
1765 Some(p) => out.push_str(&format!("{}\n", p.display())),
1766 None => out.push_str("(none - using defaults)\n"),
1767 }
1768
1769 out.push_str(&format!(
1771 " Profile: {} (from {})\n",
1772 self.profile, self.profile_source
1773 ));
1774
1775 out.push_str("\n Thresholds:\n");
1777 out.push_str(&format!(
1778 " max_function_lines: {}\n",
1779 self.thresholds.max_function_lines
1780 ));
1781 out.push_str(&format!(
1782 " max_complexity: {}\n",
1783 self.thresholds.max_complexity
1784 ));
1785 out.push_str(&format!(
1786 " max_cognitive_complexity: {}\n",
1787 self.thresholds.max_cognitive_complexity
1788 ));
1789 out.push_str(&format!(
1790 " max_file_lines: {}\n",
1791 self.thresholds.max_file_lines
1792 ));
1793
1794 out.push_str("\n Rules:\n");
1796 out.push_str(&format!(
1797 " enabled patterns: {}\n",
1798 self.enabled_rules_count
1799 ));
1800 out.push_str(&format!(
1801 " disabled patterns: {}\n",
1802 self.disabled_rules_count
1803 ));
1804 out.push_str(&format!(
1805 " severity overrides: {}\n",
1806 self.severity_overrides_count
1807 ));
1808
1809 if !self.threshold_override_paths.is_empty() {
1811 out.push_str("\n Threshold overrides:\n");
1812 for path in &self.threshold_override_paths {
1813 out.push_str(&format!(" - {}\n", path));
1814 }
1815 }
1816
1817 out.push_str(&format!(
1819 "\n Baseline mode: {:?} (from {})\n",
1820 self.baseline_mode, self.baseline_mode_source
1821 ));
1822
1823 out
1824 }
1825
1826 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1828 serde_json::to_string_pretty(self)
1829 }
1830}
1831
1832#[derive(Debug, Clone)]
1834pub struct ConfigWarning {
1835 pub level: WarningLevel,
1836 pub message: String,
1837}
1838
1839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1840pub enum WarningLevel {
1841 Warning,
1842 Error,
1843}
1844
1845#[derive(Debug, Clone, PartialEq, Eq)]
1847pub struct InlineSuppression {
1848 pub rule_id: String,
1850 pub reason: Option<String>,
1852 pub line: usize,
1854 pub suppression_type: SuppressionType,
1856}
1857
1858#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1860pub enum SuppressionType {
1861 NextLine,
1863 Block,
1865}
1866
1867impl InlineSuppression {
1868 pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1875 let trimmed = line.trim();
1876
1877 let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1879 rest.trim()
1880 } else if let Some(rest) = trimmed.strip_prefix('#') {
1881 rest.trim()
1882 } else {
1883 return None;
1884 };
1885
1886 if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1888 return Self::parse_suppression_body(
1889 rest.trim(),
1890 line_number,
1891 SuppressionType::NextLine,
1892 );
1893 }
1894
1895 if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1897 return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1898 }
1899
1900 None
1901 }
1902
1903 fn parse_suppression_body(
1904 body: &str,
1905 line_number: usize,
1906 suppression_type: SuppressionType,
1907 ) -> Option<Self> {
1908 if body.is_empty() {
1909 return None;
1910 }
1911
1912 let mut parts = body.splitn(2, ' ');
1914 let rule_id = parts.next()?.trim().to_string();
1915
1916 if rule_id.is_empty() {
1917 return None;
1918 }
1919
1920 let reason = parts.next().and_then(|rest| {
1921 if let Some(start) = rest.find("reason=\"") {
1923 let after_quote = &rest[start + 8..];
1924 if let Some(end) = after_quote.find('"') {
1925 return Some(after_quote[..end].to_string());
1926 }
1927 }
1928 None
1929 });
1930
1931 Some(Self {
1932 rule_id,
1933 reason,
1934 line: line_number,
1935 suppression_type,
1936 })
1937 }
1938
1939 pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1941 if self.rule_id != rule_id && self.rule_id != "*" {
1942 return false;
1943 }
1944
1945 match self.suppression_type {
1946 SuppressionType::NextLine => finding_line == self.line + 1,
1947 SuppressionType::Block => finding_line >= self.line,
1948 }
1949 }
1950
1951 pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1953 if require_reason && self.reason.is_none() {
1954 return Err(format!(
1955 "Suppression for '{}' at line {} requires a reason in strict profile",
1956 self.rule_id, self.line
1957 ));
1958 }
1959 Ok(())
1960 }
1961}
1962
1963pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1965 content
1966 .lines()
1967 .enumerate()
1968 .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1969 .collect()
1970}
1971
1972#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1984pub struct Fingerprint(String);
1985
1986impl Fingerprint {
1987 pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1989 use sha2::{Digest, Sha256};
1990
1991 let mut hasher = Sha256::new();
1992
1993 hasher.update(rule_id.as_bytes());
1995 hasher.update(b"|");
1996
1997 let normalized_path = file_path
1999 .to_string_lossy()
2000 .replace('\\', "/")
2001 .to_lowercase();
2002 hasher.update(normalized_path.as_bytes());
2003 hasher.update(b"|");
2004
2005 let normalized_snippet = Self::normalize_snippet(snippet);
2007 hasher.update(normalized_snippet.as_bytes());
2008
2009 let hash = hasher.finalize();
2010 Self(format!("sha256:{:x}", hash))
2011 }
2012
2013 fn normalize_snippet(snippet: &str) -> String {
2015 snippet
2016 .split_whitespace()
2017 .collect::<Vec<_>>()
2018 .join(" ")
2019 .trim()
2020 .to_string()
2021 }
2022
2023 pub fn as_str(&self) -> &str {
2025 &self.0
2026 }
2027
2028 pub fn from_string(s: String) -> Self {
2030 Self(s)
2031 }
2032}
2033
2034impl std::fmt::Display for Fingerprint {
2035 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2036 write!(f, "{}", self.0)
2037 }
2038}
2039
2040impl From<Fingerprint> for String {
2041 fn from(fp: Fingerprint) -> String {
2042 fp.0
2043 }
2044}
2045
2046#[derive(Debug, Clone, Serialize, Deserialize)]
2048pub struct BaselineEntry {
2049 pub rule_id: String,
2050 pub file: PathBuf,
2051 #[serde(default)]
2052 pub line: usize,
2053 pub fingerprint: String,
2054 pub first_seen: String,
2055 #[serde(default)]
2056 pub suppressed: bool,
2057 #[serde(default)]
2058 pub comment: Option<String>,
2059}
2060
2061#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2063pub struct Baseline {
2064 pub version: String,
2065 pub created: String,
2066 pub entries: Vec<BaselineEntry>,
2067}
2068
2069impl Baseline {
2070 pub fn new() -> Self {
2071 Self {
2072 version: "1.0".to_string(),
2073 created: chrono::Utc::now().to_rfc3339(),
2074 entries: Vec::new(),
2075 }
2076 }
2077
2078 pub fn load(path: &Path) -> Result<Self, String> {
2079 let content = std::fs::read_to_string(path)
2080 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
2081
2082 serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
2083 }
2084
2085 pub fn save(&self, path: &Path) -> Result<(), String> {
2086 if let Some(parent) = path.parent() {
2087 std::fs::create_dir_all(parent)
2088 .map_err(|e| format!("Failed to create directory: {}", e))?;
2089 }
2090
2091 let content = serde_json::to_string_pretty(self)
2092 .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
2093
2094 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
2095 }
2096
2097 pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
2099 self.entries
2100 .iter()
2101 .any(|e| e.fingerprint == fingerprint.as_str())
2102 }
2103
2104 pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
2106 self.entries
2107 .iter()
2108 .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
2109 }
2110
2111 pub fn add_with_fingerprint(
2113 &mut self,
2114 rule_id: String,
2115 file: PathBuf,
2116 line: usize,
2117 fingerprint: Fingerprint,
2118 ) {
2119 if !self.contains_fingerprint(&fingerprint) {
2120 self.entries.push(BaselineEntry {
2121 rule_id,
2122 file,
2123 line,
2124 fingerprint: fingerprint.into(),
2125 first_seen: chrono::Utc::now().to_rfc3339(),
2126 suppressed: false,
2127 comment: None,
2128 });
2129 }
2130 }
2131
2132 pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
2134 if !self.contains(&rule_id, &file, &fingerprint) {
2135 self.entries.push(BaselineEntry {
2136 rule_id,
2137 file,
2138 line,
2139 fingerprint,
2140 first_seen: chrono::Utc::now().to_rfc3339(),
2141 suppressed: false,
2142 comment: None,
2143 });
2144 }
2145 }
2146}
2147
2148#[derive(Debug, Clone, Serialize, Deserialize)]
2154pub struct SuppressionResult {
2155 pub suppressed: bool,
2157 pub reason: Option<String>,
2159 pub source: Option<SuppressionSource>,
2161 pub location: Option<String>,
2163}
2164
2165impl SuppressionResult {
2166 pub fn not_suppressed() -> Self {
2168 Self {
2169 suppressed: false,
2170 reason: None,
2171 source: None,
2172 location: None,
2173 }
2174 }
2175
2176 pub fn suppressed(source: SuppressionSource, reason: String, location: String) -> Self {
2178 Self {
2179 suppressed: true,
2180 reason: Some(reason),
2181 source: Some(source),
2182 location: Some(location),
2183 }
2184 }
2185}
2186
2187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2189#[serde(rename_all = "kebab-case")]
2190pub enum SuppressionSource {
2191 Inline,
2193 PathGlobal,
2195 PathRule,
2197 Preset,
2199 Baseline,
2201 Database,
2203}
2204
2205impl std::fmt::Display for SuppressionSource {
2206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2207 match self {
2208 SuppressionSource::Inline => write!(f, "inline"),
2209 SuppressionSource::PathGlobal => write!(f, "path-global"),
2210 SuppressionSource::PathRule => write!(f, "path-rule"),
2211 SuppressionSource::Preset => write!(f, "preset"),
2212 SuppressionSource::Baseline => write!(f, "baseline"),
2213 SuppressionSource::Database => write!(f, "database"),
2214 }
2215 }
2216}
2217
2218pub struct SuppressionEngine {
2228 global_ignore_paths: Vec<String>,
2230 rule_ignore_paths: HashMap<String, Vec<String>>,
2232 use_default_presets: bool,
2234 skip_security_in_tests: bool,
2236 baseline: Option<Baseline>,
2238 global_patterns: Vec<regex::Regex>,
2240 rule_patterns: HashMap<String, Vec<regex::Regex>>,
2242 test_patterns: Vec<regex::Regex>,
2244 example_patterns: Vec<regex::Regex>,
2246 vendor_patterns: Vec<regex::Regex>,
2248 suppression_store: Option<std::sync::Arc<crate::suppression::SuppressionStore>>,
2250}
2251
2252impl SuppressionEngine {
2253 pub fn new(rules_config: &RulesConfig, use_default_presets: bool) -> Self {
2255 let global_patterns = rules_config
2256 .ignore_paths
2257 .iter()
2258 .filter_map(|p| Self::compile_glob(p))
2259 .collect();
2260
2261 let mut rule_patterns = HashMap::new();
2262 for (rule_id, paths) in &rules_config.ignore_paths_by_rule {
2263 let patterns: Vec<regex::Regex> =
2264 paths.iter().filter_map(|p| Self::compile_glob(p)).collect();
2265 if !patterns.is_empty() {
2266 rule_patterns.insert(rule_id.clone(), patterns);
2267 }
2268 }
2269
2270 let test_patterns = if use_default_presets {
2271 DEFAULT_TEST_IGNORE_PATHS
2272 .iter()
2273 .filter_map(|p| Self::compile_glob(p))
2274 .collect()
2275 } else {
2276 Vec::new()
2277 };
2278
2279 let example_patterns = if use_default_presets {
2280 DEFAULT_EXAMPLE_IGNORE_PATHS
2281 .iter()
2282 .filter_map(|p| Self::compile_glob(p))
2283 .collect()
2284 } else {
2285 Vec::new()
2286 };
2287
2288 let vendor_patterns: Vec<regex::Regex> = DEFAULT_VENDOR_IGNORE_PATHS
2290 .iter()
2291 .filter_map(|p| Self::compile_glob(p))
2292 .collect();
2293
2294 Self {
2295 global_ignore_paths: rules_config.ignore_paths.clone(),
2296 rule_ignore_paths: rules_config.ignore_paths_by_rule.clone(),
2297 use_default_presets,
2298 skip_security_in_tests: false,
2299 baseline: None,
2300 global_patterns,
2301 rule_patterns,
2302 test_patterns,
2303 example_patterns,
2304 vendor_patterns,
2305 suppression_store: None,
2306 }
2307 }
2308
2309 pub fn with_defaults_only() -> Self {
2311 Self::new(&RulesConfig::default(), true)
2312 }
2313
2314 pub fn with_baseline(mut self, baseline: Baseline) -> Self {
2316 self.baseline = Some(baseline);
2317 self
2318 }
2319
2320 pub fn with_store(mut self, store: crate::suppression::SuppressionStore) -> Self {
2322 self.suppression_store = Some(std::sync::Arc::new(store));
2323 self
2324 }
2325
2326 pub fn with_store_ref(
2328 mut self,
2329 store: std::sync::Arc<crate::suppression::SuppressionStore>,
2330 ) -> Self {
2331 self.suppression_store = Some(store);
2332 self
2333 }
2334
2335 pub fn store(&self) -> Option<&crate::suppression::SuppressionStore> {
2337 self.suppression_store.as_ref().map(|s| s.as_ref())
2338 }
2339
2340 pub fn with_skip_security_in_tests(mut self, skip: bool) -> Self {
2342 self.skip_security_in_tests = skip;
2343 self
2344 }
2345
2346 fn compile_glob(pattern: &str) -> Option<regex::Regex> {
2352 let regex_pattern = pattern
2353 .replace('.', r"\.")
2354 .replace("**", "§")
2355 .replace('*', "[^/]*")
2356 .replace('§', ".*");
2357
2358 let regex_pattern = if let Some(rest) = regex_pattern.strip_prefix(".*/") {
2361 format!("(^|.*/){}", rest)
2363 } else if regex_pattern.starts_with(".*") {
2364 regex_pattern
2366 } else {
2367 format!("^{}", regex_pattern)
2369 };
2370
2371 regex::Regex::new(&format!("(?i){}$", regex_pattern)).ok()
2372 }
2373
2374 fn matches_patterns(path: &str, patterns: &[regex::Regex]) -> bool {
2376 let normalized = path.replace('\\', "/");
2377 patterns.iter().any(|re| re.is_match(&normalized))
2378 }
2379
2380 pub fn is_always_enabled(rule_id: &str) -> bool {
2382 RULES_ALWAYS_ENABLED.iter().any(|r| {
2383 rule_id == *r
2384 || rule_id.starts_with(&format!("{}:", r))
2385 || r.ends_with("*") && rule_id.starts_with(r.trim_end_matches('*'))
2386 })
2387 }
2388
2389 pub fn check(
2401 &self,
2402 rule_id: &str,
2403 file_path: &Path,
2404 finding_line: usize,
2405 inline_suppressions: &[InlineSuppression],
2406 fingerprint: Option<&str>,
2407 ) -> SuppressionResult {
2408 let path_str = file_path.to_string_lossy();
2409
2410 for suppression in inline_suppressions {
2412 if suppression.applies_to(finding_line, rule_id) {
2413 let reason = suppression
2414 .reason
2415 .clone()
2416 .unwrap_or_else(|| "No reason provided".to_string());
2417 return SuppressionResult::suppressed(
2418 SuppressionSource::Inline,
2419 reason,
2420 format!("line {}", suppression.line),
2421 );
2422 }
2423 }
2424
2425 let is_always_enabled = Self::is_always_enabled(rule_id) && !self.skip_security_in_tests;
2428
2429 if !is_always_enabled {
2430 if Self::matches_patterns(&path_str, &self.global_patterns) {
2432 for (i, pattern) in self.global_ignore_paths.iter().enumerate() {
2433 if let Some(re) = self.global_patterns.get(i)
2434 && re.is_match(&path_str.replace('\\', "/"))
2435 {
2436 return SuppressionResult::suppressed(
2437 SuppressionSource::PathGlobal,
2438 format!("Path matches global ignore pattern: {}", pattern),
2439 pattern.clone(),
2440 );
2441 }
2442 }
2443 }
2444
2445 if let Some(patterns) = self.rule_patterns.get(rule_id)
2447 && Self::matches_patterns(&path_str, patterns)
2448 && let Some(rule_paths) = self.rule_ignore_paths.get(rule_id)
2449 {
2450 for (i, pattern) in rule_paths.iter().enumerate() {
2451 if let Some(re) = patterns.get(i)
2452 && re.is_match(&path_str.replace('\\', "/"))
2453 {
2454 return SuppressionResult::suppressed(
2455 SuppressionSource::PathRule,
2456 format!("Path matches rule-specific ignore pattern: {}", pattern),
2457 format!("{}:{}", rule_id, pattern),
2458 );
2459 }
2460 }
2461 }
2462
2463 for (pattern_rule_id, patterns) in &self.rule_patterns {
2465 if pattern_rule_id.ends_with("/*") {
2466 let prefix = pattern_rule_id.trim_end_matches("/*");
2467 if rule_id.starts_with(prefix)
2468 && Self::matches_patterns(&path_str, patterns)
2469 && let Some(rule_paths) = self.rule_ignore_paths.get(pattern_rule_id)
2470 && let Some(pattern) = rule_paths.first()
2471 {
2472 return SuppressionResult::suppressed(
2473 SuppressionSource::PathRule,
2474 format!("Path matches rule-specific ignore pattern: {}", pattern),
2475 format!("{}:{}", pattern_rule_id, pattern),
2476 );
2477 }
2478 }
2479 }
2480
2481 if self.use_default_presets {
2483 if Self::matches_patterns(&path_str, &self.test_patterns) {
2484 return SuppressionResult::suppressed(
2485 SuppressionSource::Preset,
2486 "File is in test directory (suppressed by default preset)".to_string(),
2487 "test-preset".to_string(),
2488 );
2489 }
2490 if Self::matches_patterns(&path_str, &self.example_patterns) {
2491 return SuppressionResult::suppressed(
2492 SuppressionSource::Preset,
2493 "File is in example/fixture directory (suppressed by default preset)"
2494 .to_string(),
2495 "example-preset".to_string(),
2496 );
2497 }
2498 }
2499
2500 if Self::matches_patterns(&path_str, &self.vendor_patterns) {
2503 return SuppressionResult::suppressed(
2504 SuppressionSource::Preset,
2505 "File is vendored/bundled/minified third-party code".to_string(),
2506 "vendor-preset".to_string(),
2507 );
2508 }
2509 }
2510
2511 if let Some(ref baseline) = self.baseline
2513 && let Some(fp) = fingerprint
2514 {
2515 let fingerprint_obj = Fingerprint::from_string(fp.to_string());
2516 if baseline.contains_fingerprint(&fingerprint_obj) {
2517 return SuppressionResult::suppressed(
2518 SuppressionSource::Baseline,
2519 "Finding is in baseline".to_string(),
2520 "baseline".to_string(),
2521 );
2522 }
2523 }
2524
2525 if let Some(ref store) = self.suppression_store
2527 && let Some(fp) = fingerprint
2528 && let Ok(Some(entry)) = store.is_suppressed(fp)
2529 {
2530 return SuppressionResult::suppressed(
2531 SuppressionSource::Database,
2532 entry.reason.clone(),
2533 format!("database:{}", entry.id),
2534 );
2535 }
2536
2537 SuppressionResult::not_suppressed()
2538 }
2539
2540 pub fn should_skip_path(&self, file_path: &Path) -> bool {
2545 let path_str = file_path.to_string_lossy();
2546
2547 if Self::matches_patterns(&path_str, &self.global_patterns) {
2549 return true;
2550 }
2551
2552 if self.use_default_presets {
2554 false
2559 } else {
2560 false
2561 }
2562 }
2563
2564 pub fn add_suppression_metadata(
2566 properties: &mut HashMap<String, serde_json::Value>,
2567 result: &SuppressionResult,
2568 ) {
2569 if result.suppressed {
2570 properties.insert("suppressed".to_string(), serde_json::json!(true));
2571 if let Some(ref reason) = result.reason {
2572 properties.insert("suppression_reason".to_string(), serde_json::json!(reason));
2573 }
2574 if let Some(ref source) = result.source {
2575 properties.insert(
2576 "suppression_source".to_string(),
2577 serde_json::json!(source.to_string()),
2578 );
2579
2580 if *source == SuppressionSource::Database
2582 && let Some(ref location) = result.location
2583 && let Some(id) = location.strip_prefix("database:")
2584 {
2585 properties.insert("suppression_id".to_string(), serde_json::json!(id));
2586 }
2587 }
2588 if let Some(ref location) = result.location {
2589 properties.insert(
2590 "suppression_location".to_string(),
2591 serde_json::json!(location),
2592 );
2593 }
2594 }
2595 }
2596}
2597
2598impl Default for SuppressionEngine {
2599 fn default() -> Self {
2600 Self::new(&RulesConfig::default(), false)
2601 }
2602}
2603
2604#[cfg(test)]
2605mod tests {
2606 use super::*;
2607 use std::str::FromStr;
2608
2609 #[test]
2610 fn test_profile_parsing() {
2611 assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
2612 assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
2613 assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
2614 assert!(Profile::from_str("unknown").is_err());
2615 }
2616
2617 #[test]
2618 fn test_rule_matching() {
2619 assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
2620 assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
2621 assert!(!RmaTomlConfig::matches_pattern(
2622 "generic/long",
2623 "security/*"
2624 ));
2625 assert!(RmaTomlConfig::matches_pattern(
2626 "security/xss",
2627 "security/xss"
2628 ));
2629 }
2630
2631 #[test]
2632 fn test_default_config_parses() {
2633 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2634 let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
2635 assert_eq!(config.profiles.default, Profile::Balanced);
2636 }
2637
2638 #[test]
2639 fn test_thresholds_for_profile() {
2640 let fast = ProfileThresholds::for_profile(Profile::Fast);
2641 let strict = ProfileThresholds::for_profile(Profile::Strict);
2642
2643 assert!(fast.max_function_lines > strict.max_function_lines);
2644 assert!(fast.max_complexity > strict.max_complexity);
2645 }
2646
2647 #[test]
2648 fn test_fingerprint_stable_across_line_changes() {
2649 let fp1 = Fingerprint::generate(
2651 "js/xss-sink",
2652 Path::new("src/app.js"),
2653 "element.textContent = userInput;",
2654 );
2655 let fp2 = Fingerprint::generate(
2656 "js/xss-sink",
2657 Path::new("src/app.js"),
2658 "element.textContent = userInput;",
2659 );
2660
2661 assert_eq!(fp1, fp2);
2662 }
2663
2664 #[test]
2665 fn test_fingerprint_stable_with_whitespace_changes() {
2666 let fp1 = Fingerprint::generate(
2668 "generic/long-function",
2669 Path::new("src/utils.rs"),
2670 "fn very_long_function() {",
2671 );
2672 let fp2 = Fingerprint::generate(
2673 "generic/long-function",
2674 Path::new("src/utils.rs"),
2675 "fn very_long_function() {",
2676 );
2677 let fp3 = Fingerprint::generate(
2678 "generic/long-function",
2679 Path::new("src/utils.rs"),
2680 " fn very_long_function() { ",
2681 );
2682
2683 assert_eq!(fp1, fp2);
2684 assert_eq!(fp2, fp3);
2685 }
2686
2687 #[test]
2688 fn test_fingerprint_different_for_different_rules() {
2689 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2690 let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
2691
2692 assert_ne!(fp1, fp2);
2693 }
2694
2695 #[test]
2696 fn test_fingerprint_different_for_different_files() {
2697 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2698 let fp2 =
2699 Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
2700
2701 assert_ne!(fp1, fp2);
2702 }
2703
2704 #[test]
2705 fn test_fingerprint_path_normalization() {
2706 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
2708 let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
2709
2710 assert_eq!(fp1, fp2);
2711 }
2712
2713 #[test]
2714 fn test_effective_config_precedence() {
2715 let toml_config = RmaTomlConfig::default();
2717 let effective = EffectiveConfig::resolve(
2718 Some(&toml_config),
2719 Some(Path::new("rma.toml")),
2720 Some(Profile::Strict), false,
2722 );
2723
2724 assert_eq!(effective.profile, Profile::Strict);
2725 assert_eq!(effective.profile_source, ConfigSource::CliFlag);
2726 }
2727
2728 #[test]
2729 fn test_effective_config_defaults() {
2730 let effective = EffectiveConfig::resolve(None, None, None, false);
2732
2733 assert_eq!(effective.profile, Profile::Balanced);
2734 assert_eq!(effective.profile_source, ConfigSource::Default);
2735 assert!(effective.config_file.is_none());
2736 }
2737
2738 #[test]
2739 fn test_effective_config_from_file() {
2740 let mut toml_config = RmaTomlConfig::default();
2742 toml_config.profiles.default = Profile::Fast;
2743
2744 let effective =
2745 EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
2746
2747 assert_eq!(effective.profile, Profile::Fast);
2748 assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
2749 }
2750
2751 #[test]
2752 fn test_config_version_missing_warns() {
2753 let toml = r#"
2754[profiles]
2755default = "balanced"
2756"#;
2757 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2758 assert!(config.config_version.is_none());
2759 assert!(!config.has_version());
2760 assert_eq!(config.effective_version(), 1);
2761
2762 let warnings = config.validate();
2763 assert!(
2764 warnings
2765 .iter()
2766 .any(|w| w.message.contains("Missing 'config_version'"))
2767 );
2768 }
2769
2770 #[test]
2771 fn test_config_version_1_ok() {
2772 let toml = r#"
2773config_version = 1
2774
2775[profiles]
2776default = "balanced"
2777"#;
2778 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2779 assert_eq!(config.config_version, Some(1));
2780 assert!(config.has_version());
2781 assert_eq!(config.effective_version(), 1);
2782
2783 let warnings = config.validate();
2784 assert!(
2785 !warnings
2786 .iter()
2787 .any(|w| w.message.contains("config_version"))
2788 );
2789 }
2790
2791 #[test]
2792 fn test_config_version_999_fails() {
2793 let toml = r#"
2794config_version = 999
2795
2796[profiles]
2797default = "balanced"
2798"#;
2799 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2800 assert_eq!(config.config_version, Some(999));
2801
2802 let warnings = config.validate();
2803 let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
2804 assert!(error.is_some());
2805 assert!(
2806 error
2807 .unwrap()
2808 .message
2809 .contains("Unsupported config version: 999")
2810 );
2811 }
2812
2813 #[test]
2814 fn test_default_toml_includes_version() {
2815 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2816 assert!(toml.contains("config_version = 1"));
2817
2818 let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
2820 assert_eq!(config.config_version, Some(1));
2821 }
2822
2823 #[test]
2824 fn test_ruleset_security() {
2825 let toml = r#"
2826config_version = 1
2827
2828[rulesets]
2829security = ["js/innerhtml-xss", "js/timer-string-eval"]
2830maintainability = ["generic/long-function"]
2831
2832[rules]
2833enable = ["*"]
2834"#;
2835 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2836
2837 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2839 assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2840 assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
2841
2842 assert!(config.is_rule_enabled("generic/long-function"));
2844 }
2845
2846 #[test]
2847 fn test_ruleset_with_disable() {
2848 let toml = r#"
2849config_version = 1
2850
2851[rulesets]
2852security = ["js/innerhtml-xss", "js/timer-string-eval"]
2853
2854[rules]
2855enable = ["*"]
2856disable = ["js/timer-string-eval"]
2857"#;
2858 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2859
2860 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2862 assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2863 }
2864
2865 #[test]
2866 fn test_get_ruleset_names() {
2867 let toml = r#"
2868config_version = 1
2869
2870[rulesets]
2871security = ["js/innerhtml-xss"]
2872maintainability = ["generic/long-function"]
2873"#;
2874 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2875 let names = config.get_ruleset_names();
2876
2877 assert!(names.contains(&"security".to_string()));
2878 assert!(names.contains(&"maintainability".to_string()));
2879 }
2880
2881 #[test]
2882 fn test_default_toml_includes_rulesets() {
2883 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2884 assert!(toml.contains("[rulesets]"));
2885 assert!(toml.contains("security = "));
2886 assert!(toml.contains("maintainability = "));
2887 }
2888
2889 #[test]
2890 fn test_inline_suppression_next_line() {
2891 let suppression = InlineSuppression::parse(
2892 "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
2893 10,
2894 );
2895 assert!(suppression.is_some());
2896 let s = suppression.unwrap();
2897 assert_eq!(s.rule_id, "js/innerhtml-xss");
2898 assert_eq!(s.reason, Some("sanitized input".to_string()));
2899 assert_eq!(s.line, 10);
2900 assert_eq!(s.suppression_type, SuppressionType::NextLine);
2901
2902 assert!(s.applies_to(11, "js/innerhtml-xss"));
2904 assert!(!s.applies_to(12, "js/innerhtml-xss"));
2906 assert!(!s.applies_to(11, "js/console-log"));
2908 }
2909
2910 #[test]
2911 fn test_inline_suppression_block() {
2912 let suppression = InlineSuppression::parse(
2913 "// rma-ignore generic/long-function reason=\"legacy code\"",
2914 5,
2915 );
2916 assert!(suppression.is_some());
2917 let s = suppression.unwrap();
2918 assert_eq!(s.rule_id, "generic/long-function");
2919 assert_eq!(s.suppression_type, SuppressionType::Block);
2920
2921 assert!(s.applies_to(5, "generic/long-function"));
2923 assert!(s.applies_to(10, "generic/long-function"));
2924 assert!(s.applies_to(100, "generic/long-function"));
2925 }
2926
2927 #[test]
2928 fn test_inline_suppression_without_reason() {
2929 let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
2930 assert!(suppression.is_some());
2931 let s = suppression.unwrap();
2932 assert_eq!(s.rule_id, "js/console-log");
2933 assert!(s.reason.is_none());
2934 }
2935
2936 #[test]
2937 fn test_inline_suppression_python_style() {
2938 let suppression = InlineSuppression::parse(
2939 "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
2940 3,
2941 );
2942 assert!(suppression.is_some());
2943 let s = suppression.unwrap();
2944 assert_eq!(s.rule_id, "python/hardcoded-secret");
2945 assert_eq!(s.reason, Some("test data".to_string()));
2946 }
2947
2948 #[test]
2949 fn test_inline_suppression_validation_strict() {
2950 let s = InlineSuppression {
2951 rule_id: "js/xss".to_string(),
2952 reason: None,
2953 line: 1,
2954 suppression_type: SuppressionType::NextLine,
2955 };
2956
2957 assert!(s.validate(true).is_err());
2959 assert!(s.validate(false).is_ok());
2961
2962 let s_with_reason = InlineSuppression {
2963 rule_id: "js/xss".to_string(),
2964 reason: Some("approved".to_string()),
2965 line: 1,
2966 suppression_type: SuppressionType::NextLine,
2967 };
2968
2969 assert!(s_with_reason.validate(true).is_ok());
2971 assert!(s_with_reason.validate(false).is_ok());
2972 }
2973
2974 #[test]
2975 fn test_parse_inline_suppressions() {
2976 let content = r#"
2977function foo() {
2978 // rma-ignore-next-line js/console-log reason="debugging"
2979 console.log("test");
2980
2981 // rma-ignore generic/long-function reason="complex algorithm"
2982 // ... lots of code ...
2983}
2984"#;
2985 let suppressions = parse_inline_suppressions(content);
2986 assert_eq!(suppressions.len(), 2);
2987 assert_eq!(suppressions[0].rule_id, "js/console-log");
2988 assert_eq!(suppressions[1].rule_id, "generic/long-function");
2989 }
2990
2991 #[test]
2992 fn test_suppression_does_not_affect_other_rules() {
2993 let suppression = InlineSuppression::parse(
2994 "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2995 10,
2996 )
2997 .unwrap();
2998
2999 assert!(suppression.applies_to(11, "js/innerhtml-xss"));
3001 assert!(!suppression.applies_to(11, "js/console-log"));
3003 assert!(!suppression.applies_to(11, "generic/long-function"));
3004 }
3005
3006 #[test]
3011 fn test_suppression_engine_global_path_ignore() {
3012 let rules_config = RulesConfig {
3013 ignore_paths: vec!["**/vendor/**".to_string(), "**/generated/**".to_string()],
3014 ..Default::default()
3015 };
3016
3017 let engine = SuppressionEngine::new(&rules_config, false);
3018
3019 let result = engine.check(
3021 "generic/long-function",
3022 Path::new("src/vendor/lib.js"),
3023 10,
3024 &[],
3025 None,
3026 );
3027 assert!(result.suppressed);
3028 assert_eq!(result.source, Some(SuppressionSource::PathGlobal));
3029
3030 let result = engine.check(
3032 "generic/long-function",
3033 Path::new("src/app.js"),
3034 10,
3035 &[],
3036 None,
3037 );
3038 assert!(!result.suppressed);
3039 }
3040
3041 #[test]
3042 fn test_suppression_engine_per_rule_path_ignore() {
3043 let rules_config = RulesConfig {
3044 ignore_paths_by_rule: HashMap::from([(
3045 "generic/long-function".to_string(),
3046 vec!["**/tests/**".to_string()],
3047 )]),
3048 ..Default::default()
3049 };
3050
3051 let engine = SuppressionEngine::new(&rules_config, false);
3052
3053 let result = engine.check(
3055 "generic/long-function",
3056 Path::new("src/tests/test_app.js"),
3057 10,
3058 &[],
3059 None,
3060 );
3061 assert!(result.suppressed);
3062 assert_eq!(result.source, Some(SuppressionSource::PathRule));
3063
3064 let result = engine.check(
3066 "js/console-log",
3067 Path::new("src/tests/test_app.js"),
3068 10,
3069 &[],
3070 None,
3071 );
3072 assert!(!result.suppressed);
3073 }
3074
3075 #[test]
3076 fn test_suppression_engine_inline_suppression() {
3077 let rules_config = RulesConfig::default();
3078 let engine = SuppressionEngine::new(&rules_config, false);
3079
3080 let inline_suppressions = vec![InlineSuppression {
3081 rule_id: "js/console-log".to_string(),
3082 reason: Some("debug output".to_string()),
3083 line: 10,
3084 suppression_type: SuppressionType::NextLine,
3085 }];
3086
3087 let result = engine.check(
3089 "js/console-log",
3090 Path::new("src/app.js"),
3091 11, &inline_suppressions,
3093 None,
3094 );
3095 assert!(result.suppressed);
3096 assert_eq!(result.source, Some(SuppressionSource::Inline));
3097 assert_eq!(result.reason, Some("debug output".to_string()));
3098
3099 let result = engine.check(
3101 "js/console-log",
3102 Path::new("src/app.js"),
3103 12,
3104 &inline_suppressions,
3105 None,
3106 );
3107 assert!(!result.suppressed);
3108 }
3109
3110 #[test]
3111 fn test_suppression_engine_default_presets() {
3112 let rules_config = RulesConfig::default();
3113 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
3117 "generic/long-function",
3118 Path::new("src/tests/test_app.rs"),
3119 10,
3120 &[],
3121 None,
3122 );
3123 assert!(result.suppressed);
3124 assert_eq!(result.source, Some(SuppressionSource::Preset));
3125
3126 let result = engine.check(
3128 "js/console-log",
3129 Path::new("src/app.test.ts"),
3130 10,
3131 &[],
3132 None,
3133 );
3134 assert!(result.suppressed);
3135
3136 let result = engine.check(
3138 "generic/long-function",
3139 Path::new("examples/demo.rs"),
3140 10,
3141 &[],
3142 None,
3143 );
3144 assert!(result.suppressed);
3145
3146 let result = engine.check(
3148 "generic/long-function",
3149 Path::new("src/lib.rs"),
3150 10,
3151 &[],
3152 None,
3153 );
3154 assert!(!result.suppressed);
3155 }
3156
3157 #[test]
3158 fn test_suppression_engine_security_rules_not_suppressed_by_preset() {
3159 let rules_config = RulesConfig::default();
3160 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
3164 "rust/command-injection",
3165 Path::new("src/tests/test_app.rs"),
3166 10,
3167 &[],
3168 None,
3169 );
3170 assert!(!result.suppressed);
3171
3172 let result = engine.check(
3173 "generic/hardcoded-secret",
3174 Path::new("examples/demo.py"),
3175 10,
3176 &[],
3177 None,
3178 );
3179 assert!(!result.suppressed);
3180
3181 let result = engine.check(
3182 "python/shell-injection",
3183 Path::new("tests/test_shell.py"),
3184 10,
3185 &[],
3186 None,
3187 );
3188 assert!(!result.suppressed);
3189 }
3190
3191 #[test]
3192 fn test_suppression_engine_security_rules_can_be_suppressed_inline() {
3193 let rules_config = RulesConfig::default();
3194 let engine = SuppressionEngine::new(&rules_config, true);
3195
3196 let inline_suppressions = vec![InlineSuppression {
3197 rule_id: "rust/command-injection".to_string(),
3198 reason: Some("sanitized input validated upstream".to_string()),
3199 line: 10,
3200 suppression_type: SuppressionType::NextLine,
3201 }];
3202
3203 let result = engine.check(
3205 "rust/command-injection",
3206 Path::new("src/app.rs"),
3207 11,
3208 &inline_suppressions,
3209 None,
3210 );
3211 assert!(result.suppressed);
3212 assert_eq!(result.source, Some(SuppressionSource::Inline));
3213 }
3214
3215 #[test]
3216 fn test_suppression_engine_is_always_enabled() {
3217 assert!(SuppressionEngine::is_always_enabled(
3218 "rust/command-injection"
3219 ));
3220 assert!(SuppressionEngine::is_always_enabled(
3221 "python/shell-injection"
3222 ));
3223 assert!(SuppressionEngine::is_always_enabled(
3224 "generic/hardcoded-secret"
3225 ));
3226 assert!(SuppressionEngine::is_always_enabled("go/command-injection"));
3227 assert!(SuppressionEngine::is_always_enabled(
3228 "java/command-execution"
3229 ));
3230 assert!(SuppressionEngine::is_always_enabled(
3231 "js/dynamic-code-execution"
3232 ));
3233
3234 assert!(!SuppressionEngine::is_always_enabled(
3236 "generic/long-function"
3237 ));
3238 assert!(!SuppressionEngine::is_always_enabled("js/console-log"));
3239 assert!(!SuppressionEngine::is_always_enabled("rust/unsafe-block"));
3240 }
3241
3242 #[test]
3243 fn test_suppression_engine_add_metadata() {
3244 let result = SuppressionResult::suppressed(
3245 SuppressionSource::Inline,
3246 "debug output".to_string(),
3247 "line 10".to_string(),
3248 );
3249
3250 let mut properties = HashMap::new();
3251 SuppressionEngine::add_suppression_metadata(&mut properties, &result);
3252
3253 assert_eq!(properties.get("suppressed"), Some(&serde_json::json!(true)));
3254 assert_eq!(
3255 properties.get("suppression_reason"),
3256 Some(&serde_json::json!("debug output"))
3257 );
3258 assert_eq!(
3259 properties.get("suppression_source"),
3260 Some(&serde_json::json!("inline"))
3261 );
3262 assert_eq!(
3263 properties.get("suppression_location"),
3264 Some(&serde_json::json!("line 10"))
3265 );
3266 }
3267
3268 #[test]
3269 fn test_suppression_result_not_suppressed() {
3270 let result = SuppressionResult::not_suppressed();
3271 assert!(!result.suppressed);
3272 assert!(result.reason.is_none());
3273 assert!(result.source.is_none());
3274 assert!(result.location.is_none());
3275 }
3276
3277 #[test]
3278 fn test_rules_config_with_ignore_paths() {
3279 let toml = r#"
3280config_version = 1
3281
3282[rules]
3283enable = ["*"]
3284disable = []
3285ignore_paths = ["**/vendor/**", "**/generated/**"]
3286
3287[rules.ignore_paths_by_rule]
3288"generic/long-function" = ["**/tests/**", "**/examples/**"]
3289"js/console-log" = ["**/debug/**"]
3290"#;
3291 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
3292
3293 assert_eq!(config.rules.ignore_paths.len(), 2);
3294 assert!(
3295 config
3296 .rules
3297 .ignore_paths
3298 .contains(&"**/vendor/**".to_string())
3299 );
3300 assert!(
3301 config
3302 .rules
3303 .ignore_paths
3304 .contains(&"**/generated/**".to_string())
3305 );
3306
3307 assert_eq!(config.rules.ignore_paths_by_rule.len(), 2);
3308 assert!(
3309 config
3310 .rules
3311 .ignore_paths_by_rule
3312 .contains_key("generic/long-function")
3313 );
3314 assert!(
3315 config
3316 .rules
3317 .ignore_paths_by_rule
3318 .contains_key("js/console-log")
3319 );
3320 }
3321}