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, ProviderType::Oxc]
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct PmdProviderConfig {
449 #[serde(default)]
451 pub configured: bool,
452
453 #[serde(default = "default_java_path")]
455 pub java_path: String,
456
457 #[serde(default)]
460 pub pmd_path: String,
461
462 #[serde(default = "default_pmd_rulesets")]
464 pub rulesets: Vec<String>,
465
466 #[serde(default = "default_pmd_timeout")]
468 pub timeout_ms: u64,
469
470 #[serde(default = "default_pmd_include_patterns")]
472 pub include_patterns: Vec<String>,
473
474 #[serde(default = "default_pmd_exclude_patterns")]
476 pub exclude_patterns: Vec<String>,
477
478 #[serde(default = "default_pmd_severity_map")]
482 pub severity_map: HashMap<String, Severity>,
483
484 #[serde(default)]
486 pub fail_on_error: bool,
487
488 #[serde(default = "default_pmd_min_priority")]
490 pub min_priority: u8,
491
492 #[serde(default)]
494 pub extra_args: Vec<String>,
495}
496
497impl Default for PmdProviderConfig {
498 fn default() -> Self {
499 Self {
500 configured: false,
501 java_path: default_java_path(),
502 pmd_path: String::new(),
503 rulesets: default_pmd_rulesets(),
504 timeout_ms: default_pmd_timeout(),
505 include_patterns: default_pmd_include_patterns(),
506 exclude_patterns: default_pmd_exclude_patterns(),
507 severity_map: default_pmd_severity_map(),
508 fail_on_error: false,
509 min_priority: default_pmd_min_priority(),
510 extra_args: Vec::new(),
511 }
512 }
513}
514
515fn default_java_path() -> String {
516 "java".to_string()
517}
518
519fn default_pmd_rulesets() -> Vec<String> {
520 vec![
521 "category/java/security.xml".to_string(),
522 "category/java/bestpractices.xml".to_string(),
523 "category/java/errorprone.xml".to_string(),
524 ]
525}
526
527fn default_pmd_timeout() -> u64 {
528 600_000 }
530
531fn default_pmd_include_patterns() -> Vec<String> {
532 vec!["**/*.java".to_string()]
533}
534
535fn default_pmd_exclude_patterns() -> Vec<String> {
536 vec![
537 "**/target/**".to_string(),
538 "**/build/**".to_string(),
539 "**/generated/**".to_string(),
540 "**/out/**".to_string(),
541 "**/.git/**".to_string(),
542 "**/node_modules/**".to_string(),
543 ]
544}
545
546fn default_pmd_severity_map() -> HashMap<String, Severity> {
547 let mut map = HashMap::new();
548 map.insert("1".to_string(), Severity::Critical);
549 map.insert("2".to_string(), Severity::Error);
550 map.insert("3".to_string(), Severity::Warning);
551 map.insert("4".to_string(), Severity::Info);
552 map.insert("5".to_string(), Severity::Info);
553 map
554}
555
556fn default_pmd_min_priority() -> u8 {
557 5 }
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct OxlintProviderConfig {
563 #[serde(default)]
565 pub configured: bool,
566
567 #[serde(default)]
569 pub binary_path: String,
570
571 #[serde(default = "default_oxlint_timeout")]
573 pub timeout_ms: u64,
574
575 #[serde(default)]
577 pub extra_args: Vec<String>,
578}
579
580impl Default for OxlintProviderConfig {
581 fn default() -> Self {
582 Self {
583 configured: false,
584 binary_path: String::new(),
585 timeout_ms: default_oxlint_timeout(),
586 extra_args: Vec::new(),
587 }
588 }
589}
590
591fn default_oxlint_timeout() -> u64 {
592 300_000 }
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct GosecProviderConfig {
598 #[serde(default)]
600 pub configured: bool,
601
602 #[serde(default)]
604 pub binary_path: String,
605
606 #[serde(default = "default_gosec_timeout")]
608 pub timeout_ms: u64,
609
610 #[serde(default)]
612 pub exclude_rules: Vec<String>,
613
614 #[serde(default)]
616 pub include_rules: Vec<String>,
617
618 #[serde(default)]
620 pub extra_args: Vec<String>,
621}
622
623impl Default for GosecProviderConfig {
624 fn default() -> Self {
625 Self {
626 configured: false,
627 binary_path: String::new(),
628 timeout_ms: default_gosec_timeout(),
629 exclude_rules: Vec::new(),
630 include_rules: Vec::new(),
631 extra_args: Vec::new(),
632 }
633 }
634}
635
636fn default_gosec_timeout() -> u64 {
637 300_000 }
639
640#[derive(Debug, Clone, Default, Serialize, Deserialize)]
642pub struct OxcProviderConfig {
643 #[serde(default)]
645 pub configured: bool,
646
647 #[serde(default)]
649 pub enable_rules: Vec<String>,
650
651 #[serde(default)]
653 pub disable_rules: Vec<String>,
654
655 #[serde(default)]
657 pub severity_overrides: HashMap<String, Severity>,
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
662#[serde(rename_all = "lowercase")]
663pub enum OsvEcosystem {
664 #[serde(rename = "crates.io")]
666 CratesIo,
667 Npm,
669 PyPI,
671 Go,
673 Maven,
675}
676
677impl std::fmt::Display for OsvEcosystem {
678 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679 match self {
680 OsvEcosystem::CratesIo => write!(f, "crates.io"),
681 OsvEcosystem::Npm => write!(f, "npm"),
682 OsvEcosystem::PyPI => write!(f, "PyPI"),
683 OsvEcosystem::Go => write!(f, "Go"),
684 OsvEcosystem::Maven => write!(f, "Maven"),
685 }
686 }
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize)]
691pub struct OsvProviderConfig {
692 #[serde(default)]
694 pub configured: bool,
695
696 #[serde(default)]
698 pub include_dev_deps: bool,
699
700 #[serde(default = "default_osv_cache_ttl")]
703 pub cache_ttl: String,
704
705 #[serde(default = "default_osv_ecosystems")]
707 pub enabled_ecosystems: Vec<OsvEcosystem>,
708
709 #[serde(default)]
712 pub severity_overrides: HashMap<String, Severity>,
713
714 #[serde(default)]
717 pub ignore_list: Vec<String>,
718
719 #[serde(default)]
721 pub offline: bool,
722
723 #[serde(default)]
725 pub cache_dir: Option<PathBuf>,
726}
727
728impl Default for OsvProviderConfig {
729 fn default() -> Self {
730 Self {
731 configured: false,
732 include_dev_deps: false,
733 cache_ttl: default_osv_cache_ttl(),
734 enabled_ecosystems: default_osv_ecosystems(),
735 severity_overrides: HashMap::new(),
736 ignore_list: Vec::new(),
737 offline: false,
738 cache_dir: None,
739 }
740 }
741}
742
743fn default_osv_cache_ttl() -> String {
744 "24h".to_string()
745}
746
747fn default_osv_ecosystems() -> Vec<OsvEcosystem> {
748 vec![
749 OsvEcosystem::CratesIo,
750 OsvEcosystem::Npm,
751 OsvEcosystem::PyPI,
752 OsvEcosystem::Go,
753 OsvEcosystem::Maven,
754 ]
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize, Default)]
759pub struct BaselineConfig {
760 #[serde(default = "default_baseline_file")]
762 pub file: PathBuf,
763
764 #[serde(default)]
766 pub mode: BaselineMode,
767}
768
769fn default_baseline_file() -> PathBuf {
770 PathBuf::from(".rma/baseline.json")
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
779pub struct SuppressionConfig {
780 #[serde(default = "default_suppression_database")]
782 pub database: PathBuf,
783
784 #[serde(default = "default_expiration")]
786 pub default_expiration: String,
787
788 #[serde(default)]
790 pub require_ticket: bool,
791
792 #[serde(default = "default_max_expiration")]
794 pub max_expiration: String,
795
796 #[serde(default = "default_enabled")]
798 pub enabled: bool,
799}
800
801impl Default for SuppressionConfig {
802 fn default() -> Self {
803 Self {
804 database: default_suppression_database(),
805 default_expiration: default_expiration(),
806 require_ticket: false,
807 max_expiration: default_max_expiration(),
808 enabled: true,
809 }
810 }
811}
812
813fn default_suppression_database() -> PathBuf {
814 PathBuf::from(".rma/suppressions.db")
815}
816
817fn default_expiration() -> String {
818 "90d".to_string()
819}
820
821fn default_max_expiration() -> String {
822 "365d".to_string()
823}
824
825fn default_enabled() -> bool {
826 true
827}
828
829pub fn parse_expiration_days(s: &str) -> Option<u32> {
831 let s = s.trim().to_lowercase();
832 if let Some(days_str) = s.strip_suffix('d') {
833 days_str.parse().ok()
834 } else if let Some(weeks_str) = s.strip_suffix('w') {
835 weeks_str.parse::<u32>().ok().map(|w| w * 7)
836 } else if let Some(months_str) = s.strip_suffix('m') {
837 months_str.parse::<u32>().ok().map(|m| m * 30)
838 } else {
839 s.parse().ok()
841 }
842}
843
844pub const CURRENT_CONFIG_VERSION: u32 = 1;
846
847#[derive(Debug, Clone, Serialize, Deserialize, Default)]
849pub struct RmaTomlConfig {
850 #[serde(default)]
852 pub config_version: Option<u32>,
853
854 #[serde(default)]
856 pub scan: ScanConfig,
857
858 #[serde(default)]
860 pub rules: RulesConfig,
861
862 #[serde(default)]
864 pub rulesets: RulesetsConfig,
865
866 #[serde(default)]
868 pub profiles: ProfilesConfig,
869
870 #[serde(default)]
872 pub severity: HashMap<String, Severity>,
873
874 #[serde(default)]
876 pub threshold_overrides: Vec<ThresholdOverride>,
877
878 #[serde(default)]
880 pub allow: AllowConfig,
881
882 #[serde(default)]
884 pub baseline: BaselineConfig,
885
886 #[serde(default)]
888 pub providers: ProvidersConfig,
889
890 #[serde(default)]
892 pub suppressions: SuppressionConfig,
893}
894
895#[derive(Debug)]
897pub struct ConfigLoadResult {
898 pub config: RmaTomlConfig,
900 pub version_warning: Option<String>,
902}
903
904impl RmaTomlConfig {
905 pub fn load(path: &Path) -> Result<Self, String> {
907 let result = Self::load_with_validation(path)?;
908 Ok(result.config)
909 }
910
911 pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
913 let content = std::fs::read_to_string(path)
914 .map_err(|e| format!("Failed to read config file: {}", e))?;
915
916 let config: RmaTomlConfig =
917 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
918
919 let version_warning = config.validate_version()?;
921
922 Ok(ConfigLoadResult {
923 config,
924 version_warning,
925 })
926 }
927
928 fn validate_version(&self) -> Result<Option<String>, String> {
930 match self.config_version {
931 Some(CURRENT_CONFIG_VERSION) => Ok(None),
932 Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
933 "Unsupported config version: {}. Maximum supported version is {}. \
934 Please upgrade RMA or use a compatible config format.",
935 version, CURRENT_CONFIG_VERSION
936 )),
937 Some(version) => {
938 Err(format!(
940 "Invalid config version: {}. Expected version {}.",
941 version, CURRENT_CONFIG_VERSION
942 ))
943 }
944 None => Ok(Some(
945 "Config file is missing 'config_version'. Assuming version 1. \
946 Add 'config_version = 1' to suppress this warning."
947 .to_string(),
948 )),
949 }
950 }
951
952 pub fn has_version(&self) -> bool {
954 self.config_version.is_some()
955 }
956
957 pub fn effective_version(&self) -> u32 {
959 self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
960 }
961
962 pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
964 let candidates = [
965 start_path.join("rma.toml"),
966 start_path.join(".rma/rma.toml"),
967 start_path.join(".rma.toml"),
968 ];
969
970 for candidate in &candidates {
971 if candidate.exists()
972 && let Ok(config) = Self::load(candidate)
973 {
974 return Some((candidate.clone(), config));
975 }
976 }
977
978 let mut current = start_path.to_path_buf();
980 for _ in 0..5 {
981 if let Some(parent) = current.parent() {
982 let config_path = parent.join("rma.toml");
983 if config_path.exists()
984 && let Ok(config) = Self::load(&config_path)
985 {
986 return Some((config_path, config));
987 }
988 current = parent.to_path_buf();
989 } else {
990 break;
991 }
992 }
993
994 None
995 }
996
997 pub fn validate(&self) -> Vec<ConfigWarning> {
999 let mut warnings = Vec::new();
1000
1001 if self.config_version.is_none() {
1003 warnings.push(ConfigWarning {
1004 level: WarningLevel::Warning,
1005 message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
1006 .to_string(),
1007 });
1008 } else if let Some(version) = self.config_version
1009 && version > CURRENT_CONFIG_VERSION
1010 {
1011 warnings.push(ConfigWarning {
1012 level: WarningLevel::Error,
1013 message: format!(
1014 "Unsupported config version: {}. Maximum supported is {}.",
1015 version, CURRENT_CONFIG_VERSION
1016 ),
1017 });
1018 }
1019
1020 for disabled in &self.rules.disable {
1022 for enabled in &self.rules.enable {
1023 if enabled == disabled {
1024 warnings.push(ConfigWarning {
1025 level: WarningLevel::Warning,
1026 message: format!(
1027 "Rule '{}' is both enabled and disabled (disable takes precedence)",
1028 disabled
1029 ),
1030 });
1031 }
1032 }
1033 }
1034
1035 for (i, override_) in self.threshold_overrides.iter().enumerate() {
1037 if override_.path.is_empty() {
1038 warnings.push(ConfigWarning {
1039 level: WarningLevel::Error,
1040 message: format!("threshold_overrides[{}]: path cannot be empty", i),
1041 });
1042 }
1043 }
1044
1045 if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
1047 warnings.push(ConfigWarning {
1048 level: WarningLevel::Warning,
1049 message: format!(
1050 "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
1051 self.baseline.file.display()
1052 ),
1053 });
1054 }
1055
1056 for rule_id in self.severity.keys() {
1058 if rule_id.is_empty() {
1059 warnings.push(ConfigWarning {
1060 level: WarningLevel::Error,
1061 message: "Empty rule ID in severity overrides".to_string(),
1062 });
1063 }
1064 }
1065
1066 if self.providers.enabled.contains(&ProviderType::Pmd) {
1068 if !self.providers.pmd.configured && self.providers.pmd.pmd_path.is_empty() {
1070 warnings.push(ConfigWarning {
1071 level: WarningLevel::Warning,
1072 message: "PMD provider is enabled but not configured. Set [providers.pmd] configured = true or provide pmd_path.".to_string(),
1073 });
1074 }
1075
1076 if self.providers.pmd.rulesets.is_empty() {
1078 warnings.push(ConfigWarning {
1079 level: WarningLevel::Warning,
1080 message:
1081 "PMD provider has no rulesets configured. Add rulesets to [providers.pmd]."
1082 .to_string(),
1083 });
1084 }
1085
1086 for priority in self.providers.pmd.severity_map.keys() {
1088 if !["1", "2", "3", "4", "5"].contains(&priority.as_str()) {
1089 warnings.push(ConfigWarning {
1090 level: WarningLevel::Warning,
1091 message: format!(
1092 "Invalid PMD priority '{}' in severity_map. Valid priorities: 1-5.",
1093 priority
1094 ),
1095 });
1096 }
1097 }
1098 }
1099
1100 if self.providers.enabled.contains(&ProviderType::Oxlint)
1101 && !self.providers.oxlint.configured
1102 {
1103 warnings.push(ConfigWarning {
1104 level: WarningLevel::Warning,
1105 message: "Oxlint provider is enabled but not configured. Set [providers.oxlint] configured = true.".to_string(),
1106 });
1107 }
1108
1109 warnings
1110 }
1111
1112 pub fn is_provider_enabled(&self, provider: ProviderType) -> bool {
1114 self.providers.enabled.contains(&provider)
1115 }
1116
1117 pub fn get_enabled_providers(&self) -> &[ProviderType] {
1119 &self.providers.enabled
1120 }
1121
1122 pub fn get_pmd_config(&self) -> Option<&PmdProviderConfig> {
1124 if self.is_provider_enabled(ProviderType::Pmd) {
1125 Some(&self.providers.pmd)
1126 } else {
1127 None
1128 }
1129 }
1130
1131 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
1133 self.is_rule_enabled_with_ruleset(rule_id, None)
1134 }
1135
1136 pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
1141 for pattern in &self.rules.disable {
1143 if Self::matches_pattern(rule_id, pattern) {
1144 return false;
1145 }
1146 }
1147
1148 if let Some(ruleset_name) = ruleset {
1150 let ruleset_rules = self.get_ruleset_rules(ruleset_name);
1151 if !ruleset_rules.is_empty() {
1152 return ruleset_rules
1154 .iter()
1155 .any(|r| Self::matches_pattern(rule_id, r));
1156 }
1157 }
1158
1159 for pattern in &self.rules.enable {
1161 if Self::matches_pattern(rule_id, pattern) {
1162 return true;
1163 }
1164 }
1165
1166 false
1167 }
1168
1169 pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
1171 match name {
1172 "security" => self.rulesets.security.clone(),
1173 "maintainability" => self.rulesets.maintainability.clone(),
1174 _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
1175 }
1176 }
1177
1178 pub fn get_ruleset_names(&self) -> Vec<String> {
1180 let mut names = Vec::new();
1181 if !self.rulesets.security.is_empty() {
1182 names.push("security".to_string());
1183 }
1184 if !self.rulesets.maintainability.is_empty() {
1185 names.push("maintainability".to_string());
1186 }
1187 names.extend(self.rulesets.custom.keys().cloned());
1188 names
1189 }
1190
1191 pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
1193 self.severity.get(rule_id).copied()
1194 }
1195
1196 pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
1198 let mut thresholds = self.profiles.get_thresholds(profile).clone();
1199
1200 let path_str = path.to_string_lossy();
1202 for override_ in &self.threshold_overrides {
1203 if Self::matches_glob(&path_str, &override_.path) {
1204 if let Some(v) = override_.max_function_lines {
1205 thresholds.max_function_lines = v;
1206 }
1207 if let Some(v) = override_.max_complexity {
1208 thresholds.max_complexity = v;
1209 }
1210 if let Some(v) = override_.max_cognitive_complexity {
1211 thresholds.max_cognitive_complexity = v;
1212 }
1213 }
1214 }
1215
1216 thresholds
1217 }
1218
1219 pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
1221 let path_str = path.to_string_lossy();
1222 let allowed_paths = match rule_type {
1223 AllowType::InnerHtml => &self.allow.innerhtml_paths,
1224 AllowType::Eval => &self.allow.eval_paths,
1225 AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
1226 };
1227
1228 for pattern in allowed_paths {
1229 if Self::matches_glob(&path_str, pattern) {
1230 return true;
1231 }
1232 }
1233
1234 false
1235 }
1236
1237 fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
1238 if pattern == "*" {
1239 return true;
1240 }
1241
1242 if let Some(prefix) = pattern.strip_suffix("/*") {
1243 return rule_id.starts_with(prefix);
1244 }
1245
1246 rule_id == pattern
1247 }
1248
1249 fn matches_glob(path: &str, pattern: &str) -> bool {
1250 let pattern = pattern
1252 .replace("**", "§")
1253 .replace('*', "[^/]*")
1254 .replace('§', ".*");
1255 regex::Regex::new(&format!("^{}$", pattern))
1256 .map(|re| re.is_match(path))
1257 .unwrap_or(false)
1258 }
1259
1260 pub fn default_toml(profile: Profile) -> String {
1262 let thresholds = ProfileThresholds::for_profile(profile);
1263
1264 format!(
1265 r#"# RMA Configuration
1266# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
1267
1268# Config format version (required for future compatibility)
1269config_version = 1
1270
1271[scan]
1272# Paths to include in scanning (default: all supported files)
1273include = ["src/**", "lib/**", "scripts/**"]
1274
1275# Paths to exclude from scanning
1276exclude = [
1277 "node_modules/**",
1278 "target/**",
1279 "dist/**",
1280 "build/**",
1281 "vendor/**",
1282 "**/*.min.js",
1283 "**/*.bundle.js",
1284]
1285
1286# Maximum file size to scan (10MB default)
1287max_file_size = 10485760
1288
1289[rules]
1290# Rules to enable (wildcards supported)
1291enable = ["*"]
1292
1293# Rules to disable (takes precedence over enable)
1294disable = []
1295
1296# Global ignore paths - findings in these paths are suppressed for all rules
1297# Supports glob patterns. Uncomment to customize.
1298# ignore_paths = ["**/vendor/**", "**/generated/**"]
1299
1300# Per-rule ignore paths - suppress specific rules in specific paths
1301# Note: Security rules (command-injection, hardcoded-secret, etc.) cannot be
1302# suppressed via path ignores, only via inline comments with reason.
1303# [rules.ignore_paths_by_rule]
1304# "generic/long-function" = ["**/tests/**", "**/examples/**"]
1305# "js/console-log" = ["**/debug/**"]
1306
1307# Default test/example suppressions are automatically applied in --mode pr/ci
1308# This reduces noise from test files. Security rules are NOT suppressed.
1309
1310[profiles]
1311# Default profile: fast, balanced, or strict
1312default = "{profile}"
1313
1314[profiles.fast]
1315max_function_lines = 200
1316max_complexity = 25
1317max_cognitive_complexity = 35
1318max_file_lines = 2000
1319
1320[profiles.balanced]
1321max_function_lines = {max_function_lines}
1322max_complexity = {max_complexity}
1323max_cognitive_complexity = {max_cognitive_complexity}
1324max_file_lines = 1000
1325
1326[profiles.strict]
1327max_function_lines = 50
1328max_complexity = 10
1329max_cognitive_complexity = 15
1330max_file_lines = 500
1331
1332[rulesets]
1333# Named rule groups for targeted scanning
1334security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
1335maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
1336
1337[severity]
1338# Override severity for specific rules
1339# "generic/long-function" = "warning"
1340# "js/innerhtml-xss" = "error"
1341# "rust/unsafe-block" = "warning"
1342
1343# [[threshold_overrides]]
1344# path = "src/legacy/**"
1345# max_function_lines = 300
1346# max_complexity = 30
1347
1348# [[threshold_overrides]]
1349# path = "tests/**"
1350# disable_rules = ["generic/long-function"]
1351
1352[allow]
1353# Approved patterns that won't trigger alerts
1354settimeout_string = false
1355settimeout_function = true
1356innerhtml_paths = []
1357eval_paths = []
1358unsafe_rust_paths = []
1359approved_secrets = []
1360
1361[baseline]
1362# Baseline file for tracking legacy issues
1363file = ".rma/baseline.json"
1364# Mode: "all" or "new-only"
1365mode = "all"
1366
1367# =============================================================================
1368# ANALYSIS PROVIDERS
1369# =============================================================================
1370# RMA supports external analysis providers for extended language coverage.
1371# Providers can be enabled/disabled individually.
1372
1373[providers]
1374# List of enabled providers
1375# Default: ["rma", "oxc"] - built-in rules + native JS/TS linting
1376enabled = ["rma", "oxc"]
1377# To add PMD for Java: enabled = ["rma", "oxc", "pmd"]
1378# To add external Oxlint: enabled = ["rma", "oxc", "oxlint"]
1379
1380# -----------------------------------------------------------------------------
1381# PMD Provider - Java Static Analysis (optional)
1382# -----------------------------------------------------------------------------
1383# PMD provides comprehensive Java security and quality analysis.
1384# Requires: Java runtime and PMD installation
1385#
1386# [providers.pmd]
1387# configured = true
1388# java_path = "java" # Path to java binary
1389# pmd_path = "" # Path to pmd binary (or leave empty to use PATH)
1390# rulesets = [
1391# "category/java/security.xml",
1392# "category/java/bestpractices.xml",
1393# "category/java/errorprone.xml",
1394# ]
1395# timeout_ms = 600000 # 10 minutes timeout
1396# include_patterns = ["**/*.java"]
1397# exclude_patterns = ["**/target/**", "**/build/**", "**/generated/**"]
1398# fail_on_error = false # Continue scan if PMD fails
1399# min_priority = 5 # Report all priorities (1-5)
1400# extra_args = [] # Additional PMD CLI arguments
1401
1402# [providers.pmd.severity_map]
1403# # Map PMD priority (1-5) to RMA severity
1404# "1" = "critical"
1405# "2" = "error"
1406# "3" = "warning"
1407# "4" = "info"
1408# "5" = "info"
1409
1410# -----------------------------------------------------------------------------
1411# Oxlint Provider - Fast JavaScript/TypeScript Linting (optional)
1412# -----------------------------------------------------------------------------
1413# [providers.oxlint]
1414# configured = true
1415# binary_path = "" # Path to oxlint binary (or leave empty to use PATH)
1416# timeout_ms = 300000 # 5 minutes timeout
1417# extra_args = []
1418"#,
1419 profile = profile,
1420 max_function_lines = thresholds.max_function_lines,
1421 max_complexity = thresholds.max_complexity,
1422 max_cognitive_complexity = thresholds.max_cognitive_complexity,
1423 )
1424 }
1425}
1426
1427#[derive(Debug, Clone, Copy)]
1429pub enum AllowType {
1430 InnerHtml,
1431 Eval,
1432 UnsafeRust,
1433}
1434
1435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1437#[serde(rename_all = "kebab-case")]
1438pub enum ConfigSource {
1439 Default,
1441 ConfigFile,
1443 CliFlag,
1445}
1446
1447impl std::fmt::Display for ConfigSource {
1448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1449 match self {
1450 ConfigSource::Default => write!(f, "default"),
1451 ConfigSource::ConfigFile => write!(f, "config-file"),
1452 ConfigSource::CliFlag => write!(f, "cli-flag"),
1453 }
1454 }
1455}
1456
1457#[derive(Debug, Clone, Serialize, Deserialize)]
1464pub struct EffectiveConfig {
1465 pub config_file: Option<PathBuf>,
1467
1468 pub profile: Profile,
1470
1471 pub profile_source: ConfigSource,
1473
1474 pub thresholds: ProfileThresholds,
1476
1477 pub enabled_rules_count: usize,
1479
1480 pub disabled_rules_count: usize,
1482
1483 pub severity_overrides_count: usize,
1485
1486 pub threshold_override_paths: Vec<String>,
1488
1489 pub baseline_mode: BaselineMode,
1491
1492 pub baseline_mode_source: ConfigSource,
1494
1495 pub exclude_patterns: Vec<String>,
1497
1498 pub include_patterns: Vec<String>,
1500}
1501
1502impl EffectiveConfig {
1503 pub fn resolve(
1505 toml_config: Option<&RmaTomlConfig>,
1506 config_path: Option<&Path>,
1507 cli_profile: Option<Profile>,
1508 cli_baseline_mode: bool,
1509 ) -> Self {
1510 let (profile, profile_source) = if let Some(p) = cli_profile {
1512 (p, ConfigSource::CliFlag)
1513 } else if let Some(cfg) = toml_config {
1514 (cfg.profiles.default, ConfigSource::ConfigFile)
1515 } else {
1516 (Profile::default(), ConfigSource::Default)
1517 };
1518
1519 let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
1521 (BaselineMode::NewOnly, ConfigSource::CliFlag)
1522 } else if let Some(cfg) = toml_config {
1523 (cfg.baseline.mode, ConfigSource::ConfigFile)
1524 } else {
1525 (BaselineMode::default(), ConfigSource::Default)
1526 };
1527
1528 let thresholds = toml_config
1530 .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
1531 .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
1532
1533 let (enabled_rules_count, disabled_rules_count) = toml_config
1535 .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
1536 .unwrap_or((1, 0)); let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
1540
1541 let threshold_override_paths = toml_config
1543 .map(|cfg| {
1544 cfg.threshold_overrides
1545 .iter()
1546 .map(|o| o.path.clone())
1547 .collect()
1548 })
1549 .unwrap_or_default();
1550
1551 let exclude_patterns = toml_config
1553 .map(|cfg| cfg.scan.exclude.clone())
1554 .unwrap_or_default();
1555
1556 let include_patterns = toml_config
1557 .map(|cfg| cfg.scan.include.clone())
1558 .unwrap_or_default();
1559
1560 Self {
1561 config_file: config_path.map(|p| p.to_path_buf()),
1562 profile,
1563 profile_source,
1564 thresholds,
1565 enabled_rules_count,
1566 disabled_rules_count,
1567 severity_overrides_count,
1568 threshold_override_paths,
1569 baseline_mode,
1570 baseline_mode_source,
1571 exclude_patterns,
1572 include_patterns,
1573 }
1574 }
1575
1576 pub fn to_text(&self) -> String {
1578 let mut out = String::new();
1579
1580 out.push_str("Effective Configuration\n");
1581 out.push_str("═══════════════════════════════════════════════════════════\n\n");
1582
1583 out.push_str(" Config file: ");
1585 match &self.config_file {
1586 Some(p) => out.push_str(&format!("{}\n", p.display())),
1587 None => out.push_str("(none - using defaults)\n"),
1588 }
1589
1590 out.push_str(&format!(
1592 " Profile: {} (from {})\n",
1593 self.profile, self.profile_source
1594 ));
1595
1596 out.push_str("\n Thresholds:\n");
1598 out.push_str(&format!(
1599 " max_function_lines: {}\n",
1600 self.thresholds.max_function_lines
1601 ));
1602 out.push_str(&format!(
1603 " max_complexity: {}\n",
1604 self.thresholds.max_complexity
1605 ));
1606 out.push_str(&format!(
1607 " max_cognitive_complexity: {}\n",
1608 self.thresholds.max_cognitive_complexity
1609 ));
1610 out.push_str(&format!(
1611 " max_file_lines: {}\n",
1612 self.thresholds.max_file_lines
1613 ));
1614
1615 out.push_str("\n Rules:\n");
1617 out.push_str(&format!(
1618 " enabled patterns: {}\n",
1619 self.enabled_rules_count
1620 ));
1621 out.push_str(&format!(
1622 " disabled patterns: {}\n",
1623 self.disabled_rules_count
1624 ));
1625 out.push_str(&format!(
1626 " severity overrides: {}\n",
1627 self.severity_overrides_count
1628 ));
1629
1630 if !self.threshold_override_paths.is_empty() {
1632 out.push_str("\n Threshold overrides:\n");
1633 for path in &self.threshold_override_paths {
1634 out.push_str(&format!(" - {}\n", path));
1635 }
1636 }
1637
1638 out.push_str(&format!(
1640 "\n Baseline mode: {:?} (from {})\n",
1641 self.baseline_mode, self.baseline_mode_source
1642 ));
1643
1644 out
1645 }
1646
1647 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1649 serde_json::to_string_pretty(self)
1650 }
1651}
1652
1653#[derive(Debug, Clone)]
1655pub struct ConfigWarning {
1656 pub level: WarningLevel,
1657 pub message: String,
1658}
1659
1660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1661pub enum WarningLevel {
1662 Warning,
1663 Error,
1664}
1665
1666#[derive(Debug, Clone, PartialEq, Eq)]
1668pub struct InlineSuppression {
1669 pub rule_id: String,
1671 pub reason: Option<String>,
1673 pub line: usize,
1675 pub suppression_type: SuppressionType,
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1681pub enum SuppressionType {
1682 NextLine,
1684 Block,
1686}
1687
1688impl InlineSuppression {
1689 pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1696 let trimmed = line.trim();
1697
1698 let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1700 rest.trim()
1701 } else if let Some(rest) = trimmed.strip_prefix('#') {
1702 rest.trim()
1703 } else {
1704 return None;
1705 };
1706
1707 if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1709 return Self::parse_suppression_body(
1710 rest.trim(),
1711 line_number,
1712 SuppressionType::NextLine,
1713 );
1714 }
1715
1716 if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1718 return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1719 }
1720
1721 None
1722 }
1723
1724 fn parse_suppression_body(
1725 body: &str,
1726 line_number: usize,
1727 suppression_type: SuppressionType,
1728 ) -> Option<Self> {
1729 if body.is_empty() {
1730 return None;
1731 }
1732
1733 let mut parts = body.splitn(2, ' ');
1735 let rule_id = parts.next()?.trim().to_string();
1736
1737 if rule_id.is_empty() {
1738 return None;
1739 }
1740
1741 let reason = parts.next().and_then(|rest| {
1742 if let Some(start) = rest.find("reason=\"") {
1744 let after_quote = &rest[start + 8..];
1745 if let Some(end) = after_quote.find('"') {
1746 return Some(after_quote[..end].to_string());
1747 }
1748 }
1749 None
1750 });
1751
1752 Some(Self {
1753 rule_id,
1754 reason,
1755 line: line_number,
1756 suppression_type,
1757 })
1758 }
1759
1760 pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1762 if self.rule_id != rule_id && self.rule_id != "*" {
1763 return false;
1764 }
1765
1766 match self.suppression_type {
1767 SuppressionType::NextLine => finding_line == self.line + 1,
1768 SuppressionType::Block => finding_line >= self.line,
1769 }
1770 }
1771
1772 pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1774 if require_reason && self.reason.is_none() {
1775 return Err(format!(
1776 "Suppression for '{}' at line {} requires a reason in strict profile",
1777 self.rule_id, self.line
1778 ));
1779 }
1780 Ok(())
1781 }
1782}
1783
1784pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1786 content
1787 .lines()
1788 .enumerate()
1789 .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1790 .collect()
1791}
1792
1793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1805pub struct Fingerprint(String);
1806
1807impl Fingerprint {
1808 pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1810 use sha2::{Digest, Sha256};
1811
1812 let mut hasher = Sha256::new();
1813
1814 hasher.update(rule_id.as_bytes());
1816 hasher.update(b"|");
1817
1818 let normalized_path = file_path
1820 .to_string_lossy()
1821 .replace('\\', "/")
1822 .to_lowercase();
1823 hasher.update(normalized_path.as_bytes());
1824 hasher.update(b"|");
1825
1826 let normalized_snippet = Self::normalize_snippet(snippet);
1828 hasher.update(normalized_snippet.as_bytes());
1829
1830 let hash = hasher.finalize();
1831 Self(format!("sha256:{:x}", hash))
1832 }
1833
1834 fn normalize_snippet(snippet: &str) -> String {
1836 snippet
1837 .split_whitespace()
1838 .collect::<Vec<_>>()
1839 .join(" ")
1840 .trim()
1841 .to_string()
1842 }
1843
1844 pub fn as_str(&self) -> &str {
1846 &self.0
1847 }
1848
1849 pub fn from_string(s: String) -> Self {
1851 Self(s)
1852 }
1853}
1854
1855impl std::fmt::Display for Fingerprint {
1856 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1857 write!(f, "{}", self.0)
1858 }
1859}
1860
1861impl From<Fingerprint> for String {
1862 fn from(fp: Fingerprint) -> String {
1863 fp.0
1864 }
1865}
1866
1867#[derive(Debug, Clone, Serialize, Deserialize)]
1869pub struct BaselineEntry {
1870 pub rule_id: String,
1871 pub file: PathBuf,
1872 #[serde(default)]
1873 pub line: usize,
1874 pub fingerprint: String,
1875 pub first_seen: String,
1876 #[serde(default)]
1877 pub suppressed: bool,
1878 #[serde(default)]
1879 pub comment: Option<String>,
1880}
1881
1882#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1884pub struct Baseline {
1885 pub version: String,
1886 pub created: String,
1887 pub entries: Vec<BaselineEntry>,
1888}
1889
1890impl Baseline {
1891 pub fn new() -> Self {
1892 Self {
1893 version: "1.0".to_string(),
1894 created: chrono::Utc::now().to_rfc3339(),
1895 entries: Vec::new(),
1896 }
1897 }
1898
1899 pub fn load(path: &Path) -> Result<Self, String> {
1900 let content = std::fs::read_to_string(path)
1901 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1902
1903 serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1904 }
1905
1906 pub fn save(&self, path: &Path) -> Result<(), String> {
1907 if let Some(parent) = path.parent() {
1908 std::fs::create_dir_all(parent)
1909 .map_err(|e| format!("Failed to create directory: {}", e))?;
1910 }
1911
1912 let content = serde_json::to_string_pretty(self)
1913 .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1914
1915 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1916 }
1917
1918 pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1920 self.entries
1921 .iter()
1922 .any(|e| e.fingerprint == fingerprint.as_str())
1923 }
1924
1925 pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1927 self.entries
1928 .iter()
1929 .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1930 }
1931
1932 pub fn add_with_fingerprint(
1934 &mut self,
1935 rule_id: String,
1936 file: PathBuf,
1937 line: usize,
1938 fingerprint: Fingerprint,
1939 ) {
1940 if !self.contains_fingerprint(&fingerprint) {
1941 self.entries.push(BaselineEntry {
1942 rule_id,
1943 file,
1944 line,
1945 fingerprint: fingerprint.into(),
1946 first_seen: chrono::Utc::now().to_rfc3339(),
1947 suppressed: false,
1948 comment: None,
1949 });
1950 }
1951 }
1952
1953 pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1955 if !self.contains(&rule_id, &file, &fingerprint) {
1956 self.entries.push(BaselineEntry {
1957 rule_id,
1958 file,
1959 line,
1960 fingerprint,
1961 first_seen: chrono::Utc::now().to_rfc3339(),
1962 suppressed: false,
1963 comment: None,
1964 });
1965 }
1966 }
1967}
1968
1969#[derive(Debug, Clone, Serialize, Deserialize)]
1975pub struct SuppressionResult {
1976 pub suppressed: bool,
1978 pub reason: Option<String>,
1980 pub source: Option<SuppressionSource>,
1982 pub location: Option<String>,
1984}
1985
1986impl SuppressionResult {
1987 pub fn not_suppressed() -> Self {
1989 Self {
1990 suppressed: false,
1991 reason: None,
1992 source: None,
1993 location: None,
1994 }
1995 }
1996
1997 pub fn suppressed(source: SuppressionSource, reason: String, location: String) -> Self {
1999 Self {
2000 suppressed: true,
2001 reason: Some(reason),
2002 source: Some(source),
2003 location: Some(location),
2004 }
2005 }
2006}
2007
2008#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2010#[serde(rename_all = "kebab-case")]
2011pub enum SuppressionSource {
2012 Inline,
2014 PathGlobal,
2016 PathRule,
2018 Preset,
2020 Baseline,
2022 Database,
2024}
2025
2026impl std::fmt::Display for SuppressionSource {
2027 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2028 match self {
2029 SuppressionSource::Inline => write!(f, "inline"),
2030 SuppressionSource::PathGlobal => write!(f, "path-global"),
2031 SuppressionSource::PathRule => write!(f, "path-rule"),
2032 SuppressionSource::Preset => write!(f, "preset"),
2033 SuppressionSource::Baseline => write!(f, "baseline"),
2034 SuppressionSource::Database => write!(f, "database"),
2035 }
2036 }
2037}
2038
2039pub struct SuppressionEngine {
2049 global_ignore_paths: Vec<String>,
2051 rule_ignore_paths: HashMap<String, Vec<String>>,
2053 use_default_presets: bool,
2055 baseline: Option<Baseline>,
2057 global_patterns: Vec<regex::Regex>,
2059 rule_patterns: HashMap<String, Vec<regex::Regex>>,
2061 test_patterns: Vec<regex::Regex>,
2063 example_patterns: Vec<regex::Regex>,
2065 suppression_store: Option<std::sync::Arc<crate::suppression::SuppressionStore>>,
2067}
2068
2069impl SuppressionEngine {
2070 pub fn new(rules_config: &RulesConfig, use_default_presets: bool) -> Self {
2072 let global_patterns = rules_config
2073 .ignore_paths
2074 .iter()
2075 .filter_map(|p| Self::compile_glob(p))
2076 .collect();
2077
2078 let mut rule_patterns = HashMap::new();
2079 for (rule_id, paths) in &rules_config.ignore_paths_by_rule {
2080 let patterns: Vec<regex::Regex> =
2081 paths.iter().filter_map(|p| Self::compile_glob(p)).collect();
2082 if !patterns.is_empty() {
2083 rule_patterns.insert(rule_id.clone(), patterns);
2084 }
2085 }
2086
2087 let test_patterns = if use_default_presets {
2088 DEFAULT_TEST_IGNORE_PATHS
2089 .iter()
2090 .filter_map(|p| Self::compile_glob(p))
2091 .collect()
2092 } else {
2093 Vec::new()
2094 };
2095
2096 let example_patterns = if use_default_presets {
2097 DEFAULT_EXAMPLE_IGNORE_PATHS
2098 .iter()
2099 .filter_map(|p| Self::compile_glob(p))
2100 .collect()
2101 } else {
2102 Vec::new()
2103 };
2104
2105 Self {
2106 global_ignore_paths: rules_config.ignore_paths.clone(),
2107 rule_ignore_paths: rules_config.ignore_paths_by_rule.clone(),
2108 use_default_presets,
2109 baseline: None,
2110 global_patterns,
2111 rule_patterns,
2112 test_patterns,
2113 example_patterns,
2114 suppression_store: None,
2115 }
2116 }
2117
2118 pub fn with_defaults_only() -> Self {
2120 Self::new(&RulesConfig::default(), true)
2121 }
2122
2123 pub fn with_baseline(mut self, baseline: Baseline) -> Self {
2125 self.baseline = Some(baseline);
2126 self
2127 }
2128
2129 pub fn with_store(mut self, store: crate::suppression::SuppressionStore) -> Self {
2131 self.suppression_store = Some(std::sync::Arc::new(store));
2132 self
2133 }
2134
2135 pub fn with_store_ref(
2137 mut self,
2138 store: std::sync::Arc<crate::suppression::SuppressionStore>,
2139 ) -> Self {
2140 self.suppression_store = Some(store);
2141 self
2142 }
2143
2144 pub fn store(&self) -> Option<&crate::suppression::SuppressionStore> {
2146 self.suppression_store.as_ref().map(|s| s.as_ref())
2147 }
2148
2149 fn compile_glob(pattern: &str) -> Option<regex::Regex> {
2155 let regex_pattern = pattern
2156 .replace('.', r"\.")
2157 .replace("**", "§")
2158 .replace('*', "[^/]*")
2159 .replace('§', ".*");
2160
2161 let regex_pattern = if let Some(rest) = regex_pattern.strip_prefix(".*/") {
2164 format!("(^|.*/){}", rest)
2166 } else if regex_pattern.starts_with(".*") {
2167 regex_pattern
2169 } else {
2170 format!("^{}", regex_pattern)
2172 };
2173
2174 regex::Regex::new(&format!("(?i){}$", regex_pattern)).ok()
2175 }
2176
2177 fn matches_patterns(path: &str, patterns: &[regex::Regex]) -> bool {
2179 let normalized = path.replace('\\', "/");
2180 patterns.iter().any(|re| re.is_match(&normalized))
2181 }
2182
2183 pub fn is_always_enabled(rule_id: &str) -> bool {
2185 RULES_ALWAYS_ENABLED.iter().any(|r| {
2186 rule_id == *r
2187 || rule_id.starts_with(&format!("{}:", r))
2188 || r.ends_with("*") && rule_id.starts_with(r.trim_end_matches('*'))
2189 })
2190 }
2191
2192 pub fn check(
2203 &self,
2204 rule_id: &str,
2205 file_path: &Path,
2206 finding_line: usize,
2207 inline_suppressions: &[InlineSuppression],
2208 fingerprint: Option<&str>,
2209 ) -> SuppressionResult {
2210 let path_str = file_path.to_string_lossy();
2211
2212 for suppression in inline_suppressions {
2214 if suppression.applies_to(finding_line, rule_id) {
2215 let reason = suppression
2216 .reason
2217 .clone()
2218 .unwrap_or_else(|| "No reason provided".to_string());
2219 return SuppressionResult::suppressed(
2220 SuppressionSource::Inline,
2221 reason,
2222 format!("line {}", suppression.line),
2223 );
2224 }
2225 }
2226
2227 let is_always_enabled = Self::is_always_enabled(rule_id);
2229
2230 if !is_always_enabled {
2231 if Self::matches_patterns(&path_str, &self.global_patterns) {
2233 for (i, pattern) in self.global_ignore_paths.iter().enumerate() {
2234 if let Some(re) = self.global_patterns.get(i)
2235 && re.is_match(&path_str.replace('\\', "/"))
2236 {
2237 return SuppressionResult::suppressed(
2238 SuppressionSource::PathGlobal,
2239 format!("Path matches global ignore pattern: {}", pattern),
2240 pattern.clone(),
2241 );
2242 }
2243 }
2244 }
2245
2246 if let Some(patterns) = self.rule_patterns.get(rule_id)
2248 && Self::matches_patterns(&path_str, patterns)
2249 && let Some(rule_paths) = self.rule_ignore_paths.get(rule_id)
2250 {
2251 for (i, pattern) in rule_paths.iter().enumerate() {
2252 if let Some(re) = patterns.get(i)
2253 && re.is_match(&path_str.replace('\\', "/"))
2254 {
2255 return SuppressionResult::suppressed(
2256 SuppressionSource::PathRule,
2257 format!("Path matches rule-specific ignore pattern: {}", pattern),
2258 format!("{}:{}", rule_id, pattern),
2259 );
2260 }
2261 }
2262 }
2263
2264 for (pattern_rule_id, patterns) in &self.rule_patterns {
2266 if pattern_rule_id.ends_with("/*") {
2267 let prefix = pattern_rule_id.trim_end_matches("/*");
2268 if rule_id.starts_with(prefix)
2269 && Self::matches_patterns(&path_str, patterns)
2270 && let Some(rule_paths) = self.rule_ignore_paths.get(pattern_rule_id)
2271 && let Some(pattern) = rule_paths.first()
2272 {
2273 return SuppressionResult::suppressed(
2274 SuppressionSource::PathRule,
2275 format!("Path matches rule-specific ignore pattern: {}", pattern),
2276 format!("{}:{}", pattern_rule_id, pattern),
2277 );
2278 }
2279 }
2280 }
2281
2282 if self.use_default_presets {
2284 if Self::matches_patterns(&path_str, &self.test_patterns) {
2285 return SuppressionResult::suppressed(
2286 SuppressionSource::Preset,
2287 "File is in test directory (suppressed by default preset)".to_string(),
2288 "test-preset".to_string(),
2289 );
2290 }
2291 if Self::matches_patterns(&path_str, &self.example_patterns) {
2292 return SuppressionResult::suppressed(
2293 SuppressionSource::Preset,
2294 "File is in example/fixture directory (suppressed by default preset)"
2295 .to_string(),
2296 "example-preset".to_string(),
2297 );
2298 }
2299 }
2300 }
2301
2302 if let Some(ref baseline) = self.baseline
2304 && let Some(fp) = fingerprint
2305 {
2306 let fingerprint_obj = Fingerprint::from_string(fp.to_string());
2307 if baseline.contains_fingerprint(&fingerprint_obj) {
2308 return SuppressionResult::suppressed(
2309 SuppressionSource::Baseline,
2310 "Finding is in baseline".to_string(),
2311 "baseline".to_string(),
2312 );
2313 }
2314 }
2315
2316 if let Some(ref store) = self.suppression_store
2318 && let Some(fp) = fingerprint
2319 && let Ok(Some(entry)) = store.is_suppressed(fp)
2320 {
2321 return SuppressionResult::suppressed(
2322 SuppressionSource::Database,
2323 entry.reason.clone(),
2324 format!("database:{}", entry.id),
2325 );
2326 }
2327
2328 SuppressionResult::not_suppressed()
2329 }
2330
2331 pub fn should_skip_path(&self, file_path: &Path) -> bool {
2336 let path_str = file_path.to_string_lossy();
2337
2338 if Self::matches_patterns(&path_str, &self.global_patterns) {
2340 return true;
2341 }
2342
2343 if self.use_default_presets {
2345 false
2350 } else {
2351 false
2352 }
2353 }
2354
2355 pub fn add_suppression_metadata(
2357 properties: &mut HashMap<String, serde_json::Value>,
2358 result: &SuppressionResult,
2359 ) {
2360 if result.suppressed {
2361 properties.insert("suppressed".to_string(), serde_json::json!(true));
2362 if let Some(ref reason) = result.reason {
2363 properties.insert("suppression_reason".to_string(), serde_json::json!(reason));
2364 }
2365 if let Some(ref source) = result.source {
2366 properties.insert(
2367 "suppression_source".to_string(),
2368 serde_json::json!(source.to_string()),
2369 );
2370
2371 if *source == SuppressionSource::Database
2373 && let Some(ref location) = result.location
2374 && let Some(id) = location.strip_prefix("database:")
2375 {
2376 properties.insert("suppression_id".to_string(), serde_json::json!(id));
2377 }
2378 }
2379 if let Some(ref location) = result.location {
2380 properties.insert(
2381 "suppression_location".to_string(),
2382 serde_json::json!(location),
2383 );
2384 }
2385 }
2386 }
2387}
2388
2389impl Default for SuppressionEngine {
2390 fn default() -> Self {
2391 Self::new(&RulesConfig::default(), false)
2392 }
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397 use super::*;
2398 use std::str::FromStr;
2399
2400 #[test]
2401 fn test_profile_parsing() {
2402 assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
2403 assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
2404 assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
2405 assert!(Profile::from_str("unknown").is_err());
2406 }
2407
2408 #[test]
2409 fn test_rule_matching() {
2410 assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
2411 assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
2412 assert!(!RmaTomlConfig::matches_pattern(
2413 "generic/long",
2414 "security/*"
2415 ));
2416 assert!(RmaTomlConfig::matches_pattern(
2417 "security/xss",
2418 "security/xss"
2419 ));
2420 }
2421
2422 #[test]
2423 fn test_default_config_parses() {
2424 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2425 let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
2426 assert_eq!(config.profiles.default, Profile::Balanced);
2427 }
2428
2429 #[test]
2430 fn test_thresholds_for_profile() {
2431 let fast = ProfileThresholds::for_profile(Profile::Fast);
2432 let strict = ProfileThresholds::for_profile(Profile::Strict);
2433
2434 assert!(fast.max_function_lines > strict.max_function_lines);
2435 assert!(fast.max_complexity > strict.max_complexity);
2436 }
2437
2438 #[test]
2439 fn test_fingerprint_stable_across_line_changes() {
2440 let fp1 = Fingerprint::generate(
2442 "js/xss-sink",
2443 Path::new("src/app.js"),
2444 "element.textContent = userInput;",
2445 );
2446 let fp2 = Fingerprint::generate(
2447 "js/xss-sink",
2448 Path::new("src/app.js"),
2449 "element.textContent = userInput;",
2450 );
2451
2452 assert_eq!(fp1, fp2);
2453 }
2454
2455 #[test]
2456 fn test_fingerprint_stable_with_whitespace_changes() {
2457 let fp1 = Fingerprint::generate(
2459 "generic/long-function",
2460 Path::new("src/utils.rs"),
2461 "fn very_long_function() {",
2462 );
2463 let fp2 = Fingerprint::generate(
2464 "generic/long-function",
2465 Path::new("src/utils.rs"),
2466 "fn very_long_function() {",
2467 );
2468 let fp3 = Fingerprint::generate(
2469 "generic/long-function",
2470 Path::new("src/utils.rs"),
2471 " fn very_long_function() { ",
2472 );
2473
2474 assert_eq!(fp1, fp2);
2475 assert_eq!(fp2, fp3);
2476 }
2477
2478 #[test]
2479 fn test_fingerprint_different_for_different_rules() {
2480 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2481 let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
2482
2483 assert_ne!(fp1, fp2);
2484 }
2485
2486 #[test]
2487 fn test_fingerprint_different_for_different_files() {
2488 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
2489 let fp2 =
2490 Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
2491
2492 assert_ne!(fp1, fp2);
2493 }
2494
2495 #[test]
2496 fn test_fingerprint_path_normalization() {
2497 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
2499 let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
2500
2501 assert_eq!(fp1, fp2);
2502 }
2503
2504 #[test]
2505 fn test_effective_config_precedence() {
2506 let toml_config = RmaTomlConfig::default();
2508 let effective = EffectiveConfig::resolve(
2509 Some(&toml_config),
2510 Some(Path::new("rma.toml")),
2511 Some(Profile::Strict), false,
2513 );
2514
2515 assert_eq!(effective.profile, Profile::Strict);
2516 assert_eq!(effective.profile_source, ConfigSource::CliFlag);
2517 }
2518
2519 #[test]
2520 fn test_effective_config_defaults() {
2521 let effective = EffectiveConfig::resolve(None, None, None, false);
2523
2524 assert_eq!(effective.profile, Profile::Balanced);
2525 assert_eq!(effective.profile_source, ConfigSource::Default);
2526 assert!(effective.config_file.is_none());
2527 }
2528
2529 #[test]
2530 fn test_effective_config_from_file() {
2531 let mut toml_config = RmaTomlConfig::default();
2533 toml_config.profiles.default = Profile::Fast;
2534
2535 let effective =
2536 EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
2537
2538 assert_eq!(effective.profile, Profile::Fast);
2539 assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
2540 }
2541
2542 #[test]
2543 fn test_config_version_missing_warns() {
2544 let toml = r#"
2545[profiles]
2546default = "balanced"
2547"#;
2548 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2549 assert!(config.config_version.is_none());
2550 assert!(!config.has_version());
2551 assert_eq!(config.effective_version(), 1);
2552
2553 let warnings = config.validate();
2554 assert!(
2555 warnings
2556 .iter()
2557 .any(|w| w.message.contains("Missing 'config_version'"))
2558 );
2559 }
2560
2561 #[test]
2562 fn test_config_version_1_ok() {
2563 let toml = r#"
2564config_version = 1
2565
2566[profiles]
2567default = "balanced"
2568"#;
2569 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2570 assert_eq!(config.config_version, Some(1));
2571 assert!(config.has_version());
2572 assert_eq!(config.effective_version(), 1);
2573
2574 let warnings = config.validate();
2575 assert!(
2576 !warnings
2577 .iter()
2578 .any(|w| w.message.contains("config_version"))
2579 );
2580 }
2581
2582 #[test]
2583 fn test_config_version_999_fails() {
2584 let toml = r#"
2585config_version = 999
2586
2587[profiles]
2588default = "balanced"
2589"#;
2590 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2591 assert_eq!(config.config_version, Some(999));
2592
2593 let warnings = config.validate();
2594 let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
2595 assert!(error.is_some());
2596 assert!(
2597 error
2598 .unwrap()
2599 .message
2600 .contains("Unsupported config version: 999")
2601 );
2602 }
2603
2604 #[test]
2605 fn test_default_toml_includes_version() {
2606 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2607 assert!(toml.contains("config_version = 1"));
2608
2609 let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
2611 assert_eq!(config.config_version, Some(1));
2612 }
2613
2614 #[test]
2615 fn test_ruleset_security() {
2616 let toml = r#"
2617config_version = 1
2618
2619[rulesets]
2620security = ["js/innerhtml-xss", "js/timer-string-eval"]
2621maintainability = ["generic/long-function"]
2622
2623[rules]
2624enable = ["*"]
2625"#;
2626 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2627
2628 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2630 assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2631 assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
2632
2633 assert!(config.is_rule_enabled("generic/long-function"));
2635 }
2636
2637 #[test]
2638 fn test_ruleset_with_disable() {
2639 let toml = r#"
2640config_version = 1
2641
2642[rulesets]
2643security = ["js/innerhtml-xss", "js/timer-string-eval"]
2644
2645[rules]
2646enable = ["*"]
2647disable = ["js/timer-string-eval"]
2648"#;
2649 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2650
2651 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
2653 assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
2654 }
2655
2656 #[test]
2657 fn test_get_ruleset_names() {
2658 let toml = r#"
2659config_version = 1
2660
2661[rulesets]
2662security = ["js/innerhtml-xss"]
2663maintainability = ["generic/long-function"]
2664"#;
2665 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
2666 let names = config.get_ruleset_names();
2667
2668 assert!(names.contains(&"security".to_string()));
2669 assert!(names.contains(&"maintainability".to_string()));
2670 }
2671
2672 #[test]
2673 fn test_default_toml_includes_rulesets() {
2674 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
2675 assert!(toml.contains("[rulesets]"));
2676 assert!(toml.contains("security = "));
2677 assert!(toml.contains("maintainability = "));
2678 }
2679
2680 #[test]
2681 fn test_inline_suppression_next_line() {
2682 let suppression = InlineSuppression::parse(
2683 "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
2684 10,
2685 );
2686 assert!(suppression.is_some());
2687 let s = suppression.unwrap();
2688 assert_eq!(s.rule_id, "js/innerhtml-xss");
2689 assert_eq!(s.reason, Some("sanitized input".to_string()));
2690 assert_eq!(s.line, 10);
2691 assert_eq!(s.suppression_type, SuppressionType::NextLine);
2692
2693 assert!(s.applies_to(11, "js/innerhtml-xss"));
2695 assert!(!s.applies_to(12, "js/innerhtml-xss"));
2697 assert!(!s.applies_to(11, "js/console-log"));
2699 }
2700
2701 #[test]
2702 fn test_inline_suppression_block() {
2703 let suppression = InlineSuppression::parse(
2704 "// rma-ignore generic/long-function reason=\"legacy code\"",
2705 5,
2706 );
2707 assert!(suppression.is_some());
2708 let s = suppression.unwrap();
2709 assert_eq!(s.rule_id, "generic/long-function");
2710 assert_eq!(s.suppression_type, SuppressionType::Block);
2711
2712 assert!(s.applies_to(5, "generic/long-function"));
2714 assert!(s.applies_to(10, "generic/long-function"));
2715 assert!(s.applies_to(100, "generic/long-function"));
2716 }
2717
2718 #[test]
2719 fn test_inline_suppression_without_reason() {
2720 let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
2721 assert!(suppression.is_some());
2722 let s = suppression.unwrap();
2723 assert_eq!(s.rule_id, "js/console-log");
2724 assert!(s.reason.is_none());
2725 }
2726
2727 #[test]
2728 fn test_inline_suppression_python_style() {
2729 let suppression = InlineSuppression::parse(
2730 "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
2731 3,
2732 );
2733 assert!(suppression.is_some());
2734 let s = suppression.unwrap();
2735 assert_eq!(s.rule_id, "python/hardcoded-secret");
2736 assert_eq!(s.reason, Some("test data".to_string()));
2737 }
2738
2739 #[test]
2740 fn test_inline_suppression_validation_strict() {
2741 let s = InlineSuppression {
2742 rule_id: "js/xss".to_string(),
2743 reason: None,
2744 line: 1,
2745 suppression_type: SuppressionType::NextLine,
2746 };
2747
2748 assert!(s.validate(true).is_err());
2750 assert!(s.validate(false).is_ok());
2752
2753 let s_with_reason = InlineSuppression {
2754 rule_id: "js/xss".to_string(),
2755 reason: Some("approved".to_string()),
2756 line: 1,
2757 suppression_type: SuppressionType::NextLine,
2758 };
2759
2760 assert!(s_with_reason.validate(true).is_ok());
2762 assert!(s_with_reason.validate(false).is_ok());
2763 }
2764
2765 #[test]
2766 fn test_parse_inline_suppressions() {
2767 let content = r#"
2768function foo() {
2769 // rma-ignore-next-line js/console-log reason="debugging"
2770 console.log("test");
2771
2772 // rma-ignore generic/long-function reason="complex algorithm"
2773 // ... lots of code ...
2774}
2775"#;
2776 let suppressions = parse_inline_suppressions(content);
2777 assert_eq!(suppressions.len(), 2);
2778 assert_eq!(suppressions[0].rule_id, "js/console-log");
2779 assert_eq!(suppressions[1].rule_id, "generic/long-function");
2780 }
2781
2782 #[test]
2783 fn test_suppression_does_not_affect_other_rules() {
2784 let suppression = InlineSuppression::parse(
2785 "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
2786 10,
2787 )
2788 .unwrap();
2789
2790 assert!(suppression.applies_to(11, "js/innerhtml-xss"));
2792 assert!(!suppression.applies_to(11, "js/console-log"));
2794 assert!(!suppression.applies_to(11, "generic/long-function"));
2795 }
2796
2797 #[test]
2802 fn test_suppression_engine_global_path_ignore() {
2803 let rules_config = RulesConfig {
2804 ignore_paths: vec!["**/vendor/**".to_string(), "**/generated/**".to_string()],
2805 ..Default::default()
2806 };
2807
2808 let engine = SuppressionEngine::new(&rules_config, false);
2809
2810 let result = engine.check(
2812 "generic/long-function",
2813 Path::new("src/vendor/lib.js"),
2814 10,
2815 &[],
2816 None,
2817 );
2818 assert!(result.suppressed);
2819 assert_eq!(result.source, Some(SuppressionSource::PathGlobal));
2820
2821 let result = engine.check(
2823 "generic/long-function",
2824 Path::new("src/app.js"),
2825 10,
2826 &[],
2827 None,
2828 );
2829 assert!(!result.suppressed);
2830 }
2831
2832 #[test]
2833 fn test_suppression_engine_per_rule_path_ignore() {
2834 let rules_config = RulesConfig {
2835 ignore_paths_by_rule: HashMap::from([(
2836 "generic/long-function".to_string(),
2837 vec!["**/tests/**".to_string()],
2838 )]),
2839 ..Default::default()
2840 };
2841
2842 let engine = SuppressionEngine::new(&rules_config, false);
2843
2844 let result = engine.check(
2846 "generic/long-function",
2847 Path::new("src/tests/test_app.js"),
2848 10,
2849 &[],
2850 None,
2851 );
2852 assert!(result.suppressed);
2853 assert_eq!(result.source, Some(SuppressionSource::PathRule));
2854
2855 let result = engine.check(
2857 "js/console-log",
2858 Path::new("src/tests/test_app.js"),
2859 10,
2860 &[],
2861 None,
2862 );
2863 assert!(!result.suppressed);
2864 }
2865
2866 #[test]
2867 fn test_suppression_engine_inline_suppression() {
2868 let rules_config = RulesConfig::default();
2869 let engine = SuppressionEngine::new(&rules_config, false);
2870
2871 let inline_suppressions = vec![InlineSuppression {
2872 rule_id: "js/console-log".to_string(),
2873 reason: Some("debug output".to_string()),
2874 line: 10,
2875 suppression_type: SuppressionType::NextLine,
2876 }];
2877
2878 let result = engine.check(
2880 "js/console-log",
2881 Path::new("src/app.js"),
2882 11, &inline_suppressions,
2884 None,
2885 );
2886 assert!(result.suppressed);
2887 assert_eq!(result.source, Some(SuppressionSource::Inline));
2888 assert_eq!(result.reason, Some("debug output".to_string()));
2889
2890 let result = engine.check(
2892 "js/console-log",
2893 Path::new("src/app.js"),
2894 12,
2895 &inline_suppressions,
2896 None,
2897 );
2898 assert!(!result.suppressed);
2899 }
2900
2901 #[test]
2902 fn test_suppression_engine_default_presets() {
2903 let rules_config = RulesConfig::default();
2904 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
2908 "generic/long-function",
2909 Path::new("src/tests/test_app.rs"),
2910 10,
2911 &[],
2912 None,
2913 );
2914 assert!(result.suppressed);
2915 assert_eq!(result.source, Some(SuppressionSource::Preset));
2916
2917 let result = engine.check(
2919 "js/console-log",
2920 Path::new("src/app.test.ts"),
2921 10,
2922 &[],
2923 None,
2924 );
2925 assert!(result.suppressed);
2926
2927 let result = engine.check(
2929 "generic/long-function",
2930 Path::new("examples/demo.rs"),
2931 10,
2932 &[],
2933 None,
2934 );
2935 assert!(result.suppressed);
2936
2937 let result = engine.check(
2939 "generic/long-function",
2940 Path::new("src/lib.rs"),
2941 10,
2942 &[],
2943 None,
2944 );
2945 assert!(!result.suppressed);
2946 }
2947
2948 #[test]
2949 fn test_suppression_engine_security_rules_not_suppressed_by_preset() {
2950 let rules_config = RulesConfig::default();
2951 let engine = SuppressionEngine::new(&rules_config, true); let result = engine.check(
2955 "rust/command-injection",
2956 Path::new("src/tests/test_app.rs"),
2957 10,
2958 &[],
2959 None,
2960 );
2961 assert!(!result.suppressed);
2962
2963 let result = engine.check(
2964 "generic/hardcoded-secret",
2965 Path::new("examples/demo.py"),
2966 10,
2967 &[],
2968 None,
2969 );
2970 assert!(!result.suppressed);
2971
2972 let result = engine.check(
2973 "python/shell-injection",
2974 Path::new("tests/test_shell.py"),
2975 10,
2976 &[],
2977 None,
2978 );
2979 assert!(!result.suppressed);
2980 }
2981
2982 #[test]
2983 fn test_suppression_engine_security_rules_can_be_suppressed_inline() {
2984 let rules_config = RulesConfig::default();
2985 let engine = SuppressionEngine::new(&rules_config, true);
2986
2987 let inline_suppressions = vec![InlineSuppression {
2988 rule_id: "rust/command-injection".to_string(),
2989 reason: Some("sanitized input validated upstream".to_string()),
2990 line: 10,
2991 suppression_type: SuppressionType::NextLine,
2992 }];
2993
2994 let result = engine.check(
2996 "rust/command-injection",
2997 Path::new("src/app.rs"),
2998 11,
2999 &inline_suppressions,
3000 None,
3001 );
3002 assert!(result.suppressed);
3003 assert_eq!(result.source, Some(SuppressionSource::Inline));
3004 }
3005
3006 #[test]
3007 fn test_suppression_engine_is_always_enabled() {
3008 assert!(SuppressionEngine::is_always_enabled(
3009 "rust/command-injection"
3010 ));
3011 assert!(SuppressionEngine::is_always_enabled(
3012 "python/shell-injection"
3013 ));
3014 assert!(SuppressionEngine::is_always_enabled(
3015 "generic/hardcoded-secret"
3016 ));
3017 assert!(SuppressionEngine::is_always_enabled("go/command-injection"));
3018 assert!(SuppressionEngine::is_always_enabled(
3019 "java/command-execution"
3020 ));
3021 assert!(SuppressionEngine::is_always_enabled(
3022 "js/dynamic-code-execution"
3023 ));
3024
3025 assert!(!SuppressionEngine::is_always_enabled(
3027 "generic/long-function"
3028 ));
3029 assert!(!SuppressionEngine::is_always_enabled("js/console-log"));
3030 assert!(!SuppressionEngine::is_always_enabled("rust/unsafe-block"));
3031 }
3032
3033 #[test]
3034 fn test_suppression_engine_add_metadata() {
3035 let result = SuppressionResult::suppressed(
3036 SuppressionSource::Inline,
3037 "debug output".to_string(),
3038 "line 10".to_string(),
3039 );
3040
3041 let mut properties = HashMap::new();
3042 SuppressionEngine::add_suppression_metadata(&mut properties, &result);
3043
3044 assert_eq!(properties.get("suppressed"), Some(&serde_json::json!(true)));
3045 assert_eq!(
3046 properties.get("suppression_reason"),
3047 Some(&serde_json::json!("debug output"))
3048 );
3049 assert_eq!(
3050 properties.get("suppression_source"),
3051 Some(&serde_json::json!("inline"))
3052 );
3053 assert_eq!(
3054 properties.get("suppression_location"),
3055 Some(&serde_json::json!("line 10"))
3056 );
3057 }
3058
3059 #[test]
3060 fn test_suppression_result_not_suppressed() {
3061 let result = SuppressionResult::not_suppressed();
3062 assert!(!result.suppressed);
3063 assert!(result.reason.is_none());
3064 assert!(result.source.is_none());
3065 assert!(result.location.is_none());
3066 }
3067
3068 #[test]
3069 fn test_rules_config_with_ignore_paths() {
3070 let toml = r#"
3071config_version = 1
3072
3073[rules]
3074enable = ["*"]
3075disable = []
3076ignore_paths = ["**/vendor/**", "**/generated/**"]
3077
3078[rules.ignore_paths_by_rule]
3079"generic/long-function" = ["**/tests/**", "**/examples/**"]
3080"js/console-log" = ["**/debug/**"]
3081"#;
3082 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
3083
3084 assert_eq!(config.rules.ignore_paths.len(), 2);
3085 assert!(
3086 config
3087 .rules
3088 .ignore_paths
3089 .contains(&"**/vendor/**".to_string())
3090 );
3091 assert!(
3092 config
3093 .rules
3094 .ignore_paths
3095 .contains(&"**/generated/**".to_string())
3096 );
3097
3098 assert_eq!(config.rules.ignore_paths_by_rule.len(), 2);
3099 assert!(
3100 config
3101 .rules
3102 .ignore_paths_by_rule
3103 .contains_key("generic/long-function")
3104 );
3105 assert!(
3106 config
3107 .rules
3108 .ignore_paths_by_rule
3109 .contains_key("js/console-log")
3110 );
3111 }
3112}