1use crate::Result;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ScanResult {
10 pub status: ScanStatus,
12
13 pub score: u8,
15
16 pub findings: Vec<Finding>,
18
19 pub metadata: ScanMetadata,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "lowercase")]
26pub enum ScanStatus {
27 Pass,
28 Warning,
29 Fail,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Finding {
35 pub id: String,
37
38 pub severity: Severity,
40
41 pub category: Category,
43
44 pub title: String,
46
47 pub description: String,
49
50 pub location: Option<String>,
52
53 pub recommendation: String,
55
56 pub references: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "lowercase")]
63pub enum Severity {
64 Info,
65 Low,
66 Medium,
67 High,
68 Critical,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum Category {
75 Malware,
76 VulnerableDependency,
77 InsecureCoding,
78 DataExfiltration,
79 SupplyChain,
80 Licensing,
81 Configuration,
82 Obfuscation,
83 Other,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ScanMetadata {
89 pub scan_id: String,
90 pub scanner_version: String,
91 pub scan_started_at: String,
92 pub scan_completed_at: String,
93 pub duration_ms: u64,
94 pub scanned_files: u32,
95 pub scanned_bytes: u64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ScannerConfig {
101 pub enable_malware_scan: bool,
103
104 pub enable_dependency_scan: bool,
106
107 pub enable_static_analysis: bool,
109
110 pub enable_license_check: bool,
112
113 pub max_file_size: u64,
115
116 pub timeout_per_file: u64,
118
119 pub allowed_licenses: Vec<String>,
121
122 pub fail_on_severity: Severity,
124}
125
126impl Default for ScannerConfig {
127 fn default() -> Self {
128 Self {
129 enable_malware_scan: true,
130 enable_dependency_scan: true,
131 enable_static_analysis: true,
132 enable_license_check: true,
133 max_file_size: 10 * 1024 * 1024, timeout_per_file: 30,
135 allowed_licenses: vec![
136 "MIT".to_string(),
137 "Apache-2.0".to_string(),
138 "BSD-2-Clause".to_string(),
139 "BSD-3-Clause".to_string(),
140 "ISC".to_string(),
141 "MPL-2.0".to_string(),
142 ],
143 fail_on_severity: Severity::High,
144 }
145 }
146}
147
148pub struct SecurityScanner {
150 config: ScannerConfig,
151}
152
153impl SecurityScanner {
154 pub fn new(config: ScannerConfig) -> Self {
156 Self { config }
157 }
158
159 pub async fn scan_plugin(&self, package_path: &Path) -> Result<ScanResult> {
161 let start_time = std::time::Instant::now();
162 let scan_id = uuid::Uuid::new_v4().to_string();
163
164 let mut findings = Vec::new();
165 let scanned_files = 0;
166 let scanned_bytes = 0;
167
168 if self.config.enable_malware_scan {
170 findings.extend(self.scan_for_malware(package_path).await?);
171 }
172
173 if self.config.enable_dependency_scan {
175 findings.extend(self.scan_dependencies(package_path).await?);
176 }
177
178 if self.config.enable_static_analysis {
180 findings.extend(self.static_analysis(package_path).await?);
181 }
182
183 if self.config.enable_license_check {
185 findings.extend(self.check_license_compliance(package_path).await?);
186 }
187
188 let score = self.calculate_security_score(&findings);
190 let status = self.determine_status(&findings);
191
192 let duration = start_time.elapsed();
193
194 Ok(ScanResult {
195 status,
196 score,
197 findings,
198 metadata: ScanMetadata {
199 scan_id,
200 scanner_version: env!("CARGO_PKG_VERSION").to_string(),
201 scan_started_at: chrono::Utc::now().to_rfc3339(),
202 scan_completed_at: chrono::Utc::now().to_rfc3339(),
203 duration_ms: duration.as_millis() as u64,
204 scanned_files,
205 scanned_bytes,
206 },
207 })
208 }
209
210 async fn scan_for_malware(&self, package_path: &Path) -> Result<Vec<Finding>> {
211 let mut findings = Vec::new();
212
213 let suspicious_patterns = [
214 "backdoor",
215 "keylogger",
216 "trojan",
217 "ransomware",
218 "cryptominer",
219 "rootkit",
220 "exploit",
221 ];
222
223 Self::walk_files(package_path, self.config.max_file_size, &mut |path| {
224 let file_name =
225 path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
226
227 for pattern in &suspicious_patterns {
228 if file_name.contains(pattern) {
229 findings.push(Finding {
230 id: format!("MAL-FILENAME-{}", uuid::Uuid::new_v4()),
231 severity: Severity::High,
232 category: Category::Malware,
233 title: format!("Suspicious file name: {}", file_name),
234 description: format!(
235 "File name contains suspicious pattern '{}'. This may indicate malicious content.",
236 pattern
237 ),
238 location: Some(path.display().to_string()),
239 recommendation: "Review the file contents and remove if malicious.".to_string(),
240 references: vec!["CWE-506: Embedded Malicious Code".to_string()],
241 });
242 }
243 }
244 });
245
246 Ok(findings)
247 }
248
249 async fn scan_dependencies(&self, package_path: &Path) -> Result<Vec<Finding>> {
250 let mut findings = Vec::new();
251
252 let cargo_toml_path = package_path.join("Cargo.toml");
254 if cargo_toml_path.exists() {
255 if let Ok(content) = std::fs::read_to_string(&cargo_toml_path) {
256 for line in content.lines() {
258 let trimmed = line.trim();
259 if trimmed.contains("git = \"")
260 && !trimmed.contains("github.com")
261 && !trimmed.contains("gitlab.com")
262 {
263 findings.push(Finding {
264 id: format!("DEP-GIT-{}", uuid::Uuid::new_v4()),
265 severity: Severity::Medium,
266 category: Category::SupplyChain,
267 title: "Non-standard git dependency source".to_string(),
268 description: format!(
269 "Dependency uses a non-standard git repository: {}",
270 trimmed
271 ),
272 location: Some(cargo_toml_path.display().to_string()),
273 recommendation: "Verify the dependency source is trusted.".to_string(),
274 references: vec![
275 "CWE-829: Inclusion of Functionality from Untrusted Control Sphere"
276 .to_string(),
277 ],
278 });
279 }
280 if trimmed.contains("path = \"") && trimmed.contains("..") {
282 findings.push(Finding {
283 id: format!("DEP-PATH-{}", uuid::Uuid::new_v4()),
284 severity: Severity::Low,
285 category: Category::SupplyChain,
286 title: "Path dependency with parent traversal".to_string(),
287 description: format!(
288 "Dependency uses a relative path that traverses parent directories: {}",
289 trimmed
290 ),
291 location: Some(cargo_toml_path.display().to_string()),
292 recommendation: "Ensure path dependencies don't reference files outside the package.".to_string(),
293 references: vec![],
294 });
295 }
296 }
297 }
298 }
299
300 let package_json_path = package_path.join("package.json");
302 if package_json_path.exists() {
303 if let Ok(content) = std::fs::read_to_string(&package_json_path) {
304 if content.contains("\"preinstall\"") || content.contains("\"postinstall\"") {
306 findings.push(Finding {
307 id: format!("DEP-SCRIPT-{}", uuid::Uuid::new_v4()),
308 severity: Severity::Medium,
309 category: Category::SupplyChain,
310 title: "Package contains install scripts".to_string(),
311 description: "Package defines preinstall or postinstall scripts which can execute arbitrary code during installation.".to_string(),
312 location: Some(package_json_path.display().to_string()),
313 recommendation: "Review install scripts for malicious behavior.".to_string(),
314 references: vec!["CWE-829: Inclusion of Functionality from Untrusted Control Sphere".to_string()],
315 });
316 }
317 }
318 }
319
320 Ok(findings)
321 }
322
323 async fn static_analysis(&self, package_path: &Path) -> Result<Vec<Finding>> {
324 let mut findings = Vec::new();
325
326 let secret_patterns = [
327 ("password", "Hardcoded password"),
328 ("secret_key", "Hardcoded secret key"),
329 ("api_key", "Hardcoded API key"),
330 ("private_key", "Hardcoded private key"),
331 ("access_token", "Hardcoded access token"),
332 ];
333
334 Self::walk_files(package_path, self.config.max_file_size, &mut |path| {
335 let ext =
336 path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
337
338 if !matches!(ext.as_str(), "rs" | "js" | "ts" | "py" | "go" | "java" | "rb" | "sh") {
340 return;
341 }
342
343 let Ok(content) = std::fs::read_to_string(path) else {
344 return;
345 };
346
347 if ext == "rs" && content.contains("unsafe {") {
349 let unsafe_count = content.matches("unsafe {").count();
350 findings.push(Finding {
351 id: format!("SA-UNSAFE-{}", uuid::Uuid::new_v4()),
352 severity: Severity::Medium,
353 category: Category::InsecureCoding,
354 title: format!("Contains {} unsafe block(s)", unsafe_count),
355 description: "Unsafe code can lead to memory safety issues. Each unsafe block should be carefully reviewed.".to_string(),
356 location: Some(path.display().to_string()),
357 recommendation: "Ensure all unsafe blocks have SAFETY comments and are truly necessary.".to_string(),
358 references: vec!["CWE-119: Buffer Overflow".to_string()],
359 });
360 }
361
362 for (pattern, description) in &secret_patterns {
364 for (line_num, line) in content.lines().enumerate() {
365 let lower = line.to_lowercase();
366 if lower.contains(pattern)
368 && (line.contains("= \"") || line.contains(": \"") || line.contains("=\""))
369 && !line.trim_start().starts_with("//")
370 && !line.trim_start().starts_with('#')
371 && !line.trim_start().starts_with("///")
372 {
373 findings.push(Finding {
374 id: format!("SA-SECRET-{}", uuid::Uuid::new_v4()),
375 severity: Severity::High,
376 category: Category::DataExfiltration,
377 title: format!("{} detected", description),
378 description: format!(
379 "Possible hardcoded credential at line {}. Secrets should be loaded from environment variables or a secret manager.",
380 line_num + 1
381 ),
382 location: Some(format!("{}:{}", path.display(), line_num + 1)),
383 recommendation: "Move credentials to environment variables or a secrets manager.".to_string(),
384 references: vec!["CWE-798: Use of Hard-coded Credentials".to_string()],
385 });
386 break; }
388 }
389 }
390
391 if ext == "rs" && (content.contains("Command::new") && content.contains(".arg(")) {
393 if content.contains("std::process::Command") {
395 findings.push(Finding {
396 id: format!("SA-CMDINJ-{}", uuid::Uuid::new_v4()),
397 severity: Severity::Low,
398 category: Category::InsecureCoding,
399 title: "External command execution detected".to_string(),
400 description: "Code executes external commands. Ensure arguments are properly sanitized.".to_string(),
401 location: Some(path.display().to_string()),
402 recommendation: "Validate and sanitize all inputs passed to external commands.".to_string(),
403 references: vec!["CWE-78: OS Command Injection".to_string()],
404 });
405 }
406 }
407 });
408
409 Ok(findings)
410 }
411
412 async fn check_license_compliance(&self, package_path: &Path) -> Result<Vec<Finding>> {
413 let mut findings = Vec::new();
414
415 let has_license = package_path.join("LICENSE").exists()
417 || package_path.join("LICENSE.md").exists()
418 || package_path.join("LICENSE.txt").exists()
419 || package_path.join("LICENCE").exists();
420
421 if !has_license {
422 findings.push(Finding {
423 id: format!("LIC-MISSING-{}", uuid::Uuid::new_v4()),
424 severity: Severity::Medium,
425 category: Category::Licensing,
426 title: "No LICENSE file found".to_string(),
427 description:
428 "Package does not contain a LICENSE file. License must be clearly specified."
429 .to_string(),
430 location: Some(package_path.display().to_string()),
431 recommendation: "Add a LICENSE file with an approved open source license."
432 .to_string(),
433 references: vec![],
434 });
435 }
436
437 let cargo_toml_path = package_path.join("Cargo.toml");
439 if cargo_toml_path.exists() {
440 if let Ok(content) = std::fs::read_to_string(&cargo_toml_path) {
441 let has_license_field = content.lines().any(|line| {
442 let trimmed = line.trim();
443 trimmed.starts_with("license ")
444 || trimmed.starts_with("license=")
445 || trimmed.starts_with("license-file")
446 });
447
448 if !has_license_field {
449 findings.push(Finding {
450 id: format!("LIC-CARGO-{}", uuid::Uuid::new_v4()),
451 severity: Severity::Low,
452 category: Category::Licensing,
453 title: "No license field in Cargo.toml".to_string(),
454 description: "Cargo.toml does not specify a license or license-file field."
455 .to_string(),
456 location: Some(cargo_toml_path.display().to_string()),
457 recommendation:
458 "Add a 'license' field to Cargo.toml with an SPDX identifier."
459 .to_string(),
460 references: vec![],
461 });
462 } else {
463 for line in content.lines() {
465 let trimmed = line.trim();
466 if (trimmed.starts_with("license ") || trimmed.starts_with("license="))
467 && !trimmed.starts_with("license-file")
468 {
469 let license_value = trimmed
470 .split('=')
471 .nth(1)
472 .unwrap_or("")
473 .trim()
474 .trim_matches('"')
475 .trim_matches('\'');
476
477 let all_allowed = license_value.split(" OR ").all(|l| {
479 self.config.allowed_licenses.iter().any(|a| a == l.trim())
480 });
481
482 if !all_allowed && !license_value.is_empty() {
483 findings.push(Finding {
484 id: format!("LIC-UNAPPROVED-{}", uuid::Uuid::new_v4()),
485 severity: Severity::Medium,
486 category: Category::Licensing,
487 title: format!("License '{}' may not be approved", license_value),
488 description: format!(
489 "The license '{}' is not in the approved license list: {:?}",
490 license_value, self.config.allowed_licenses
491 ),
492 location: Some(cargo_toml_path.display().to_string()),
493 recommendation: "Use an approved license or request an exception.".to_string(),
494 references: vec![],
495 });
496 }
497 }
498 }
499 }
500 }
501 }
502
503 Ok(findings)
504 }
505
506 fn walk_files(dir: &Path, max_size: u64, callback: &mut dyn FnMut(&Path)) {
508 let mut stack = vec![dir.to_path_buf()];
509 while let Some(current) = stack.pop() {
510 let Ok(entries) = std::fs::read_dir(¤t) else {
511 continue;
512 };
513 for entry in entries.flatten() {
514 let path = entry.path();
515 if path.is_dir() {
516 stack.push(path);
517 } else if let Ok(meta) = std::fs::metadata(&path) {
518 if meta.len() <= max_size {
519 callback(&path);
520 }
521 }
522 }
523 }
524 }
525
526 fn calculate_security_score(&self, findings: &[Finding]) -> u8 {
527 let mut score: u8 = 100;
528
529 for finding in findings {
530 let deduction = match finding.severity {
531 Severity::Critical => 30,
532 Severity::High => 20,
533 Severity::Medium => 10,
534 Severity::Low => 5,
535 Severity::Info => 0,
536 };
537 score = score.saturating_sub(deduction);
538 }
539
540 score
541 }
542
543 fn determine_status(&self, findings: &[Finding]) -> ScanStatus {
544 let has_critical = findings.iter().any(|f| f.severity >= self.config.fail_on_severity);
545
546 if has_critical {
547 ScanStatus::Fail
548 } else if findings.iter().any(|f| f.severity >= Severity::Medium) {
549 ScanStatus::Warning
550 } else {
551 ScanStatus::Pass
552 }
553 }
554}
555
556impl Default for SecurityScanner {
557 fn default() -> Self {
558 Self::new(ScannerConfig::default())
559 }
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct Vulnerability {
565 pub id: String,
566 pub package: String,
567 pub versions: Vec<String>,
568 pub severity: Severity,
569 pub title: String,
570 pub description: String,
571 pub cvss_score: Option<f32>,
572 pub cve: Option<String>,
573 pub patched_versions: Vec<String>,
574 pub references: Vec<String>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct LicenseInfo {
580 pub spdx_id: String,
581 pub name: String,
582 pub approved: bool,
583 pub osi_approved: bool,
584 pub category: LicenseCategory,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
589#[serde(rename_all = "lowercase")]
590pub enum LicenseCategory {
591 Permissive,
592 Copyleft,
593 Proprietary,
594 Unknown,
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn test_security_score_calculation() {
603 let scanner = SecurityScanner::default();
604
605 let findings = vec![
606 Finding {
607 id: "1".to_string(),
608 severity: Severity::High,
609 category: Category::Malware,
610 title: "Suspicious code".to_string(),
611 description: "Test".to_string(),
612 location: None,
613 recommendation: "Remove".to_string(),
614 references: vec![],
615 },
616 Finding {
617 id: "2".to_string(),
618 severity: Severity::Medium,
619 category: Category::InsecureCoding,
620 title: "Weak encryption".to_string(),
621 description: "Test".to_string(),
622 location: None,
623 recommendation: "Use strong encryption".to_string(),
624 references: vec![],
625 },
626 ];
627
628 let score = scanner.calculate_security_score(&findings);
629 assert_eq!(score, 70); }
631
632 #[test]
633 fn test_status_determination() {
634 let scanner = SecurityScanner::default();
635
636 let critical_findings = vec![Finding {
637 id: "1".to_string(),
638 severity: Severity::Critical,
639 category: Category::Malware,
640 title: "Malware detected".to_string(),
641 description: "Test".to_string(),
642 location: None,
643 recommendation: "Remove".to_string(),
644 references: vec![],
645 }];
646
647 assert_eq!(scanner.determine_status(&critical_findings), ScanStatus::Fail);
648
649 let medium_findings = vec![Finding {
650 id: "1".to_string(),
651 severity: Severity::Medium,
652 category: Category::InsecureCoding,
653 title: "Code issue".to_string(),
654 description: "Test".to_string(),
655 location: None,
656 recommendation: "Fix".to_string(),
657 references: vec![],
658 }];
659
660 assert_eq!(scanner.determine_status(&medium_findings), ScanStatus::Warning);
661
662 let low_findings = vec![Finding {
663 id: "1".to_string(),
664 severity: Severity::Low,
665 category: Category::Configuration,
666 title: "Config issue".to_string(),
667 description: "Test".to_string(),
668 location: None,
669 recommendation: "Update".to_string(),
670 references: vec![],
671 }];
672
673 assert_eq!(scanner.determine_status(&low_findings), ScanStatus::Pass);
674 }
675}