1use crate::{Exploitability, Finding, MirPackage, Rule, RuleMetadata, RuleOrigin, Severity};
10use semver::{Version, VersionReq};
11use std::collections::HashSet;
12use std::ffi::OsStr;
13use std::fs;
14use std::path::Path;
15use walkdir::WalkDir;
16
17use super::utils::filter_entry;
18
19struct UnsoundAdvisory {
24 crate_name: &'static str,
25 version_req: &'static str,
26 advisory_id: &'static str,
27 summary: &'static str,
28}
29
30pub struct RustsecUnsoundDependencyRule {
32 metadata: RuleMetadata,
33}
34
35impl RustsecUnsoundDependencyRule {
36 pub fn new() -> Self {
37 Self {
38 metadata: RuleMetadata {
39 id: "RUSTCOLA018".to_string(),
40 name: "rustsec-unsound-dependency".to_string(),
41 short_description: "Dependency has known RUSTSEC advisory".to_string(),
42 full_description: "Detects dependencies that have known soundness issues documented in RUSTSEC advisories.".to_string(),
43 help_uri: Some("https://rustsec.org/".to_string()),
44 default_severity: Severity::High,
45 origin: RuleOrigin::BuiltIn,
46 cwe_ids: Vec::new(),
47 fix_suggestion: None,
48 exploitability: Exploitability::default(),
49 },
50 }
51 }
52
53 fn advisories() -> &'static [UnsoundAdvisory] {
54 &[
55 UnsoundAdvisory {
56 crate_name: "arrayvec",
57 version_req: "<= 0.4.10",
58 advisory_id: "RUSTSEC-2018-0001",
59 summary: "arrayvec::ArrayVec::insert can cause memory corruption",
60 },
61 UnsoundAdvisory {
62 crate_name: "time",
63 version_req: "< 0.2.23",
64 advisory_id: "RUSTSEC-2020-0071",
65 summary: "Potential segfault in localtime_r",
66 },
67 UnsoundAdvisory {
68 crate_name: "crossbeam-deque",
69 version_req: "< 0.7.4",
70 advisory_id: "RUSTSEC-2021-0093",
71 summary: "Race condition may result in double-free",
72 },
73 UnsoundAdvisory {
74 crate_name: "owning_ref",
75 version_req: "< 0.4.2",
76 advisory_id: "RUSTSEC-2022-0044",
77 summary: "Unsound StableAddress impl",
78 },
79 UnsoundAdvisory {
80 crate_name: "smallvec",
81 version_req: "< 1.10.0",
82 advisory_id: "RUSTSEC-2021-0009",
83 summary: "SmallVec::insert_many can cause memory exposure",
84 },
85 UnsoundAdvisory {
86 crate_name: "fixedbitset",
87 version_req: "< 0.4.0",
88 advisory_id: "RUSTSEC-2019-0003",
89 summary: "FixedBitSet::insert unsound aliasing",
90 },
91 ]
92 }
93
94 fn advisory_matches(advisory: &UnsoundAdvisory, version: &Version) -> bool {
95 VersionReq::parse(advisory.version_req)
96 .ok()
97 .map(|req| req.matches(version))
98 .unwrap_or(false)
99 }
100}
101
102impl Rule for RustsecUnsoundDependencyRule {
103 fn metadata(&self) -> &RuleMetadata {
104 &self.metadata
105 }
106
107 fn evaluate(
108 &self,
109 package: &MirPackage,
110 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
111 ) -> Vec<Finding> {
112 let mut findings = Vec::new();
113 let crate_root = Path::new(&package.crate_root);
114 let lock_path = crate_root.join("Cargo.lock");
115
116 if !lock_path.exists() {
117 return findings;
118 }
119
120 let Ok(contents) = fs::read_to_string(&lock_path) else {
121 return findings;
122 };
123
124 let Ok(doc) = toml::from_str::<toml::Value>(&contents) else {
125 return findings;
126 };
127
128 let Some(packages) = doc.get("package").and_then(|value| value.as_array()) else {
129 return findings;
130 };
131
132 let relative_lock = lock_path
133 .strip_prefix(crate_root)
134 .unwrap_or(&lock_path)
135 .to_string_lossy()
136 .replace('\\', "/");
137
138 let mut emitted = HashSet::new();
139
140 for pkg in packages {
141 let Some(name) = pkg.get("name").and_then(|v| v.as_str()) else {
142 continue;
143 };
144 let Some(version_str) = pkg.get("version").and_then(|v| v.as_str()) else {
145 continue;
146 };
147 let Ok(version) = Version::parse(version_str) else {
148 continue;
149 };
150
151 for advisory in Self::advisories() {
152 if advisory.crate_name != name {
153 continue;
154 }
155
156 if !Self::advisory_matches(advisory, &version) {
157 continue;
158 }
159
160 let key = (
161 name.to_string(),
162 version_str.to_string(),
163 advisory.advisory_id,
164 );
165 if !emitted.insert(key) {
166 continue;
167 }
168
169 let evidence = vec![format!(
170 "{} {} matches {} ({})",
171 name, version_str, advisory.advisory_id, advisory.summary
172 )];
173
174 findings.push(Finding {
175 rule_id: self.metadata.id.clone(),
176 rule_name: self.metadata.name.clone(),
177 severity: self.metadata.default_severity,
178 message: format!(
179 "Dependency '{}' v{} is flagged unsound by advisory {}",
180 name, version_str, advisory.advisory_id
181 ),
182 function: relative_lock.clone(),
183 function_signature: format!("{} {}", name, version_str),
184 evidence,
185 span: None,
186 ..Default::default()
187 });
188 }
189 }
190
191 findings
192 }
193}
194
195struct YankedRelease {
200 crate_name: &'static str,
201 version: &'static str,
202 reason: &'static str,
203}
204
205pub struct YankedCrateRule {
207 metadata: RuleMetadata,
208}
209
210impl YankedCrateRule {
211 pub fn new() -> Self {
212 Self {
213 metadata: RuleMetadata {
214 id: "RUSTCOLA019".to_string(),
215 name: "yanked-crate-version".to_string(),
216 short_description: "Dependency references a yanked crate version".to_string(),
217 full_description:
218 "Highlights crates pinned to versions that have been yanked from crates.io."
219 .to_string(),
220 help_uri: Some(
221 "https://doc.rust-lang.org/cargo/reference/publishing.html#removing-a-version"
222 .to_string(),
223 ),
224 default_severity: Severity::Medium,
225 origin: RuleOrigin::BuiltIn,
226 cwe_ids: Vec::new(),
227 fix_suggestion: None,
228 exploitability: Exploitability::default(),
229 },
230 }
231 }
232
233 fn releases() -> &'static [YankedRelease] {
234 &[
235 YankedRelease {
236 crate_name: "memoffset",
237 version: "0.5.6",
238 reason: "Yanked due to soundness issue (RUSTSEC-2021-0119)",
239 },
240 YankedRelease {
241 crate_name: "chrono",
242 version: "0.4.19",
243 reason: "Yanked pending security fixes",
244 },
245 ]
246 }
247}
248
249impl Rule for YankedCrateRule {
250 fn metadata(&self) -> &RuleMetadata {
251 &self.metadata
252 }
253
254 fn evaluate(
255 &self,
256 package: &MirPackage,
257 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
258 ) -> Vec<Finding> {
259 let mut findings = Vec::new();
260 let crate_root = Path::new(&package.crate_root);
261 let lock_path = crate_root.join("Cargo.lock");
262
263 if !lock_path.exists() {
264 return findings;
265 }
266
267 let Ok(contents) = fs::read_to_string(&lock_path) else {
268 return findings;
269 };
270
271 let Ok(doc) = toml::from_str::<toml::Value>(&contents) else {
272 return findings;
273 };
274
275 let Some(packages) = doc.get("package").and_then(|value| value.as_array()) else {
276 return findings;
277 };
278
279 let relative_lock = lock_path
280 .strip_prefix(crate_root)
281 .unwrap_or(&lock_path)
282 .to_string_lossy()
283 .replace('\\', "/");
284
285 let mut emitted = HashSet::new();
286
287 for pkg in packages {
288 let Some(name) = pkg.get("name").and_then(|v| v.as_str()) else {
289 continue;
290 };
291 let Some(version) = pkg.get("version").and_then(|v| v.as_str()) else {
292 continue;
293 };
294
295 for release in Self::releases() {
296 if release.crate_name != name || release.version != version {
297 continue;
298 }
299
300 if !emitted.insert((name.to_string(), version.to_string())) {
301 continue;
302 }
303
304 let evidence = vec![format!("{}: {}", release.crate_name, release.reason)];
305
306 findings.push(Finding {
307 rule_id: self.metadata.id.clone(),
308 rule_name: self.metadata.name.clone(),
309 severity: self.metadata.default_severity,
310 message: format!(
311 "Dependency '{}' v{} has been yanked: {}",
312 name, version, release.reason
313 ),
314 function: relative_lock.clone(),
315 function_signature: format!("{} {}", name, version),
316 evidence,
317 span: None,
318 ..Default::default()
319 });
320 }
321 }
322
323 findings
324 }
325}
326
327pub struct CargoAuditableMetadataRule {
333 metadata: RuleMetadata,
334}
335
336impl CargoAuditableMetadataRule {
337 pub fn new() -> Self {
338 Self {
339 metadata: RuleMetadata {
340 id: "RUSTCOLA020".to_string(),
341 name: "cargo-auditable-metadata".to_string(),
342 short_description: "Binary crate missing cargo auditable metadata".to_string(),
343 full_description: "Detects binary crates that do not integrate cargo auditable metadata, encouraging projects to embed supply-chain provenance in release artifacts.".to_string(),
344 help_uri: Some("https://github.com/rust-secure-code/cargo-auditable".to_string()),
345 default_severity: Severity::Medium,
346 origin: RuleOrigin::BuiltIn,
347 cwe_ids: Vec::new(),
348 fix_suggestion: None,
349 exploitability: Exploitability::default(),
350 },
351 }
352 }
353
354 fn read_manifest(crate_root: &Path) -> Option<toml::Value> {
355 let manifest_path = crate_root.join("Cargo.toml");
356 let contents = fs::read_to_string(manifest_path).ok()?;
357 toml::from_str::<toml::Value>(&contents).ok()
358 }
359
360 fn is_workspace(manifest: &toml::Value) -> bool {
361 manifest.get("workspace").is_some() && manifest.get("package").is_none()
362 }
363
364 fn is_binary_crate(manifest: &toml::Value, crate_root: &Path) -> bool {
365 if Self::is_workspace(manifest) {
366 return false;
367 }
368
369 if manifest
370 .get("bin")
371 .and_then(|value| value.as_array())
372 .map(|arr| !arr.is_empty())
373 .unwrap_or(false)
374 {
375 return true;
376 }
377
378 let src_main = crate_root.join("src").join("main.rs");
379 if src_main.exists() {
380 return true;
381 }
382
383 let bin_dir = crate_root.join("src").join("bin");
384 if let Ok(entries) = fs::read_dir(&bin_dir) {
385 for entry in entries.flatten() {
386 let path = entry.path();
387 if path.is_file()
388 && path
389 .extension()
390 .and_then(OsStr::to_str)
391 .map_or(false, |ext| ext.eq_ignore_ascii_case("rs"))
392 {
393 return true;
394 }
395
396 if path.is_dir() && path.join("main.rs").exists() {
397 return true;
398 }
399 }
400 }
401
402 false
403 }
404
405 fn metadata_skip(manifest: &toml::Value) -> bool {
406 manifest
407 .get("package")
408 .and_then(|pkg| pkg.get("metadata"))
409 .and_then(|metadata| match metadata {
410 toml::Value::Table(table) => table.get("rust-cola"),
411 _ => None,
412 })
413 .and_then(|rust_cola| match rust_cola {
414 toml::Value::Table(table) => table.get("skip_auditable_check"),
415 _ => None,
416 })
417 .and_then(|value| value.as_bool())
418 .unwrap_or(false)
419 }
420
421 fn has_auditable_markers(manifest: &toml::Value) -> bool {
422 let marker_keys = ["auditable", "cargo-auditable"];
423
424 let dep_tables = [
425 manifest.get("dependencies"),
426 manifest.get("dev-dependencies"),
427 manifest.get("build-dependencies"),
428 ];
429
430 if dep_tables.iter().any(|value| match value {
431 Some(toml::Value::Table(table)) => table
432 .keys()
433 .any(|key| marker_keys.iter().any(|mk| key.contains(mk))),
434 _ => false,
435 }) {
436 return true;
437 }
438
439 if manifest
440 .get("package")
441 .and_then(|pkg| pkg.get("metadata"))
442 .and_then(|metadata| match metadata {
443 toml::Value::Table(table) => Some(table),
444 _ => None,
445 })
446 .map(|metadata_table| {
447 metadata_table.keys().any(|key| marker_keys.iter().any(|mk| key.contains(mk)))
448 || metadata_table.values().any(|value| matches!(value, toml::Value::Table(inner) if inner.keys().any(|k| k.contains("auditable"))))
449 })
450 .unwrap_or(false)
451 {
452 return true;
453 }
454
455 if let Some(toml::Value::Table(features)) = manifest.get("features") {
456 if features.iter().any(|(key, value)| {
457 key.contains("auditable")
458 || matches!(value, toml::Value::Array(items) if items.iter().any(|item| item.as_str().map_or(false, |s| s.contains("auditable"))))
459 }) {
460 return true;
461 }
462 }
463
464 false
465 }
466
467 fn lockfile_mentions_auditable(crate_root: &Path) -> bool {
468 let lock_path = crate_root.join("Cargo.lock");
469 let Ok(contents) = fs::read_to_string(lock_path) else {
470 return false;
471 };
472
473 contents.to_lowercase().contains("auditable")
474 }
475
476 fn ci_mentions_cargo_auditable(crate_root: &Path) -> bool {
477 const MAX_ANCESTOR_SEARCH: usize = 5;
478
479 for ancestor in crate_root.ancestors().take(MAX_ANCESTOR_SEARCH) {
480 if Self::ci_markers_within(ancestor) {
481 return true;
482 }
483 }
484
485 false
486 }
487
488 fn ci_markers_within(base: &Path) -> bool {
489 let search_dirs = [".github", ".gitlab", "ci", "scripts"];
490 for dir in &search_dirs {
491 let path = base.join(dir);
492 if !path.exists() {
493 continue;
494 }
495
496 for entry in WalkDir::new(&path)
497 .max_depth(6)
498 .into_iter()
499 .filter_entry(|e| {
500 if e.depth() == 0 {
502 return true;
503 }
504 let name = e.file_name().to_string_lossy();
505 if e.file_type().is_dir() {
507 return !matches!(
508 name.as_ref(),
509 "target" | ".git" | ".cola-cache" | "out" | "node_modules"
510 );
511 }
512 true
513 })
514 {
515 let entry = match entry {
516 Ok(e) => e,
517 Err(_) => continue,
518 };
519
520 if !entry.file_type().is_file() {
521 continue;
522 }
523
524 if let Ok(metadata) = entry.metadata() {
525 if metadata.len() > 512 * 1024 {
526 continue;
527 }
528 }
529
530 if fs::read_to_string(entry.path())
531 .map(|contents| contents.to_lowercase().contains("cargo auditable"))
532 .unwrap_or(false)
533 {
534 return true;
535 }
536 }
537 }
538
539 let marker_files = ["Makefile", "makefile", "Justfile", "justfile"];
540 for file in &marker_files {
541 let path = base.join(file);
542 if !path.exists() {
543 continue;
544 }
545
546 if fs::read_to_string(&path)
547 .map(|contents| contents.to_lowercase().contains("cargo auditable"))
548 .unwrap_or(false)
549 {
550 return true;
551 }
552 }
553
554 false
555 }
556}
557
558impl Rule for CargoAuditableMetadataRule {
559 fn metadata(&self) -> &RuleMetadata {
560 &self.metadata
561 }
562
563 fn evaluate(
564 &self,
565 package: &MirPackage,
566 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
567 ) -> Vec<Finding> {
568 let mut findings = Vec::new();
569 let crate_root = Path::new(&package.crate_root);
570
571 let Some(manifest) = Self::read_manifest(crate_root) else {
572 return findings;
573 };
574
575 if !Self::is_binary_crate(&manifest, crate_root) {
576 return findings;
577 }
578
579 if Self::metadata_skip(&manifest) {
580 return findings;
581 }
582
583 if Self::has_auditable_markers(&manifest)
584 || Self::lockfile_mentions_auditable(crate_root)
585 || Self::ci_mentions_cargo_auditable(crate_root)
586 {
587 return findings;
588 }
589
590 let package_name = manifest
591 .get("package")
592 .and_then(|pkg| pkg.get("name"))
593 .and_then(|name| name.as_str())
594 .unwrap_or(&package.crate_name);
595
596 let mut evidence = Vec::new();
597 if crate_root.join("src").join("main.rs").exists() {
598 evidence.push("Found src/main.rs binary entry point".to_string());
599 }
600
601 if crate_root.join("src").join("bin").exists() {
602 evidence.push("Found src/bin directory indicating additional binaries".to_string());
603 }
604
605 if manifest
606 .get("bin")
607 .and_then(|value| value.as_array())
608 .map(|arr| !arr.is_empty())
609 .unwrap_or(false)
610 {
611 evidence.push("Cargo.toml defines [[bin]] targets".to_string());
612 }
613
614 evidence.push(
615 "No cargo auditable dependency, metadata, lockfile entry, or CI integration detected"
616 .to_string(),
617 );
618
619 let manifest_path = crate_root.join("Cargo.toml");
620 let relative_manifest = manifest_path
621 .strip_prefix(crate_root)
622 .unwrap_or(&manifest_path)
623 .to_string_lossy()
624 .replace('\\', "/");
625
626 findings.push(Finding {
627 rule_id: self.metadata.id.clone(),
628 rule_name: self.metadata.name.clone(),
629 severity: self.metadata.default_severity,
630 message: format!(
631 "Binary crate '{}' is missing cargo auditable metadata; consider integrating cargo auditable builds",
632 package_name
633 ),
634 function: relative_manifest,
635 function_signature: package_name.to_string(),
636 evidence,
637 span: None,
638 ..Default::default()
639 });
640
641 findings
642 }
643}
644
645pub struct ProcMacroSideEffectsRule {
654 metadata: RuleMetadata,
655}
656
657impl ProcMacroSideEffectsRule {
658 pub fn new() -> Self {
659 Self {
660 metadata: RuleMetadata {
661 id: "RUSTCOLA102".to_string(),
662 name: "proc-macro-side-effects".to_string(),
663 short_description: "Proc-macro with suspicious side effects".to_string(),
664 full_description: "Detects proc-macro crates that use filesystem, network, or \
665 process APIs. Proc-macros execute at compile time with full system access, \
666 making them a supply chain attack vector. Patterns include: std::fs, \
667 std::net, std::process::Command, reqwest, and similar."
668 .to_string(),
669 help_uri: Some(
670 "https://doc.rust-lang.org/reference/procedural-macros.html".to_string(),
671 ),
672 default_severity: Severity::High,
673 origin: RuleOrigin::BuiltIn,
674 cwe_ids: Vec::new(),
675 fix_suggestion: None,
676 exploitability: Exploitability::default(),
677 },
678 }
679 }
680
681 fn suspicious_patterns() -> &'static [(&'static str, &'static str)] {
683 &[
684 ("std::fs::", "filesystem access in proc-macro"),
685 ("std::net::", "network access in proc-macro"),
686 ("std::process::Command", "process spawning in proc-macro"),
687 ("tokio::", "async runtime in proc-macro (unusual)"),
688 ("reqwest::", "HTTP client in proc-macro"),
689 ("hyper::", "HTTP library in proc-macro"),
690 ("curl::", "curl bindings in proc-macro"),
691 ("attohttpc::", "HTTP client in proc-macro"),
692 ("ureq::", "HTTP client in proc-macro"),
693 ("env!(", "environment variable access (may leak secrets)"),
694 ("include_bytes!", "includes external file at compile time"),
695 ("include_str!", "includes external file at compile time"),
696 ]
697 }
698}
699
700impl Rule for ProcMacroSideEffectsRule {
701 fn metadata(&self) -> &RuleMetadata {
702 &self.metadata
703 }
704
705 fn evaluate(
706 &self,
707 package: &MirPackage,
708 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
709 ) -> Vec<Finding> {
710 if package.crate_name == "mir-extractor" {
711 return Vec::new();
712 }
713
714 let mut findings = Vec::new();
715 let crate_root = Path::new(&package.crate_root);
716
717 if !crate_root.exists() {
718 return findings;
719 }
720
721 let cargo_toml = crate_root.join("Cargo.toml");
723 if !cargo_toml.exists() {
724 return findings;
725 }
726
727 let cargo_content = match std::fs::read_to_string(&cargo_toml) {
728 Ok(c) => c,
729 Err(_) => return findings,
730 };
731
732 let is_proc_macro = cargo_content.contains("proc-macro = true")
734 || cargo_content.contains("proc_macro = true");
735
736 if !is_proc_macro {
737 return findings;
738 }
739
740 for entry in WalkDir::new(crate_root)
742 .into_iter()
743 .filter_entry(|e| filter_entry(e))
744 {
745 let entry = match entry {
746 Ok(e) => e,
747 Err(_) => continue,
748 };
749
750 if !entry.file_type().is_file() {
751 continue;
752 }
753
754 let path = entry.path();
755 if path.extension() != Some(std::ffi::OsStr::new("rs")) {
756 continue;
757 }
758
759 let rel_path = path
760 .strip_prefix(crate_root)
761 .unwrap_or(path)
762 .to_string_lossy()
763 .replace('\\', "/");
764
765 let content = match std::fs::read_to_string(path) {
766 Ok(c) => c,
767 Err(_) => continue,
768 };
769
770 let lines: Vec<&str> = content.lines().collect();
771
772 for (idx, line) in lines.iter().enumerate() {
773 let trimmed = line.trim();
774
775 if trimmed.starts_with("//") {
777 continue;
778 }
779
780 for (pattern, description) in Self::suspicious_patterns() {
781 if trimmed.contains(pattern) {
782 let location = format!("{}:{}", rel_path, idx + 1);
783
784 findings.push(Finding {
785 rule_id: self.metadata.id.clone(),
786 rule_name: self.metadata.name.clone(),
787 severity: self.metadata.default_severity,
788 message: format!(
789 "Suspicious {} in proc-macro crate. Proc-macros execute at \
790 compile time with full system access. This could be a supply \
791 chain attack vector. Review carefully.",
792 description
793 ),
794 function: location,
795 function_signature: String::new(),
796 evidence: vec![trimmed.to_string()],
797 span: None,
798 ..Default::default()
799 });
800 }
801 }
802 }
803 }
804
805 findings
806 }
807}
808
809pub fn register_supply_chain_rules(engine: &mut crate::RuleEngine) {
811 engine.register_rule(Box::new(RustsecUnsoundDependencyRule::new()));
812 engine.register_rule(Box::new(YankedCrateRule::new()));
813 engine.register_rule(Box::new(CargoAuditableMetadataRule::new()));
814 engine.register_rule(Box::new(ProcMacroSideEffectsRule::new()));
815}