oparry_validators/
security.rs1use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
11pub struct SecurityConfig {
12 pub block_innerhtml: bool,
14 pub block_eval: bool,
16 pub block_dangerous_apis: bool,
18 pub check_secrets: bool,
20 pub warn_unsafe_dom: bool,
22}
23
24impl Default for SecurityConfig {
25 fn default() -> Self {
26 Self {
27 block_innerhtml: true,
28 block_eval: true,
29 block_dangerous_apis: true,
30 check_secrets: true,
31 warn_unsafe_dom: true,
32 }
33 }
34}
35
36pub struct SecurityValidator {
38 config: SecurityConfig,
39 innerhtml_regex: Regex,
40 eval_regex: Regex,
41 dangerous_dom_regex: Regex,
42 secret_pattern_regex: Regex,
43}
44
45impl SecurityValidator {
46 pub fn new(config: SecurityConfig) -> Self {
48 Self {
49 config,
50 innerhtml_regex: Regex::new(r"dangerouslySetInnerHTML|innerHTML\s*=").unwrap(),
51 eval_regex: Regex::new(r#"\beval\s*\(|new\s+Function\s*\("#).unwrap(),
52 dangerous_dom_regex: Regex::new(r#"\b(document\.write|outerHTML\s*=)"#).unwrap(),
53 secret_pattern_regex: Regex::new(
54 r#"(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*['"][\w-]{20,}"#
55 ).unwrap(),
56 }
57 }
58
59 pub fn default_config() -> Self {
61 Self::new(SecurityConfig::default())
62 }
63
64 fn check_innerhtml(&self, source: &str, file: &str) -> Vec<Issue> {
66 let mut issues = Vec::new();
67 if !self.config.block_innerhtml {
68 return issues;
69 }
70
71 for (idx, line) in source.lines().enumerate() {
72 if self.innerhtml_regex.is_match(line) {
73 issues.push(Issue::error(
74 "sec-dangerous-innerhtml",
75 "Dangerous innerHTML usage detected - XSS vulnerability",
76 )
77 .with_file(file)
78 .with_line(idx + 1)
79 .with_suggestion("Use React text or DOMParser with sanitization"));
80 }
81 }
82 issues
83 }
84
85 fn check_eval(&self, source: &str, file: &str) -> Vec<Issue> {
87 let mut issues = Vec::new();
88 if !self.config.block_eval {
89 return issues;
90 }
91
92 for (idx, line) in source.lines().enumerate() {
93 if self.eval_regex.is_match(line) {
94 issues.push(Issue::error(
95 "sec-eval-usage",
96 "eval() or new Function() detected - code injection risk",
97 )
98 .with_file(file)
99 .with_line(idx + 1)
100 .with_suggestion("Never use eval - find safer alternative"));
101 }
102 }
103 issues
104 }
105
106 fn check_secrets(&self, source: &str, file: &str) -> Vec<Issue> {
108 let mut issues = Vec::new();
109 if !self.config.check_secrets {
110 return issues;
111 }
112
113 for (idx, line) in source.lines().enumerate() {
114 let trimmed = line.trim();
116 if trimmed.starts_with("//") || trimmed.starts_with("#") {
117 continue;
118 }
119
120 if self.secret_pattern_regex.is_match(line) {
121 issues.push(Issue::error(
122 "sec-hardcoded-secret",
123 "Possible hardcoded secret detected",
124 )
125 .with_file(file)
126 .with_line(idx + 1)
127 .with_suggestion("Move secrets to environment variables"));
128 }
129 }
130 issues
131 }
132
133 fn check_unsafe_dom(&self, source: &str, file: &str) -> Vec<Issue> {
135 let mut issues = Vec::new();
136 if !self.config.warn_unsafe_dom {
137 return issues;
138 }
139
140 for (idx, line) in source.lines().enumerate() {
141 if self.dangerous_dom_regex.is_match(line) {
142 issues.push(Issue::warning(
143 "sec-unsafe-dom",
144 "Unsafe DOM manipulation detected",
145 )
146 .with_file(file)
147 .with_line(idx + 1)
148 .with_suggestion("Use React state and refs instead"));
149 }
150 }
151 issues
152 }
153}
154
155impl Validator for SecurityValidator {
156 fn name(&self) -> &str {
157 "Security"
158 }
159
160 fn supports(&self, language: Language) -> bool {
161 language.is_javascript_variant()
162 }
163
164 fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
165 let mut result = ValidationResult::new();
166 let source = code.source();
167 let file_str = file.to_string_lossy().to_string();
168
169 for issue in self.check_innerhtml(source, &file_str) {
170 result.add_issue(issue);
171 }
172 for issue in self.check_eval(source, &file_str) {
173 result.add_issue(issue);
174 }
175 for issue in self.check_secrets(source, &file_str) {
176 result.add_issue(issue);
177 }
178 for issue in self.check_unsafe_dom(source, &file_str) {
179 result.add_issue(issue);
180 }
181
182 Ok(result)
183 }
184
185 fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
186 let parsed = ParsedCode::Generic(source.to_string());
187 self.validate_parsed(&parsed, file)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_security_validator_valid() {
197 let validator = SecurityValidator::default_config();
198 let code = r#"function Safe() { return <div>{content}</div>; }"#;
199 let result = validator.validate_raw(code, Path::new("Safe.tsx")).unwrap();
200 assert!(result.passed);
201 }
202
203 #[test]
204 fn test_sec_innerhtml() {
205 let validator = SecurityValidator::default_config();
206 let code = r#"<div dangerouslySetInnerHTML={{ __html: html }} />"#;
207 let result = validator.validate_raw(code, Path::new("Unsafe.tsx")).unwrap();
208 assert!(!result.passed);
209 }
210
211 #[test]
212 fn test_sec_eval() {
213 let validator = SecurityValidator::default_config();
214 let code = r#"eval(userInput)"#;
215 let result = validator.validate_raw(code, Path::new("Bad.ts")).unwrap();
216 assert!(!result.passed);
217 }
218
219 #[test]
220 fn test_sec_hardcoded_secret() {
221 let validator = SecurityValidator::default_config();
222 let code = r#"const apiKey = 'sk-1234567890abcdef1234567890abcdef'"#;
223 let result = validator.validate_raw(code, Path::new("config.ts")).unwrap();
224 assert!(!result.passed);
225 }
226
227 #[test]
228 fn test_security_validator_supports() {
229 let validator = SecurityValidator::default_config();
230 assert!(validator.supports(Language::JavaScript));
231 assert!(!validator.supports(Language::Rust));
232 }
233}