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/**",
113 "**/tests/**",
114 "**/testing/**",
115 "**/__tests__/**",
116 "**/__test__/**",
117 "**/*.test.ts",
118 "**/*.test.js",
119 "**/*.test.tsx",
120 "**/*.test.jsx",
121 "**/*.spec.ts",
122 "**/*.spec.js",
123 "**/*.spec.tsx",
124 "**/*.spec.jsx",
125 "**/test_*.py",
126 "**/*_test.py",
127 "**/tests_*.py",
128 "**/*_test.go",
129 "**/*_test.rs",
130];
131
132pub const DEFAULT_EXAMPLE_IGNORE_PATHS: &[&str] = &[
134 "**/examples/**",
135 "**/example/**",
136 "**/fixtures/**",
137 "**/fixture/**",
138 "**/testdata/**",
139 "**/test_data/**",
140 "**/demo/**",
141 "**/demos/**",
142 "**/mocks/**",
143 "**/mock/**",
144 "**/__mocks__/**",
145 "**/stubs/**",
146];
147
148pub const RULES_ALWAYS_ENABLED: &[&str] = &[
151 "rust/command-injection",
152 "python/shell-injection",
153 "go/command-injection",
154 "java/command-execution",
155 "js/dynamic-code-execution",
156 "generic/hardcoded-secret",
157];
158
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct RulesetsConfig {
162 #[serde(default)]
164 pub security: Vec<String>,
165
166 #[serde(default)]
168 pub maintainability: Vec<String>,
169
170 #[serde(flatten)]
172 pub custom: HashMap<String, Vec<String>>,
173}
174
175fn default_enable() -> Vec<String> {
176 vec!["*".to_string()]
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ProfileThresholds {
182 #[serde(default = "default_max_function_lines")]
184 pub max_function_lines: usize,
185
186 #[serde(default = "default_max_complexity")]
188 pub max_complexity: usize,
189
190 #[serde(default = "default_max_cognitive_complexity")]
192 pub max_cognitive_complexity: usize,
193
194 #[serde(default = "default_max_file_lines")]
196 pub max_file_lines: usize,
197}
198
199fn default_max_function_lines() -> usize {
200 100
201}
202
203fn default_max_complexity() -> usize {
204 15
205}
206
207fn default_max_cognitive_complexity() -> usize {
208 20
209}
210
211fn default_max_file_lines() -> usize {
212 1000
213}
214
215impl Default for ProfileThresholds {
216 fn default() -> Self {
217 Self {
218 max_function_lines: default_max_function_lines(),
219 max_complexity: default_max_complexity(),
220 max_cognitive_complexity: default_max_cognitive_complexity(),
221 max_file_lines: default_max_file_lines(),
222 }
223 }
224}
225
226impl ProfileThresholds {
227 pub fn for_profile(profile: Profile) -> Self {
229 match profile {
230 Profile::Fast => Self {
231 max_function_lines: 200,
232 max_complexity: 25,
233 max_cognitive_complexity: 35,
234 max_file_lines: 2000,
235 },
236 Profile::Balanced => Self::default(),
237 Profile::Strict => Self {
238 max_function_lines: 50,
239 max_complexity: 10,
240 max_cognitive_complexity: 15,
241 max_file_lines: 500,
242 },
243 }
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct ProfilesConfig {
250 #[serde(default)]
252 pub default: Profile,
253
254 #[serde(default = "fast_profile_defaults")]
256 pub fast: ProfileThresholds,
257
258 #[serde(default)]
260 pub balanced: ProfileThresholds,
261
262 #[serde(default = "strict_profile_defaults")]
264 pub strict: ProfileThresholds,
265}
266
267fn fast_profile_defaults() -> ProfileThresholds {
268 ProfileThresholds::for_profile(Profile::Fast)
269}
270
271fn strict_profile_defaults() -> ProfileThresholds {
272 ProfileThresholds::for_profile(Profile::Strict)
273}
274
275impl ProfilesConfig {
276 pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
278 match profile {
279 Profile::Fast => &self.fast,
280 Profile::Balanced => &self.balanced,
281 Profile::Strict => &self.strict,
282 }
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ThresholdOverride {
289 pub path: String,
291
292 #[serde(default)]
294 pub max_function_lines: Option<usize>,
295
296 #[serde(default)]
298 pub max_complexity: Option<usize>,
299
300 #[serde(default)]
302 pub max_cognitive_complexity: Option<usize>,
303
304 #[serde(default)]
306 pub disable_rules: Vec<String>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, Default)]
311pub struct AllowConfig {
312 #[serde(default)]
314 pub settimeout_string: bool,
315
316 #[serde(default = "default_true")]
318 pub settimeout_function: bool,
319
320 #[serde(default)]
322 pub innerhtml_paths: Vec<String>,
323
324 #[serde(default)]
326 pub eval_paths: Vec<String>,
327
328 #[serde(default)]
330 pub unsafe_rust_paths: Vec<String>,
331
332 #[serde(default)]
334 pub approved_secrets: Vec<String>,
335}
336
337fn default_true() -> bool {
338 true
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
347#[serde(rename_all = "lowercase")]
348pub enum ProviderType {
349 Rma,
351 Pmd,
353 Oxlint,
355 Oxc,
357 RustSec,
359 Gosec,
361 Osv,
363}
364
365impl std::fmt::Display for ProviderType {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 match self {
368 ProviderType::Rma => write!(f, "rma"),
369 ProviderType::Pmd => write!(f, "pmd"),
370 ProviderType::Oxlint => write!(f, "oxlint"),
371 ProviderType::Oxc => write!(f, "oxc"),
372 ProviderType::RustSec => write!(f, "rustsec"),
373 ProviderType::Gosec => write!(f, "gosec"),
374 ProviderType::Osv => write!(f, "osv"),
375 }
376 }
377}
378
379impl std::str::FromStr for ProviderType {
380 type Err = String;
381
382 fn from_str(s: &str) -> Result<Self, Self::Err> {
383 match s.to_lowercase().as_str() {
384 "rma" => Ok(ProviderType::Rma),
385 "pmd" => Ok(ProviderType::Pmd),
386 "oxlint" => Ok(ProviderType::Oxlint),
387 "oxc" => Ok(ProviderType::Oxc),
388 "rustsec" => Ok(ProviderType::RustSec),
389 "gosec" => Ok(ProviderType::Gosec),
390 "osv" => Ok(ProviderType::Osv),
391 _ => Err(format!(
392 "Unknown provider: {}. Available: rma, pmd, oxlint, oxc, rustsec, gosec, osv",
393 s
394 )),
395 }
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ProvidersConfig {
402 #[serde(default = "default_enabled_providers")]
404 pub enabled: Vec<ProviderType>,
405
406 #[serde(default)]
408 pub pmd: PmdProviderConfig,
409
410 #[serde(default)]
412 pub oxlint: OxlintProviderConfig,
413
414 #[serde(default)]
416 pub oxc: OxcProviderConfig,
417
418 #[serde(default)]
420 pub gosec: GosecProviderConfig,
421
422 #[serde(default)]
424 pub osv: OsvProviderConfig,
425}
426
427impl Default for ProvidersConfig {
428 fn default() -> Self {
429 Self {
430 enabled: default_enabled_providers(),
431 pmd: PmdProviderConfig::default(),
432 oxlint: OxlintProviderConfig::default(),
433 oxc: OxcProviderConfig::default(),
434 gosec: GosecProviderConfig::default(),
435 osv: OsvProviderConfig::default(),
436 }
437 }
438}
439
440fn default_enabled_providers() -> Vec<ProviderType> {
441 vec![ProviderType::Rma]
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct PmdProviderConfig {
447 #[serde(default)]
449 pub configured: bool,
450
451 #[serde(default = "default_java_path")]
453 pub java_path: String,
454
455 #[serde(default)]
458 pub pmd_path: String,
459
460 #[serde(default = "default_pmd_rulesets")]
462 pub rulesets: Vec<String>,
463
464 #[serde(default = "default_pmd_timeout")]
466 pub timeout_ms: u64,
467
468 #[serde(default = "default_pmd_include_patterns")]
470 pub include_patterns: Vec<String>,
471
472 #[serde(default = "default_pmd_exclude_patterns")]
474 pub exclude_patterns: Vec<String>,
475
476 #[serde(default = "default_pmd_severity_map")]
480 pub severity_map: HashMap<String, Severity>,
481
482 #[serde(default)]
484 pub fail_on_error: bool,
485
486 #[serde(default = "default_pmd_min_priority")]
488 pub min_priority: u8,
489
490 #[serde(default)]
492 pub extra_args: Vec<String>,
493}
494
495impl Default for PmdProviderConfig {
496 fn default() -> Self {
497 Self {
498 configured: false,
499 java_path: default_java_path(),
500 pmd_path: String::new(),
501 rulesets: default_pmd_rulesets(),
502 timeout_ms: default_pmd_timeout(),
503 include_patterns: default_pmd_include_patterns(),
504 exclude_patterns: default_pmd_exclude_patterns(),
505 severity_map: default_pmd_severity_map(),
506 fail_on_error: false,
507 min_priority: default_pmd_min_priority(),
508 extra_args: Vec::new(),
509 }
510 }
511}
512
513fn default_java_path() -> String {
514 "java".to_string()
515}
516
517fn default_pmd_rulesets() -> Vec<String> {
518 vec![
519 "category/java/security.xml".to_string(),
520 "category/java/bestpractices.xml".to_string(),
521 "category/java/errorprone.xml".to_string(),
522 ]
523}
524
525fn default_pmd_timeout() -> u64 {
526 600_000 }
528
529fn default_pmd_include_patterns() -> Vec<String> {
530 vec!["**/*.java".to_string()]
531}
532
533fn default_pmd_exclude_patterns() -> Vec<String> {
534 vec![
535 "**/target/**".to_string(),
536 "**/build/**".to_string(),
537 "**/generated/**".to_string(),
538 "**/out/**".to_string(),
539 "**/.git/**".to_string(),
540 "**/node_modules/**".to_string(),
541 ]
542}
543
544fn default_pmd_severity_map() -> HashMap<String, Severity> {
545 let mut map = HashMap::new();
546 map.insert("1".to_string(), Severity::Critical);
547 map.insert("2".to_string(), Severity::Error);
548 map.insert("3".to_string(), Severity::Warning);
549 map.insert("4".to_string(), Severity::Info);
550 map.insert("5".to_string(), Severity::Info);
551 map
552}
553
554fn default_pmd_min_priority() -> u8 {
555 5 }
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct OxlintProviderConfig {
561 #[serde(default)]
563 pub configured: bool,
564
565 #[serde(default)]
567 pub binary_path: String,
568
569 #[serde(default = "default_oxlint_timeout")]
571 pub timeout_ms: u64,
572
573 #[serde(default)]
575 pub extra_args: Vec<String>,
576}
577
578impl Default for OxlintProviderConfig {
579 fn default() -> Self {
580 Self {
581 configured: false,
582 binary_path: String::new(),
583 timeout_ms: default_oxlint_timeout(),
584 extra_args: Vec::new(),
585 }
586 }
587}
588
589fn default_oxlint_timeout() -> u64 {
590 300_000 }
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct GosecProviderConfig {
596 #[serde(default)]
598 pub configured: bool,
599
600 #[serde(default)]
602 pub binary_path: String,
603
604 #[serde(default = "default_gosec_timeout")]
606 pub timeout_ms: u64,
607
608 #[serde(default)]
610 pub exclude_rules: Vec<String>,
611
612 #[serde(default)]
614 pub include_rules: Vec<String>,
615
616 #[serde(default)]
618 pub extra_args: Vec<String>,
619}
620
621impl Default for GosecProviderConfig {
622 fn default() -> Self {
623 Self {
624 configured: false,
625 binary_path: String::new(),
626 timeout_ms: default_gosec_timeout(),
627 exclude_rules: Vec::new(),
628 include_rules: Vec::new(),
629 extra_args: Vec::new(),
630 }
631 }
632}
633
634fn default_gosec_timeout() -> u64 {
635 300_000 }
637
638#[derive(Debug, Clone, Default, Serialize, Deserialize)]
640pub struct OxcProviderConfig {
641 #[serde(default)]
643 pub configured: bool,
644
645 #[serde(default)]
647 pub enable_rules: Vec<String>,
648
649 #[serde(default)]
651 pub disable_rules: Vec<String>,
652
653 #[serde(default)]
655 pub severity_overrides: HashMap<String, Severity>,
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
660#[serde(rename_all = "lowercase")]
661pub enum OsvEcosystem {
662 #[serde(rename = "crates.io")]
664 CratesIo,
665 Npm,
667 PyPI,
669 Go,
671 Maven,
673}
674
675impl std::fmt::Display for OsvEcosystem {
676 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 match self {
678 OsvEcosystem::CratesIo => write!(f, "crates.io"),
679 OsvEcosystem::Npm => write!(f, "npm"),
680 OsvEcosystem::PyPI => write!(f, "PyPI"),
681 OsvEcosystem::Go => write!(f, "Go"),
682 OsvEcosystem::Maven => write!(f, "Maven"),
683 }
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct OsvProviderConfig {
690 #[serde(default)]
692 pub configured: bool,
693
694 #[serde(default)]
696 pub include_dev_deps: bool,
697
698 #[serde(default = "default_osv_cache_ttl")]
701 pub cache_ttl: String,
702
703 #[serde(default = "default_osv_ecosystems")]
705 pub enabled_ecosystems: Vec<OsvEcosystem>,
706
707 #[serde(default)]
710 pub severity_overrides: HashMap<String, Severity>,
711
712 #[serde(default)]
715 pub ignore_list: Vec<String>,
716
717 #[serde(default)]
719 pub offline: bool,
720
721 #[serde(default)]
723 pub cache_dir: Option<PathBuf>,
724}
725
726impl Default for OsvProviderConfig {
727 fn default() -> Self {
728 Self {
729 configured: false,
730 include_dev_deps: false,
731 cache_ttl: default_osv_cache_ttl(),
732 enabled_ecosystems: default_osv_ecosystems(),
733 severity_overrides: HashMap::new(),
734 ignore_list: Vec::new(),
735 offline: false,
736 cache_dir: None,
737 }
738 }
739}
740
741fn default_osv_cache_ttl() -> String {
742 "24h".to_string()
743}
744
745fn default_osv_ecosystems() -> Vec<OsvEcosystem> {
746 vec![
747 OsvEcosystem::CratesIo,
748 OsvEcosystem::Npm,
749 OsvEcosystem::PyPI,
750 OsvEcosystem::Go,
751 OsvEcosystem::Maven,
752 ]
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, Default)]
757pub struct BaselineConfig {
758 #[serde(default = "default_baseline_file")]
760 pub file: PathBuf,
761
762 #[serde(default)]
764 pub mode: BaselineMode,
765}
766
767fn default_baseline_file() -> PathBuf {
768 PathBuf::from(".rma/baseline.json")
769}
770
771pub const CURRENT_CONFIG_VERSION: u32 = 1;
773
774#[derive(Debug, Clone, Serialize, Deserialize, Default)]
776pub struct RmaTomlConfig {
777 #[serde(default)]
779 pub config_version: Option<u32>,
780
781 #[serde(default)]
783 pub scan: ScanConfig,
784
785 #[serde(default)]
787 pub rules: RulesConfig,
788
789 #[serde(default)]
791 pub rulesets: RulesetsConfig,
792
793 #[serde(default)]
795 pub profiles: ProfilesConfig,
796
797 #[serde(default)]
799 pub severity: HashMap<String, Severity>,
800
801 #[serde(default)]
803 pub threshold_overrides: Vec<ThresholdOverride>,
804
805 #[serde(default)]
807 pub allow: AllowConfig,
808
809 #[serde(default)]
811 pub baseline: BaselineConfig,
812
813 #[serde(default)]
815 pub providers: ProvidersConfig,
816}
817
818#[derive(Debug)]
820pub struct ConfigLoadResult {
821 pub config: RmaTomlConfig,
823 pub version_warning: Option<String>,
825}
826
827impl RmaTomlConfig {
828 pub fn load(path: &Path) -> Result<Self, String> {
830 let result = Self::load_with_validation(path)?;
831 Ok(result.config)
832 }
833
834 pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
836 let content = std::fs::read_to_string(path)
837 .map_err(|e| format!("Failed to read config file: {}", e))?;
838
839 let config: RmaTomlConfig =
840 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
841
842 let version_warning = config.validate_version()?;
844
845 Ok(ConfigLoadResult {
846 config,
847 version_warning,
848 })
849 }
850
851 fn validate_version(&self) -> Result<Option<String>, String> {
853 match self.config_version {
854 Some(CURRENT_CONFIG_VERSION) => Ok(None),
855 Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
856 "Unsupported config version: {}. Maximum supported version is {}. \
857 Please upgrade RMA or use a compatible config format.",
858 version, CURRENT_CONFIG_VERSION
859 )),
860 Some(version) => {
861 Err(format!(
863 "Invalid config version: {}. Expected version {}.",
864 version, CURRENT_CONFIG_VERSION
865 ))
866 }
867 None => Ok(Some(
868 "Config file is missing 'config_version'. Assuming version 1. \
869 Add 'config_version = 1' to suppress this warning."
870 .to_string(),
871 )),
872 }
873 }
874
875 pub fn has_version(&self) -> bool {
877 self.config_version.is_some()
878 }
879
880 pub fn effective_version(&self) -> u32 {
882 self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
883 }
884
885 pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
887 let candidates = [
888 start_path.join("rma.toml"),
889 start_path.join(".rma/rma.toml"),
890 start_path.join(".rma.toml"),
891 ];
892
893 for candidate in &candidates {
894 if candidate.exists()
895 && let Ok(config) = Self::load(candidate)
896 {
897 return Some((candidate.clone(), config));
898 }
899 }
900
901 let mut current = start_path.to_path_buf();
903 for _ in 0..5 {
904 if let Some(parent) = current.parent() {
905 let config_path = parent.join("rma.toml");
906 if config_path.exists()
907 && let Ok(config) = Self::load(&config_path)
908 {
909 return Some((config_path, config));
910 }
911 current = parent.to_path_buf();
912 } else {
913 break;
914 }
915 }
916
917 None
918 }
919
920 pub fn validate(&self) -> Vec<ConfigWarning> {
922 let mut warnings = Vec::new();
923
924 if self.config_version.is_none() {
926 warnings.push(ConfigWarning {
927 level: WarningLevel::Warning,
928 message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
929 .to_string(),
930 });
931 } else if let Some(version) = self.config_version
932 && version > CURRENT_CONFIG_VERSION
933 {
934 warnings.push(ConfigWarning {
935 level: WarningLevel::Error,
936 message: format!(
937 "Unsupported config version: {}. Maximum supported is {}.",
938 version, CURRENT_CONFIG_VERSION
939 ),
940 });
941 }
942
943 for disabled in &self.rules.disable {
945 for enabled in &self.rules.enable {
946 if enabled == disabled {
947 warnings.push(ConfigWarning {
948 level: WarningLevel::Warning,
949 message: format!(
950 "Rule '{}' is both enabled and disabled (disable takes precedence)",
951 disabled
952 ),
953 });
954 }
955 }
956 }
957
958 for (i, override_) in self.threshold_overrides.iter().enumerate() {
960 if override_.path.is_empty() {
961 warnings.push(ConfigWarning {
962 level: WarningLevel::Error,
963 message: format!("threshold_overrides[{}]: path cannot be empty", i),
964 });
965 }
966 }
967
968 if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
970 warnings.push(ConfigWarning {
971 level: WarningLevel::Warning,
972 message: format!(
973 "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
974 self.baseline.file.display()
975 ),
976 });
977 }
978
979 for rule_id in self.severity.keys() {
981 if rule_id.is_empty() {
982 warnings.push(ConfigWarning {
983 level: WarningLevel::Error,
984 message: "Empty rule ID in severity overrides".to_string(),
985 });
986 }
987 }
988
989 if self.providers.enabled.contains(&ProviderType::Pmd) {
991 if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
993 warnings.push(ConfigWarning {
994 level: WarningLevel::Warning,
995 message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
996 });
997 }
998
999 if self.providers.pmd.rulesets.is_empty() {
1001 warnings.push(ConfigWarning {
1002 level: WarningLevel::Warning,
1003 message:
1004 "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
1005 .to_string(),
1006 });
1007 }
1008
1009 for priority in self.providers.pmd.severity_map.keys() {
1011 if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
1012 warnings.push(ConfigWarning {
1013 level: WarningLevel::Warning,
1014 message: format!(
1015 "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
1016 priority
1017 ),
1018 });
1019 }
1020 }
1021 }
1022
1023 if self.providers.enabled.contains(&ProviderType::Oxlint)
1024 && !self.providers.oxlint.configured
1025 {
1026 warnings.push(ConfigWarning {
1027 level: WarningLevel::Warning,
1028 message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
1029 });
1030 }
1031
1032 warnings
1033 }
1034
1035 pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
1037 self.providers.enabled.contains(&provider)
1038 }
1039
1040 pub fn get_enabled_providers(&self) -> &[ProviderType] {
1042 &self.providers.enabled
1043 }
1044
1045 pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
1047 if self.is_provider_enabled(ProviderType::Pmd) {
1048 Some(&self.providers.pmd)
1049 } else {
1050 None
1051 }
1052 }
1053
1054 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
1056 self.is_rule_enabled_with_ruleset(rule_id, None)
1057 }
1058
1059 pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
1064 for pattern in &self.rules.disable {
1066 if Self::matches_pattern(rule_id, pattern) {
1067 return false;
1068 }
1069 }
1070
1071 if let Some(ruleset_name) = ruleset {
1073 let ruleset_rules = self.get_ruleset_rules(ruleset_name);
1074 if !ruleset_rules.is_empty() {
1075 return ruleset_rules
1077 .iter()
1078 .any(|r| Self::matches_pattern(rule_id, r));
1079 }
1080 }
1081
1082 for pattern in &self.rules.enable {
1084 if Self::matches_pattern(rule_id, pattern) {
1085 return true;
1086 }
1087 }
1088
1089 false
1090 }
1091
1092 pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
1094 match name {
1095 "security" => self.rulesets.security.clone(),
1096 "maintainability" => self.rulesets.maintainability.clone(),
1097 _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
1098 }
1099 }
1100
1101 pub fn get_ruleset_names(&self) -> Vec<String> {
1103 let mut names = Vec::new();
1104 if !self.rulesets.security.is_empty() {
1105 names.push("security".to_string());
1106 }
1107 if !self.rulesets.maintainability.is_empty() {
1108 names.push("maintainability".to_string());
1109 }
1110 names.extend(self.rulesets.custom.keys().cloned());
1111 names
1112 }
1113
1114 pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
1116 self.severity.get(rule_id).copied()
1117 }
1118
1119 pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
1121 let mut thresholds = self.profiles.get_thresholds(profile).clone();
1122
1123 let path_str = path.to_string_lossy();
1125 for override_ in &self.threshold_overrides {
1126 if Self::matches_glob(&path_str, &override_.path) {
1127 if let Some(v) = override_.max_function_lines {
1128 thresholds.max_function_lines = v;
1129 }
1130 if let Some(v) = override_.max_complexity {
1131 thresholds.max_complexity = v;
1132 }
1133 if let Some(v) = override_.max_cognitive_complexity {
1134 thresholds.max_cognitive_complexity = v;
1135 }
1136 }
1137 }
1138
1139 thresholds
1140 }
1141
1142 pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
1144 let path_str = path.to_string_lossy();
1145 let allowed_paths = match rule_type {
1146 AllowType::InnerHtml => &self.allow.innerhtml_paths,
1147 AllowType::Eval => &self.allow.eval_paths,
1148 AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
1149 };
1150
1151 for pattern in allowed_paths {
1152 if Self::matches_glob(&path_str, pattern) {
1153 return true;
1154 }
1155 }
1156
1157 false
1158 }
1159
1160 fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
1161 if pattern == "*" {
1162 return true;
1163 }
1164
1165 if let Some(prefix) = pattern.strip_suffix("/*") {
1166 return rule_id.starts_with(prefix);
1167 }
1168
1169 rule_id == pattern
1170 }
1171
1172 fn matches_glob(path: &str, pattern: &str) -> bool {
1173 let pattern = pattern
1175 .replace("**", "§")
1176 .replace('*', "[^/]*")
1177 .replace('§', ".*");
1178 regex::Regex::new(&format!("^{}$", pattern))
1179 .map(|re| re.is_match(path))
1180 .unwrap_or(false)
1181 }
1182
1183 pub fn default_toml(profile: Profile) -> String {
1185 let thresholds = ProfileThresholds::for_profile(profile);
1186
1187 format!(
1188 r#"# RMA Configuration
1189# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
1190
1191# Config format version (required for future compatibility)
1192config_version = 1
1193
1194[scan]
1195# Paths to include in scanning (default: all supported files)
1196include = ["src/**", "lib/**", "scripts/**"]
1197
1198# Paths to exclude from scanning
1199exclude = [
1200 "node_modules/**",
1201 "target/**",
1202 "dist/**",
1203 "build/**",
1204 "vendor/**",
1205 "**/*.min.js",
1206 "**/*.bundle.js",
1207]
1208
1209# Maximum file size to scan (10MB default)
1210max_file_size = 10485760
1211
1212[rules]
1213# Rules to enable (wildcards supported)
1214enable = ["*"]
1215
1216# Rules to disable (takes precedence over enable)
1217disable = []
1218
1219# Global ignore paths - findings in these paths are suppressed for all rules
1220# Supports glob patterns. Uncomment to customize.
1221# ignore_paths = ["**/vendor/**", "**/generated/**"]
1222
1223# Per-rule ignore paths - suppress specific rules in specific paths
1224# Note: Security rules (command-injection, hardcoded-secret, etc.) cannot be
1225# suppressed via path ignores, only via inline comments with reason.
1226# [rules.ignore_paths_by_rule]
1227# "generic/long-function" = ["**/tests/**", "**/examples/**"]
1228# "js/console-log" = ["**/debug/**"]
1229
1230# Default test/example suppressions are automatically applied in --mode pr/ci
1231# This reduces noise from test files. Security rules are NOT suppressed.
1232
1233[profiles]
1234# Default profile: fast, balanced, or strict
1235default = "{profile}"
1236
1237[profiles.fast]
1238max_function_lines = 200
1239max_complexity = 25
1240max_cognitive_complexity = 35
1241max_file_lines = 2000
1242
1243[profiles.balanced]
1244max_function_lines = {max_function_lines}
1245max_complexity = {max_complexity}
1246max_cognitive_complexity = {max_cognitive_complexity}
1247max_file_lines = 1000
1248
1249[profiles.strict]
1250max_function_lines = 50
1251max_complexity = 10
1252max_cognitive_complexity = 15
1253max_file_lines = 500
1254
1255[rulesets]
1256# Named rule groups for targeted scanning
1257security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
1258maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
1259
1260[severity]
1261# Override severity for specific rules
1262# "generic/long-function" = "warning"
1263# "js/innerhtml-xss" = "error"
1264# "rust/unsafe-block" = "warning"
1265
1266# [[threshold_overrides]]
1267# path = "src/legacy/**"
1268# max_function_lines = 300
1269# max_complexity = 30
1270
1271# [[threshold_overrides]]
1272# path = "tests/**"
1273# disable_rules = ["generic/long-function"]
1274
1275[allow]
1276# Approved patterns that won't trigger alerts
1277settimeout_string = false
1278settimeout_function = true
1279innerhtml_paths = []
1280eval_paths = []
1281unsafe_rust_paths = []
1282approved_secrets = []
1283
1284[baseline]
1285# Baseline file for tracking legacy issues
1286file = ".rma/baseline.json"
1287# Mode: "all" or "new-only"
1288mode = "all"
1289
1290# =============================================================================
1291# ANALYSIS PROVIDERS
1292# =============================================================================
1293# RMA supports external analysis providers for extended language coverage.
1294# Providers can be enabled/disabled individually.
1295
1296[providers]
1297# List of enabled providers (default: only "rma" built-in rules)
1298enabled = ["rma"]
1299# To enable PMD for Java: enabled = ["rma", "pmd"]
1300# To enable Oxlint for JS/TS: enabled = ["rma", "oxlint"]
1301
1302# -----------------------------------------------------------------------------
1303# PMD Provider - Java Static Analysis (optional)
1304# -----------------------------------------------------------------------------
1305# PMD provides comprehensive Java security and quality analysis.
1306# Requires: Java runtime and PMD installation
1307#
1308# [providers.pmd]
1309# configured = true
1310# java_path = "java" # Path to java binary
1311# pmd_path = "" # Path to pmd binary (or leave empty to use PATH)
1312# rulesets = [
1313# "category/java/security.xml",
1314# "category/java/bestpractices.xml",
1315# "category/java/errorprone.xml",
1316# ]
1317# timeout_ms = 600000 # 10 minutes timeout
1318# include_patterns = ["**/*.java"]
1319# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1320# fail_on_error = false # Continue scan if PMD fails
1321# min_priority = 5 # Report all priorities (1-5)
1322# extra_args = [] # Additional PMD CLI arguments
1323
1324# [providers.pmd.severity_map]
1325# # Map PMD priority (1-5) to RMA severity
1326# "1" = "critical"
1327# "2" = "error"
1328# "3" = "warning"
1329# "4" = "info"
1330# "5" = "info"
1331
1332# -----------------------------------------------------------------------------
1333# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1334# -----------------------------------------------------------------------------
1335# [providers.oxlint]
1336# configured = true
1337# binary_path = "" # Path to oxlint binary (or leave empty to use PATH)
1338# timeout_ms = 300000 # 5 minutes timeout
1339# extra_args = []
1340"#,
1341 profile = profile,
1342 max_function_lines = thresholds.max_function_lines,
1343 max_complexity = thresholds.max_complexity,
1344 max_cognitive_complexity = thresholds.max_cognitive_complexity,
1345 )
1346 }
1347}
1348
1349#[derive(Debug, Clone, Copy)]
1351pub enum AllowType {
1352 InnerHtml,
1353 Eval,
1354 UnsafeRust,
1355}
1356
1357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1359#[serde(rename_all = "kebab-case")]
1360pub enum ConfigSource {
1361 Default,
1363 ConfigFile,
1365 CliFlag,
1367}
1368
1369impl std::fmt::Display for ConfigSource {
1370 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1371 match self {
1372 ConfigSource::Default => write!(f, "default"),
1373 ConfigSource::ConfigFile => write!(f, "config-file"),
1374 ConfigSource::CliFlag => write!(f, "cli-flag"),
1375 }
1376 }
1377}
1378
1379#[derive(Debug, Clone, Serialize, Deserialize)]
1386pub struct EffectiveConfig {
1387 pub config_file: Option<PathBuf>,
1389
1390 pub profile: Profile,
1392
1393 pub profile_source: ConfigSource,
1395
1396 pub thresholds: ProfileThresholds,
1398
1399 pub enabled_rules_count: usize,
1401
1402 pub disabled_rules_count: usize,
1404
1405 pub severity_overrides_count: usize,
1407
1408 pub threshold_override_paths: Vec<String>,
1410
1411 pub baseline_mode: BaselineMode,
1413
1414 pub baseline_mode_source: ConfigSource,
1416
1417 pub exclude_patterns: Vec<String>,
1419
1420 pub include_patterns: Vec<String>,
1422}
1423
1424impl EffectiveConfig {
1425 pub fn resolve(
1427 toml_config: Option<&RmaTomlConfig>,
1428 config_path: Option<&Path>,
1429 cli_profile: Option<Profile>,
1430 cli_baseline_mode: bool,
1431 ) -> Self {
1432 let (profile, profile_source) = if let Some(p) = cli_profile {
1434 (p, ConfigSource::CliFlag)
1435 } else if let Some(cfg) = toml_config {
1436 (cfg.profiles.default, ConfigSource::ConfigFile)
1437 } else {
1438 (Profile::default(), ConfigSource::Default)
1439 };
1440
1441 let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1443 (BaselineMode::NewOnly, ConfigSource::CliFlag)
1444 } else if let Some(cfg) = toml_config {
1445 (cfg.baseline.mode, ConfigSource::ConfigFile)
1446 } else {
1447 (BaselineMode::default(), ConfigSource::Default)
1448 };
1449
1450 let thresholds = toml_config
1452 .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1453 .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1454
1455 let (enabled_rules_count, disabled_rules_count) = toml_config
1457 .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1458 .unwrap_or((1, 0)); let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1462
1463 let threshold_override_paths = toml_config
1465 .map(|cfg| {
1466 cfg.threshold_overrides
1467 .iter()
1468 .map(|o| o.path.clone())
1469 .collect()
1470 })
1471 .unwrap_or_default();
1472
1473 let exclude_patterns = toml_config
1475 .map(|cfg| cfg.scan.exclude.clone())
1476 .unwrap_or_default();
1477
1478 let include_patterns = toml_config
1479 .map(|cfg| cfg.scan.include.clone())
1480 .unwrap_or_default();
1481
1482 Self {
1483 config_file: config_path.map(|p| p.to_path_buf()),
1484 profile,
1485 profile_source,
1486 thresholds,
1487 enabled_rules_count,
1488 disabled_rules_count,
1489 severity_overrides_count,
1490 threshold_override_paths,
1491 baseline_mode,
1492 baseline_mode_source,
1493 exclude_patterns,
1494 include_patterns,
1495 }
1496 }
1497
1498 pub fn to_text(&self) -> String {
1500 let mut out = String::new();
1501
1502 out.push_str("Effective Configuration\n");
1503 out.push_str("═══════════════════════════════════════════════════════════\n\n");
1504
1505 out.push_str(" Config file: ");
1507 match &self.config_file {
1508 Some(p) => out.push_str(&format!("{}\n", p.display())),
1509 None => out.push_str("(none - using defaults)\n"),
1510 }
1511
1512 out.push_str(&format!(
1514 " Profile: {} (from {})\n",
1515 self.profile, self.profile_source
1516 ));
1517
1518 out.push_str("\n Thresholds:\n");
1520 out.push_str(&format!(
1521 " max_function_lines: {}\n",
1522 self.thresholds.max_function_lines
1523 ));
1524 out.push_str(&format!(
1525 " max_complexity: {}\n",
1526 self.thresholds.max_complexity
1527 ));
1528 out.push_str(&format!(
1529 " max_cognitive_complexity: {}\n",
1530 self.thresholds.max_cognitive_complexity
1531 ));
1532 out.push_str(&format!(
1533 " max_file_lines: {}\n",
1534 self.thresholds.max_file_lines
1535 ));
1536
1537 out.push_str("\n Rules:\n");
1539 out.push_str(&format!(
1540 " enabled patterns: {}\n",
1541 self.enabled_rules_count
1542 ));
1543 out.push_str(&format!(
1544 " disabled patterns: {}\n",
1545 self.disabled_rules_count
1546 ));
1547 out.push_str(&format!(
1548 " severity overrides: {}\n",
1549 self.severity_overrides_count
1550 ));
1551
1552 if !self.threshold_override_paths.is_empty() {
1554 out.push_str("\n Threshold overrides:\n");
1555 for path in &self.threshold_override_paths {
1556 out.push_str(&format!(" - {}\n", path));
1557 }
1558 }
1559
1560 out.push_str(&format!(
1562 "\n Baseline mode: {:?} (from {})\n",
1563 self.baseline_mode, self.baseline_mode_source
1564 ));
1565
1566 out
1567 }
1568
1569 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1571 serde_json::to_string_pretty(self)
1572 }
1573}
1574
1575#[derive(Debug, Clone)]
1577pub struct ConfigWarning {
1578 pub level: WarningLevel,
1579 pub message: String,
1580}
1581
1582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1583pub enum WarningLevel {
1584 Warning,
1585 Error,
1586}
1587
1588#[derive(Debug, Clone, PartialEq, Eq)]
1590pub struct InlineSuppression {
1591 pub rule_id: String,
1593 pub reason: Option<String>,
1595 pub line: usize,
1597 pub suppression_type: SuppressionType,
1599}
1600
1601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603pub enum SuppressionType {
1604 NextLine,
1606 Block,
1608}
1609
1610impl InlineSuppression {
1611 pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1618 let trimmed = line.trim();
1619
1620 let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1622 rest.trim()
1623 } else if let Some(rest) = trimmed.strip_prefix('#') {
1624 rest.trim()
1625 } else {
1626 return None;
1627 };
1628
1629 if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1631 return Self::parse_suppression_body(
1632 rest.trim(),
1633 line_number,
1634 SuppressionType::NextLine,
1635 );
1636 }
1637
1638 if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1640 return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1641 }
1642
1643 None
1644 }
1645
1646 fn parse_suppression_body(
1647 body: &str,
1648 line_number: usize,
1649 suppression_type: SuppressionType,
1650 ) -> Option<Self> {
1651 if body.is_empty() {
1652 return None;
1653 }
1654
1655 let mut parts = body.splitn(2, ' ');
1657 let rule_id = parts.next()?.trim().to_string();
1658
1659 if rule_id.is_empty() {
1660 return None;
1661 }
1662
1663 let reason = parts.next().and_then(|rest| {
1664 if let Some(start) = rest.find("reason=\"") {
1666 let after_quote = &rest[start + 8..];
1667 if let Some(end) = after_quote.find('"') {
1668 return Some(after_quote[..end].to_string());
1669 }
1670 }
1671 None
1672 });
1673
1674 Some(Self {
1675 rule_id,
1676 reason,
1677 line: line_number,
1678 suppression_type,
1679 })
1680 }
1681
1682 pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1684 if self.rule_id != rule_id && self.rule_id != "*" {
1685 return false;
1686 }
1687
1688 match self.suppression_type {
1689 SuppressionType::NextLine => finding_line == self.line + 1,
1690 SuppressionType::Block => finding_line >= self.line,
1691 }
1692 }
1693
1694 pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1696 if require_reason && self.reason.is_none() {
1697 return Err(format!(
1698 "Suppression for '{}' at line {} requires a reason in strict profile",
1699 self.rule_id, self.line
1700 ));
1701 }
1702 Ok(())
1703 }
1704}
1705
1706pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1708 content
1709 .lines()
1710 .enumerate()
1711 .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1712 .collect()
1713}
1714
1715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1727pub struct Fingerprint(String);
1728
1729impl Fingerprint {
1730 pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1732 use sha2::{Digest, Sha256};
1733
1734 let mut hasher = Sha256::new();
1735
1736 hasher.update(rule_id.as_bytes());
1738 hasher.update(b"|");
1739
1740 let normalized_path = file_path
1742 .to_string_lossy()
1743 .replace('\\', "/")
1744 .to_lowercase();
1745 hasher.update(normalized_path.as_bytes());
1746 hasher.update(b"|");
1747
1748 let normalized_snippet = Self::normalize_snippet(snippet);
1750 hasher.update(normalized_snippet.as_bytes());
1751
1752 let hash = hasher.finalize();
1753 Self(format!("sha256:{:x}", hash))
1754 }
1755
1756 fn normalize_snippet(snippet: &str) -> String {
1758 snippet
1759 .split_whitespace()
1760 .collect::<Vec<_>>()
1761 .join(" ")
1762 .trim()
1763 .to_string()
1764 }
1765
1766 pub fn as_str(&self) -> &str {
1768 &self.0
1769 }
1770
1771 pub fn from_string(s: String) -> Self {
1773 Self(s)
1774 }
1775}
1776
1777impl std::fmt::Display for Fingerprint {
1778 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779 write!(f, "{}", self.0)
1780 }
1781}
1782
1783impl From<Fingerprint> for String {
1784 fn from(fp: Fingerprint) -> String {
1785 fp.0
1786 }
1787}
1788
1789#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct BaselineEntry {
1792 pub rule_id: String,
1793 pub file: PathBuf,
1794 #[serde(default)]
1795 pub line: usize,
1796 pub fingerprint: String,
1797 pub first_seen: String,
1798 #[serde(default)]
1799 pub suppressed: bool,
1800 #[serde(default)]
1801 pub comment: Option<String>,
1802}
1803
1804#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1806pub struct Baseline {
1807 pub version: String,
1808 pub created: String,
1809 pub entries: Vec<BaselineEntry>,
1810}
1811
1812impl Baseline {
1813 pub fn new() -> Self {
1814 Self {
1815 version: "1.0".to_string(),
1816 created: chrono::Utc::now().to_rfc3339(),
1817 entries: Vec::new(),
1818 }
1819 }
1820
1821 pub fn load(path: &Path) -> Result<Self, String> {
1822 let content = std::fs::read_to_string(path)
1823 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1824
1825 serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1826 }
1827
1828 pub fn save(&self, path: &Path) -> Result<(), String> {
1829 if let Some(parent) = path.parent() {
1830 std::fs::create_dir_all(parent)
1831 .map_err(|e| format!("Failed to create directory: {}", e))?;
1832 }
1833
1834 let content = serde_json::to_string_pretty(self)
1835 .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1836
1837 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1838 }
1839
1840 pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1842 self.entries
1843 .iter()
1844 .any(|e| e.fingerprint == fingerprint.as_str())
1845 }
1846
1847 pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1849 self.entries
1850 .iter()
1851 .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1852 }
1853
1854 pub fn add_with_fingerprint(
1856 &mut self,
1857 rule_id: String,
1858 file: PathBuf,
1859 line: usize,
1860 fingerprint: Fingerprint,
1861 ) {
1862 if !self.contains_fingerprint(&fingerprint) {
1863 self.entries.push(BaselineEntry {
1864 rule_id,
1865 file,
1866 line,
1867 fingerprint: fingerprint.into(),
1868 first_seen: chrono::Utc::now().to_rfc3339(),
1869 suppressed: false,
1870 comment: None,
1871 });
1872 }
1873 }
1874
1875 pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1877 if !self.contains(&rule_id, &file, &fingerprint) {
1878 self.entries.push(BaselineEntry {
1879 rule_id,
1880 file,
1881 line,
1882 fingerprint,
1883 first_seen: chrono::Utc::now().to_rfc3339(),
1884 suppressed: false,
1885 comment: None,
1886 });
1887 }
1888 }
1889}
1890
1891#[derive(Debug, Clone, Serialize, Deserialize)]
1897pub struct SuppressionResult {
1898 pub suppressed: bool,
1900 pub reason: Option<String>,
1902 pub source: Option<SuppressionSource>,
1904 pub location: Option<String>,
1906}
1907
1908impl SuppressionResult {
1909 pub fn not_suppressed() -> Self {
1911 Self {
1912 suppressed: false,
1913 reason: None,
1914 source: None,
1915 location: None,
1916 }
1917 }
1918
1919 pub fn suppressed(source: SuppressionSource, reason: String, location: String) -> Self {
1921 Self {
1922 suppressed: true,
1923 reason: Some(reason),
1924 source: Some(source),
1925 location: Some(location),
1926 }
1927 }
1928}
1929
1930#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1932#[serde(rename_all = "kebab-case")]
1933pub enum SuppressionSource {
1934 Inline,
1936 PathGlobal,
1938 PathRule,
1940 Preset,
1942 Baseline,
1944}
1945
1946impl std::fmt::Display for SuppressionSource {
1947 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1948 match self {
1949 SuppressionSource::Inline => write!(f, "inline"),
1950 SuppressionSource::PathGlobal => write!(f, "path-global"),
1951 SuppressionSource::PathRule => write!(f, "path-rule"),
1952 SuppressionSource::Preset => write!(f, "preset"),
1953 SuppressionSource::Baseline => write!(f, "baseline"),
1954 }
1955 }
1956}
1957
1958pub struct SuppressionEngine {
1967 global_ignore_paths: Vec<String>,
1969 rule_ignore_paths: HashMap<String, Vec<String>>,
1971 use_default_presets: bool,
1973 baseline: Option<Baseline>,
1975 global_patterns: Vec<regex::Regex>,
1977 rule_patterns: HashMap<String, Vec<regex::Regex>>,
1979 test_patterns: Vec<regex::Regex>,
1981 example_patterns: Vec<regex::Regex>,
1983}
1984
1985impl SuppressionEngine {
1986 pub fn new(rules_config: &RulesConfig, use_default_presets: bool) -> Self {
1988 let global_patterns = rules_config
1989 .ignore_paths
1990 .iter()
1991 .filter_map(|p| Self::compile_glob(p))
1992 .collect();
1993
1994 let mut rule_patterns = HashMap::new();
1995 for (rule_id, paths) in &rules_config.ignore_paths_by_rule {
1996 let patterns: Vec<regex::Regex> =
1997 paths.iter().filter_map(|p| Self::compile_glob(p)).collect();
1998 if !patterns.is_empty() {
1999 rule_patterns.insert(rule_id.clone(), patterns);
2000 }
2001 }
2002
2003 let test_patterns = if use_default_presets {
2004 DEFAULT_TEST_IGNORE_PATHS
2005 .iter()
2006 .filter_map(|p| Self::compile_glob(p))
2007 .collect()
2008 } else {
2009 Vec::new()
2010 };
2011
2012 let example_patterns = if use_default_presets {
2013 DEFAULT_EXAMPLE_IGNORE_PATHS
2014 .iter()
2015 .filter_map(|p| Self::compile_glob(p))
2016 .collect()
2017 } else {
2018 Vec::new()
2019 };
2020
2021 Self {
2022 global_ignore_paths: rules_config.ignore_paths.clone(),
2023 rule_ignore_paths: rules_config.ignore_paths_by_rule.clone(),
2024 use_default_presets,
2025 baseline: None,
2026 global_patterns,
2027 rule_patterns,
2028 test_patterns,
2029 example_patterns,
2030 }
2031 }
2032
2033 pub fn with_defaults_only() -> Self {
2035 Self::new(&RulesConfig::default(), true)
2036 }
2037
2038 pub fn with_baseline(mut self, baseline: Baseline) -> Self {
2040 self.baseline = Some(baseline);
2041 self
2042 }
2043
2044 fn compile_glob(pattern: &str) -> Option<regex::Regex> {
2050 let regex_pattern = pattern
2051 .replace('.', r"\.")
2052 .replace("**", "§")
2053 .replace('*', "[^/]*")
2054 .replace('§', ".*");
2055
2056 let regex_pattern = if let Some(rest) = regex_pattern.strip_prefix(".*/") {
2059 format!("(^|.*/){}", rest)
2061 } else if regex_pattern.starts_with(".*") {
2062 regex_pattern
2064 } else {
2065 format!("^{}", regex_pattern)
2067 };
2068
2069 regex::Regex::new(&format!("(?i){}$", regex_pattern)).ok()
2070 }
2071
2072 fn matches_patterns(path: &str, patterns: &[regex::Regex]) -> bool {
2074 let normalized = path.replace('\\', "/");
2075 patterns.iter().any(|re| re.is_match(&normalized))
2076 }
2077
2078 pub fn is_always_enabled(rule_id: &str) -> bool {
2080 RULES_ALWAYS_ENABLED.iter().any(|r| {
2081 rule_id == *r
2082 || rule_id.starts_with(&format!("{}:", r))
2083 || r.ends_with("*") && rule_id.starts_with(r.trim_end_matches('*'))
2084 })
2085 }
2086
2087 pub fn check(
2098 &self,
2099 rule_id: &str,
2100 file_path: &Path,
2101 finding_line: usize,
2102 inline_suppressions: &[InlineSuppression],
2103 fingerprint: Option<&str>,
2104 ) -> SuppressionResult {
2105 let path_str = file_path.to_string_lossy();
2106
2107 for suppression in inline_suppressions {
2109 if suppression.applies_to(finding_line, rule_id) {
2110 let reason = suppression
2111 .reason
2112 .clone()
2113 .unwrap_or_else(|| "No reason provided".to_string());
2114 return SuppressionResult::suppressed(
2115 SuppressionSource::Inline,
2116 reason,
2117 format!("line {}", suppression.line),
2118 );
2119 }
2120 }
2121
2122 let is_always_enabled = Self::is_always_enabled(rule_id);
2124
2125 if !is_always_enabled {
2126 if Self::matches_patterns(&path_str, &self.global_patterns) {
2128 for (i, pattern) in self.global_ignore_paths.iter().enumerate() {
2129 if let Some(re) = self.global_patterns.get(i)
2130 && re.is_match(&path_str.replace('\\', "/"))
2131 {
2132 return SuppressionResult::suppressed(
2133 SuppressionSource::PathGlobal,
2134 format!("Path matches global ignore pattern: {}", pattern),
2135 pattern.clone(),
2136 );
2137 }
2138 }
2139 }
2140
2141 if let Some(patterns) = self.rule_patterns.get(rule_id)
2143 && Self::matches_patterns(&path_str, patterns)
2144 && let Some(rule_paths) = self.rule_ignore_paths.get(rule_id)
2145 {
2146 for (i, pattern) in rule_paths.iter().enumerate() {
2147 if let Some(re) = patterns.get(i)
2148 && re.is_match(&path_str.replace('\\', "/"))
2149 {
2150 return SuppressionResult::suppressed(
2151 SuppressionSource::PathRule,
2152 format!("Path matches rule-specific ignore pattern: {}", pattern),
2153 format!("{}:{}", rule_id, pattern),
2154 );
2155 }
2156 }
2157 }
2158
2159 for (pattern_rule_id, patterns) in &self.rule_patterns {
2161 if pattern_rule_id.ends_with("/*") {
2162 let prefix = pattern_rule_id.trim_end_matches("/*");
2163 if rule_id.starts_with(prefix)
2164 && Self::matches_patterns(&path_str, patterns)
2165 && let Some(rule_paths) = self.rule_ignore_paths.get(pattern_rule_id)
2166 && let Some(pattern) = rule_paths.first()
2167 {
2168 return SuppressionResult::suppressed(
2169 SuppressionSource::PathRule,
2170 format!("Path matches rule-specific ignore pattern: {}", pattern),
2171 format!("{}:{}", pattern_rule_id, pattern),
2172 );
2173 }
2174 }
2175 }
2176
2177 if self.use_default_presets {
2179 if Self::matches_patterns(&path_str, &self.test_patterns) {
2180 return SuppressionResult::suppressed(
2181 SuppressionSource::Preset,
2182 "File is in test directory (suppressed by default preset)".to_string(),
2183 "test-preset".to_string(),
2184 );
2185 }
2186 if Self::matches_patterns(&path_str, &self.example_patterns) {
2187 return SuppressionResult::suppressed(
2188 SuppressionSource::Preset,
2189 "File is in example/fixture directory (suppressed by default preset)"
2190 .to_string(),
2191 "example-preset".to_string(),
2192 );
2193 }
2194 }
2195 }
2196
2197 if let Some(ref baseline) = self.baseline
2199 && let Some(fp) = fingerprint
2200 {
2201 let fingerprint_obj = Fingerprint::from_string(fp.to_string());
2202 if baseline.contains_fingerprint(&fingerprint_obj) {
2203 return SuppressionResult::suppressed(
2204 SuppressionSource::Baseline,
2205 "Finding is in baseline".to_string(),
2206 "baseline".to_string(),
2207 );
2208 }
2209 }
2210
2211 SuppressionResult::not_suppressed()
2212 }
2213
2214 pub fn should_skip_path(&self, file_path: &Path) -> bool {
2219 let path_str = file_path.to_string_lossy();
2220
2221 if Self::matches_patterns(&path_str, &self.global_patterns) {
2223 return true;
2224 }
2225
2226 if self.use_default_presets {
2228 false
2233 } else {
2234 false
2235 }
2236 }
2237
2238 pub fn add_suppression_metadata(
2240 properties: &mut HashMap<String, serde_json::Value>,
2241 result: &SuppressionResult,
2242 ) {
2243 if result.suppressed {
2244 properties.insert("suppressed".to_string(), serde_json::json!(true));
2245 if let Some(ref reason) = result.reason {
2246 properties.insert("suppression_reason".to_string(), serde_json::json!(reason));
2247 }
2248 if let Some(ref source) = result.source {
2249 properties.insert(
2250 "suppression_source".to_string(),
2251 serde_json::json!(source.to_string()),
2252 );
2253 }
2254 if let Some(ref location) = result.location {
2255 properties.insert(
2256 "suppression_location".to_string(),
2257 serde_json::json!(location),
2258 );
2259 }
2260 }
2261 }
2262}
2263
2264impl Default for SuppressionEngine {
2265 fn default() -> Self {
2266 Self::new(&RulesConfig::default(), false)
2267 }
2268}
2269
2270#[cfg(test)]
2271mod tests {
2272 use super::*;
2273 use std::str::FromStr;
2274
2275 #[test]
2276 fn test_profile_parsing() {
2277 assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
2278 assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
2279 assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
2280 assert!(Profile::from_str("unknown").is_err());
2281 }
2282
2283 #[test]
2284 fn test_rule_matching() {
2285 assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
2286 assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
2287 assert!(!RmaTomlConfig::matches_pattern(
2288 "generic/long",
2289 "security/*"
2290 ));
2291 assert!(RmaTomlConfig::matches_pattern(
2292 "security/xss",
2293 "security/xss"
2294 ));
2295 }
2296
2297 #[test]
2298 fn test_default_config_parses() {
2299 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2300 let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
2301 assert_eq!(config.profiles.default, Profile::Balanced);
2302 }
2303
2304 #[test]
2305 fn test_thresholds_for_profile() {
2306 let fast = ProfileThresholds::for_profile(Profile::Fast);
2307 let strict = ProfileThresholds::for_profile(Profile::Strict);
2308
2309 assert!(fast.max_function_lines > strict.max_function_lines);
2310 assert!(fast.max_complexity > strict.max_complexity);
2311 }
2312
2313 #[test]
2314 fn test_fingerprint_stable_across_line_changes() {
2315 let fp1 = Fingerprint::generate(
2317 "js/xss-sink",
2318 Path::new("src/app.js"),
2319 "element.textContent = userInput;",
2320 );
2321 let fp2 = Fingerprint::generate(
2322 "js/xss-sink",
2323 Path::new("src/app.js"),
2324 "element.textContent = userInput;",
2325 );
2326
2327 assert_eq!(fp1, fp2);
2328 }
2329
2330 #[test]
2331 fn test_fingerprint_stable_with_whitespace_changes() {
2332 let fp1 = Fingerprint::generate(
2334 "generic/long-function",
2335 Path::new("src/utils.rs"),
2336 "fn very_long_function() {",
2337 );
2338 let fp2 = Fingerprint::generate(
2339 "generic/long-function",
2340 Path::new("src/utils.rs"),
2341 "fn very_long_function() {",
2342 );
2343 let fp3 = Fingerprint::generate(
2344 "generic/long-function",
2345 Path::new("src/utils.rs"),
2346 " fn very_long_function() { ",
2347 );
2348
2349 assert_eq!(fp1, fp2);
2350 assert_eq!(fp2, fp3);
2351 }
2352
2353 #[test]
2354 fn test_fingerprint_different_for_different_rules() {
2355 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2356 let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
2357
2358 assert_ne!(fp1, fp2);
2359 }
2360
2361 #[test]
2362 fn test_fingerprint_different_for_different_files() {
2363 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2364 let fp2 =
2365 Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
2366
2367 assert_ne!(fp1, fp2);
2368 }
2369
2370 #[test]
2371 fn test_fingerprint_path_normalization() {
2372 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
2374 let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
2375
2376 assert_eq!(fp1, fp2);
2377 }
2378
2379 #[test]
2380 fn test_effective_config_precedence() {
2381 let toml_config = RmaTomlConfig::default();
2383 let effective = EffectiveConfig::resolve(
2384 Some(&toml_config),
2385 Some(Path::new("rma.toml")),
2386 Some(Profile::Strict), false,
2388 );
2389
2390 assert_eq!(effective.profile, Profile::Strict);
2391 assert_eq!(effective.profile_source, ConfigSource::CliFlag);
2392 }
2393
2394 #[test]
2395 fn test_effective_config_defaults() {
2396 let effective = EffectiveConfig::resolve(None, None, None, false);
2398
2399 assert_eq!(effective.profile, Profile::Balanced);
2400 assert_eq!(effective.profile_source, ConfigSource::Default);
2401 assert!(effective.config_file.is_none());
2402 }
2403
2404 #[test]
2405 fn test_effective_config_from_file() {
2406 let mut toml_config = RmaTomlConfig::default();
2408 toml_config.profiles.default = Profile::Fast;
2409
2410 let effective =
2411 EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
2412
2413 assert_eq!(effective.profile, Profile::Fast);
2414 assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
2415 }
2416
2417 #[test]
2418 fn test_config_version_missing_warns() {
2419 let toml = r#"
2420[profiles]
2421default = "balanced"
2422"#;
2423 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2424 assert!(config.config_version.is_none());
2425 assert!(!config.has_version());
2426 assert_eq!(config.effective_version(), 1);
2427
2428 let warnings = config.validate();
2429 assert!(
2430 warnings
2431 .iter()
2432 .any(|w| w.message.contains("Missing 'config_version'"))
2433 );
2434 }
2435
2436 #[test]
2437 fn test_config_version_1_ok() {
2438 let toml = r#"
2439config_version = 1
2440
2441[profiles]
2442default = "balanced"
2443"#;
2444 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2445 assert_eq!(config.config_version, Some(1));
2446 assert!(config.has_version());
2447 assert_eq!(config.effective_version(), 1);
2448
2449 let warnings = config.validate();
2450 assert!(
2451 !warnings
2452 .iter()
2453 .any(|w| w.message.contains("config_version"))
2454 );
2455 }
2456
2457 #[test]
2458 fn test_config_version_999_fails() {
2459 let toml = r#"
2460config_version = 999
2461
2462[profiles]
2463default = "balanced"
2464"#;
2465 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2466 assert_eq!(config.config_version, Some(999));
2467
2468 let warnings = config.validate();
2469 let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
2470 assert!(error.is_some());
2471 assert!(
2472 error
2473 .unwrap()
2474 .message
2475 .contains("Unsupported config version: 999")
2476 );
2477 }
2478
2479 #[test]
2480 fn test_default_toml_includes_version() {
2481 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2482 assert!(toml.contains("config_version = 1"));
2483
2484 let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
2486 assert_eq!(config.config_version, Some(1));
2487 }
2488
2489 #[test]
2490 fn test_ruleset_security() {
2491 let toml = r#"
2492config_version = 1
2493
2494[rulesets]
2495security = ["js/innerhtml-xss", "js/timer-string-eval"]
2496maintainability = ["generic/long-function"]
2497
2498[rules]
2499enable = ["*"]
2500"#;
2501 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2502
2503 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2505 assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2506 assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
2507
2508 assert!(config.is_rule_enabled("generic/long-function"));
2510 }
2511
2512 #[test]
2513 fn test_ruleset_with_disable() {
2514 let toml = r#"
2515config_version = 1
2516
2517[rulesets]
2518security = ["js/innerhtml-xss", "js/timer-string-eval"]
2519
2520[rules]
2521enable = ["*"]
2522disable = ["js/timer-string-eval"]
2523"#;
2524 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2525
2526 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2528 assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2529 }
2530
2531 #[test]
2532 fn test_get_ruleset_names() {
2533 let toml = r#"
2534config_version = 1
2535
2536[rulesets]
2537security = ["js/innerhtml-xss"]
2538maintainability = ["generic/long-function"]
2539"#;
2540 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2541 let names = config.get_ruleset_names();
2542
2543 assert!(names.contains(&"security".to_string()));
2544 assert!(names.contains(&"maintainability".to_string()));
2545 }
2546
2547 #[test]
2548 fn test_default_toml_includes_rulesets() {
2549 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2550 assert!(toml.contains("[rulesets]"));
2551 assert!(toml.contains("security = "));
2552 assert!(toml.contains("maintainability = "));
2553 }
2554
2555 #[test]
2556 fn test_inline_suppression_next_line() {
2557 let suppression = InlineSuppression::parse(
2558 "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
2559 10,
2560 );
2561 assert!(suppression.is_some());
2562 let s = suppression.unwrap();
2563 assert_eq!(s.rule_id, "js/innerhtml-xss");
2564 assert_eq!(s.reason, Some("sanitized input".to_string()));
2565 assert_eq!(s.line, 10);
2566 assert_eq!(s.suppression_type, SuppressionType::NextLine);
2567
2568 assert!(s.applies_to(11, "js/innerhtml-xss"));
2570 assert!(!s.applies_to(12, "js/innerhtml-xss"));
2572 assert!(!s.applies_to(11, "js/console-log"));
2574 }
2575
2576 #[test]
2577 fn test_inline_suppression_block() {
2578 let suppression = InlineSuppression::parse(
2579 "// rma-ignore generic/long-function reason=\"legacy code\"",
2580 5,
2581 );
2582 assert!(suppression.is_some());
2583 let s = suppression.unwrap();
2584 assert_eq!(s.rule_id, "generic/long-function");
2585 assert_eq!(s.suppression_type, SuppressionType::Block);
2586
2587 assert!(s.applies_to(5, "generic/long-function"));
2589 assert!(s.applies_to(10, "generic/long-function"));
2590 assert!(s.applies_to(100, "generic/long-function"));
2591 }
2592
2593 #[test]
2594 fn test_inline_suppression_without_reason() {
2595 let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
2596 assert!(suppression.is_some());
2597 let s = suppression.unwrap();
2598 assert_eq!(s.rule_id, "js/console-log");
2599 assert!(s.reason.is_none());
2600 }
2601
2602 #[test]
2603 fn test_inline_suppression_python_style() {
2604 let suppression = InlineSuppression::parse(
2605 "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
2606 3,
2607 );
2608 assert!(suppression.is_some());
2609 let s = suppression.unwrap();
2610 assert_eq!(s.rule_id, "python/hardcoded-secret");
2611 assert_eq!(s.reason, Some("test data".to_string()));
2612 }
2613
2614 #[test]
2615 fn test_inline_suppression_validation_strict() {
2616 let s = InlineSuppression {
2617 rule_id: "js/xss".to_string(),
2618 reason: None,
2619 line: 1,
2620 suppression_type: SuppressionType::NextLine,
2621 };
2622
2623 assert!(s.validate(true).is_err());
2625 assert!(s.validate(false).is_ok());
2627
2628 let s_with_reason = InlineSuppression {
2629 rule_id: "js/xss".to_string(),
2630 reason: Some("approved".to_string()),
2631 line: 1,
2632 suppression_type: SuppressionType::NextLine,
2633 };
2634
2635 assert!(s_with_reason.validate(true).is_ok());
2637 assert!(s_with_reason.validate(false).is_ok());
2638 }
2639
2640 #[test]
2641 fn test_parse_inline_suppressions() {
2642 let content = r#"
2643function foo() {
2644 // rma-ignore-next-line js/console-log reason="debugging"
2645 console.log("test");
2646
2647 // rma-ignore generic/long-function reason="complex algorithm"
2648 // ... lots of code ...
2649}
2650"#;
2651 let suppressions = parse_inline_suppressions(content);
2652 assert_eq!(suppressions.len(), 2);
2653 assert_eq!(suppressions[0].rule_id, "js/console-log");
2654 assert_eq!(suppressions[1].rule_id, "generic/long-function");
2655 }
2656
2657 #[test]
2658 fn test_suppression_does_not_affect_other_rules() {
2659 let suppression = InlineSuppression::parse(
2660 "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2661 10,
2662 )
2663 .unwrap();
2664
2665 assert!(suppression.applies_to(11, "js/innerhtml-xss"));
2667 assert!(!suppression.applies_to(11, "js/console-log"));
2669 assert!(!suppression.applies_to(11, "generic/long-function"));
2670 }
2671
2672 #[test]
2677 fn test_suppression_engine_global_path_ignore() {
2678 let rules_config = RulesConfig {
2679 ignore_paths: vec!["**/vendor/**".to_string(), "**/generated/**".to_string()],
2680 ..Default::default()
2681 };
2682
2683 let engine = SuppressionEngine::new(&rules_config, false);
2684
2685 let result = engine.check(
2687 "generic/long-function",
2688 Path::new("src/vendor/lib.js"),
2689 10,
2690 &[],
2691 None,
2692 );
2693 assert!(result.suppressed);
2694 assert_eq!(result.source, Some(SuppressionSource::PathGlobal));
2695
2696 let result = engine.check(
2698 "generic/long-function",
2699 Path::new("src/app.js"),
2700 10,
2701 &[],
2702 None,
2703 );
2704 assert!(!result.suppressed);
2705 }
2706
2707 #[test]
2708 fn test_suppression_engine_per_rule_path_ignore() {
2709 let rules_config = RulesConfig {
2710 ignore_paths_by_rule: HashMap::from([(
2711 "generic/long-function".to_string(),
2712 vec!["**/tests/**".to_string()],
2713 )]),
2714 ..Default::default()
2715 };
2716
2717 let engine = SuppressionEngine::new(&rules_config, false);
2718
2719 let result = engine.check(
2721 "generic/long-function",
2722 Path::new("src/tests/test_app.js"),
2723 10,
2724 &[],
2725 None,
2726 );
2727 assert!(result.suppressed);
2728 assert_eq!(result.source, Some(SuppressionSource::PathRule));
2729
2730 let result = engine.check(
2732 "js/console-log",
2733 Path::new("src/tests/test_app.js"),
2734 10,
2735 &[],
2736 None,
2737 );
2738 assert!(!result.suppressed);
2739 }
2740
2741 #[test]
2742 fn test_suppression_engine_inline_suppression() {
2743 let rules_config = RulesConfig::default();
2744 let engine = SuppressionEngine::new(&rules_config, false);
2745
2746 let inline_suppressions = vec![InlineSuppression {
2747 rule_id: "js/console-log".to_string(),
2748 reason: Some("debug output".to_string()),
2749 line: 10,
2750 suppression_type: SuppressionType::NextLine,
2751 }];
2752
2753 let result = engine.check(
2755 "js/console-log",
2756 Path::new("src/app.js"),
2757 11, &inline_suppressions,
2759 None,
2760 );
2761 assert!(result.suppressed);
2762 assert_eq!(result.source, Some(SuppressionSource::Inline));
2763 assert_eq!(result.reason, Some("debug output".to_string()));
2764
2765 let result = engine.check(
2767 "js/console-log",
2768 Path::new("src/app.js"),
2769 12,
2770 &inline_suppressions,
2771 None,
2772 );
2773 assert!(!result.suppressed);
2774 }
2775
2776 #[test]
2777 fn test_suppression_engine_default_presets() {
2778 let rules_config = RulesConfig::default();
2779 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
2783 "generic/long-function",
2784 Path::new("src/tests/test_app.rs"),
2785 10,
2786 &[],
2787 None,
2788 );
2789 assert!(result.suppressed);
2790 assert_eq!(result.source, Some(SuppressionSource::Preset));
2791
2792 let result = engine.check(
2794 "js/console-log",
2795 Path::new("src/app.test.ts"),
2796 10,
2797 &[],
2798 None,
2799 );
2800 assert!(result.suppressed);
2801
2802 let result = engine.check(
2804 "generic/long-function",
2805 Path::new("examples/demo.rs"),
2806 10,
2807 &[],
2808 None,
2809 );
2810 assert!(result.suppressed);
2811
2812 let result = engine.check(
2814 "generic/long-function",
2815 Path::new("src/lib.rs"),
2816 10,
2817 &[],
2818 None,
2819 );
2820 assert!(!result.suppressed);
2821 }
2822
2823 #[test]
2824 fn test_suppression_engine_security_rules_not_suppressed_by_preset() {
2825 let rules_config = RulesConfig::default();
2826 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
2830 "rust/command-injection",
2831 Path::new("src/tests/test_app.rs"),
2832 10,
2833 &[],
2834 None,
2835 );
2836 assert!(!result.suppressed);
2837
2838 let result = engine.check(
2839 "generic/hardcoded-secret",
2840 Path::new("examples/demo.py"),
2841 10,
2842 &[],
2843 None,
2844 );
2845 assert!(!result.suppressed);
2846
2847 let result = engine.check(
2848 "python/shell-injection",
2849 Path::new("tests/test_shell.py"),
2850 10,
2851 &[],
2852 None,
2853 );
2854 assert!(!result.suppressed);
2855 }
2856
2857 #[test]
2858 fn test_suppression_engine_security_rules_can_be_suppressed_inline() {
2859 let rules_config = RulesConfig::default();
2860 let engine = SuppressionEngine::new(&rules_config, true);
2861
2862 let inline_suppressions = vec![InlineSuppression {
2863 rule_id: "rust/command-injection".to_string(),
2864 reason: Some("sanitized input validated upstream".to_string()),
2865 line: 10,
2866 suppression_type: SuppressionType::NextLine,
2867 }];
2868
2869 let result = engine.check(
2871 "rust/command-injection",
2872 Path::new("src/app.rs"),
2873 11,
2874 &inline_suppressions,
2875 None,
2876 );
2877 assert!(result.suppressed);
2878 assert_eq!(result.source, Some(SuppressionSource::Inline));
2879 }
2880
2881 #[test]
2882 fn test_suppression_engine_is_always_enabled() {
2883 assert!(SuppressionEngine::is_always_enabled(
2884 "rust/command-injection"
2885 ));
2886 assert!(SuppressionEngine::is_always_enabled(
2887 "python/shell-injection"
2888 ));
2889 assert!(SuppressionEngine::is_always_enabled(
2890 "generic/hardcoded-secret"
2891 ));
2892 assert!(SuppressionEngine::is_always_enabled("go/command-injection"));
2893 assert!(SuppressionEngine::is_always_enabled(
2894 "java/command-execution"
2895 ));
2896 assert!(SuppressionEngine::is_always_enabled(
2897 "js/dynamic-code-execution"
2898 ));
2899
2900 assert!(!SuppressionEngine::is_always_enabled(
2902 "generic/long-function"
2903 ));
2904 assert!(!SuppressionEngine::is_always_enabled("js/console-log"));
2905 assert!(!SuppressionEngine::is_always_enabled("rust/unsafe-block"));
2906 }
2907
2908 #[test]
2909 fn test_suppression_engine_add_metadata() {
2910 let result = SuppressionResult::suppressed(
2911 SuppressionSource::Inline,
2912 "debug output".to_string(),
2913 "line 10".to_string(),
2914 );
2915
2916 let mut properties = HashMap::new();
2917 SuppressionEngine::add_suppression_metadata(&mut properties, &result);
2918
2919 assert_eq!(properties.get("suppressed"), Some(&serde_json::json!(true)));
2920 assert_eq!(
2921 properties.get("suppression_reason"),
2922 Some(&serde_json::json!("debug output"))
2923 );
2924 assert_eq!(
2925 properties.get("suppression_source"),
2926 Some(&serde_json::json!("inline"))
2927 );
2928 assert_eq!(
2929 properties.get("suppression_location"),
2930 Some(&serde_json::json!("line 10"))
2931 );
2932 }
2933
2934 #[test]
2935 fn test_suppression_result_not_suppressed() {
2936 let result = SuppressionResult::not_suppressed();
2937 assert!(!result.suppressed);
2938 assert!(result.reason.is_none());
2939 assert!(result.source.is_none());
2940 assert!(result.location.is_none());
2941 }
2942
2943 #[test]
2944 fn test_rules_config_with_ignore_paths() {
2945 let toml = r#"
2946config_version = 1
2947
2948[rules]
2949enable = ["*"]
2950disable = []
2951ignore_paths = ["**/vendor/**", "**/generated/**"]
2952
2953[rules.ignore_paths_by_rule]
2954"generic/long-function" = ["**/tests/**", "**/examples/**"]
2955"js/console-log" = ["**/debug/**"]
2956"#;
2957 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2958
2959 assert_eq!(config.rules.ignore_paths.len(), 2);
2960 assert!(
2961 config
2962 .rules
2963 .ignore_paths
2964 .contains(&"**/vendor/**".to_string())
2965 );
2966 assert!(
2967 config
2968 .rules
2969 .ignore_paths
2970 .contains(&"**/generated/**".to_string())
2971 );
2972
2973 assert_eq!(config.rules.ignore_paths_by_rule.len(), 2);
2974 assert!(
2975 config
2976 .rules
2977 .ignore_paths_by_rule
2978 .contains_key("generic/long-function")
2979 );
2980 assert!(
2981 config
2982 .rules
2983 .ignore_paths_by_rule
2984 .contains_key("js/console-log")
2985 );
2986 }
2987}