1use std::path::Path;
33
34use fleetreach_core::{DependencyKind, Ecosystem, FleetReport, Occurrence};
35use walkdir::WalkDir;
36
37use crate::config::Config;
38
39pub fn assess(report: &mut FleetReport, config: &Config) {
41 for finding in &mut report.vulnerabilities {
42 if finding.ecosystem.is_cargo() {
43 assess_cargo_symbols(finding, config);
44 } else if let Some(scan) = import_scanner(finding.ecosystem) {
45 assess_tier_c_imports(finding, config, scan);
46 }
47 }
49}
50
51fn assess_cargo_symbols(finding: &mut fleetreach_core::VulnFinding, config: &Config) {
54 if finding.affected_functions.is_empty() {
55 return; }
57 let names: Vec<&str> = finding
59 .affected_functions
60 .iter()
61 .map(|p| p.rsplit("::").next().unwrap_or(p.as_str()))
62 .collect();
63 let repos: std::collections::BTreeSet<&str> = finding
65 .occurrences
66 .iter()
67 .filter_map(|o| match o {
68 Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
69 Occurrence::Toolchain { .. } => None,
70 })
71 .collect();
72
73 let found = repos.iter().any(|repo_id| {
74 config
75 .repos
76 .iter()
77 .find(|r| r.id.0 == *repo_id)
78 .is_some_and(|r| source_mentions_symbol(&r.path, &names))
79 });
80 finding.reachable = Some(found);
81}
82
83fn source_mentions_symbol(dir: &Path, names: &[&str]) -> bool {
85 scan_source(dir, &["rs"], &[], |text| {
86 names.iter().any(|n| mentions(text, n))
87 })
88}
89
90fn mentions(text: &str, name: &str) -> bool {
93 text.contains(&format!("{name}("))
94 || text.contains(&format!(".{name}"))
95 || text.contains(&format!("::{name}"))
96}
97
98type ImportPredicate = fn(text: &str, package: &str) -> bool;
102
103fn import_scanner(eco: Ecosystem) -> Option<(&'static [&'static str], ImportPredicate)> {
111 match eco {
112 Ecosystem::Npm => Some((&["js", "mjs", "cjs", "ts", "tsx", "jsx"], npm_imports_text)),
114 Ecosystem::Julia => Some((&["jl"], julia_imports_text)),
115 Ecosystem::RubyGems => Some((&["rb", "rake"], rubygems_imports_text)),
116 Ecosystem::Pypi => Some((&["py"], pypi_imports_text)),
118 Ecosystem::NuGet => Some((&["cs", "fs", "vb"], nuget_imports_text)),
119 Ecosystem::Maven => Some((&["java", "kt", "scala", "groovy"], maven_imports_text)),
120 Ecosystem::Packagist => Some((&["php"], packagist_imports_text)),
121 Ecosystem::Swift => Some((&["swift"], swift_imports_text)),
122 Ecosystem::Hex => Some((&["ex", "exs"], hex_module_used_text)),
123 Ecosystem::GitHubActions => Some((&["yml", "yaml"], ghactions_uses_text)),
125 _ => None,
126 }
127}
128
129fn assess_tier_c_imports(
132 finding: &mut fleetreach_core::VulnFinding,
133 config: &Config,
134 (exts, pred): (&'static [&'static str], ImportPredicate),
135) {
136 let imported = finding.occurrences.iter().any(|o| match o {
137 Occurrence::InRepo {
138 repo,
139 package,
140 dependency_kind: DependencyKind::Direct,
141 ..
142 } => config
143 .repos
144 .iter()
145 .find(|r| r.id.0 == repo.0)
146 .is_some_and(|r| scan_source(&r.path, exts, &[], |text| pred(text, package))),
147 _ => false,
150 });
151 if imported {
152 finding.reachable = Some(true);
153 }
154}
155
156fn npm_imports_text(text: &str, pkg: &str) -> bool {
160 let specifiers = [
161 format!("'{pkg}'"),
162 format!("\"{pkg}\""),
163 format!("'{pkg}/"),
164 format!("\"{pkg}/"),
165 ];
166 text.lines().any(|line| {
167 (line.contains("require") || line.contains("import") || line.contains("from"))
168 && specifiers.iter().any(|s| line.contains(s.as_str()))
169 })
170}
171
172fn julia_imports_text(text: &str, pkg: &str) -> bool {
175 text.lines().any(|line| {
176 let t = line.trim_start();
177 (t.starts_with("using ") || t.starts_with("import ")) && word_present(line, pkg)
178 })
179}
180
181fn rubygems_imports_text(text: &str, pkg: &str) -> bool {
185 let needles = [
186 format!("'{pkg}'"),
187 format!("\"{pkg}\""),
188 format!("'{pkg}/"),
189 format!("\"{pkg}/"),
190 ];
191 text.lines()
192 .any(|line| line.contains("require") && needles.iter().any(|n| line.contains(n.as_str())))
193}
194
195fn pypi_imports_text(text: &str, pkg: &str) -> bool {
199 let module = pkg.to_ascii_lowercase().replace(['-', '.'], "_");
200 let candidates = [module, pkg.to_ascii_lowercase()];
201 text.lines().any(|line| {
202 let t = line.trim_start();
203 (t.starts_with("import ") || t.starts_with("from "))
204 && candidates
205 .iter()
206 .any(|c| !c.is_empty() && word_present(line, c))
207 })
208}
209
210fn nuget_imports_text(text: &str, pkg: &str) -> bool {
213 text.lines().any(|line| {
214 let t = line.trim_start();
215 t.strip_prefix("using ")
216 .or_else(|| t.strip_prefix("global using "))
217 .map(str::trim_start)
218 .is_some_and(|rest| namespace_starts_with(rest, pkg))
219 })
220}
221
222fn maven_imports_text(text: &str, pkg: &str) -> bool {
226 let Some((group, _artifact)) = pkg.split_once(':') else {
227 return false;
228 };
229 if group.is_empty() {
230 return false;
231 }
232 text.lines().any(|line| {
233 let t = line.trim_start();
234 t.strip_prefix("import ")
235 .map(|r| r.strip_prefix("static ").unwrap_or(r))
236 .map(str::trim_start)
237 .is_some_and(|rest| namespace_starts_with(rest, group))
238 })
239}
240
241fn packagist_imports_text(text: &str, pkg: &str) -> bool {
246 let candidates: Vec<String> = pkg
247 .split('/')
248 .map(pascal_case)
249 .filter(|c| !c.is_empty())
250 .collect();
251 if candidates.is_empty() {
252 return false;
253 }
254 text.lines().any(|line| {
255 let t = line.trim_start();
256 t.starts_with("use ") && candidates.iter().any(|c| namespace_segment_present(t, c))
257 })
258}
259
260fn swift_imports_text(text: &str, pkg: &str) -> bool {
264 let id = pkg.rsplit('/').next().unwrap_or(pkg);
265 let stripped = id
266 .strip_prefix("swift-")
267 .or_else(|| id.strip_prefix("Swift"))
268 .unwrap_or(id);
269 let candidates = [id.to_string(), stripped.replace('-', "")];
270 text.lines().any(|line| {
271 let t = line.trim_start();
272 t.strip_prefix("import ").map(str::trim).is_some_and(|m| {
273 candidates
274 .iter()
275 .any(|c| !c.is_empty() && m.eq_ignore_ascii_case(c))
276 })
277 })
278}
279
280fn hex_module_used_text(text: &str, pkg: &str) -> bool {
285 let module = pascal_case(pkg);
286 if module.is_empty() {
287 return false;
288 }
289 text.lines().any(|line| {
290 let t = line.trim_start();
291 let directive = (t.starts_with("alias ")
292 || t.starts_with("import ")
293 || t.starts_with("use ")
294 || t.starts_with("require "))
295 && word_present(t, &module);
296 directive || module_qualified(line, &module)
297 })
298}
299
300fn module_qualified(line: &str, module: &str) -> bool {
304 let needle = format!("{module}.");
305 line.match_indices(&needle).any(|(i, _)| {
306 line[..i]
307 .chars()
308 .next_back()
309 .is_none_or(|c| !is_ident_char(c) && c != '.')
310 })
311}
312
313fn ghactions_uses_text(text: &str, pkg: &str) -> bool {
317 let needle = format!("{pkg}@");
318 text.lines().any(|line| {
319 let low = line.to_ascii_lowercase();
320 low.contains("uses:") && low.contains(&needle)
321 })
322}
323
324fn namespace_starts_with(path: &str, prefix: &str) -> bool {
327 path.strip_prefix(prefix)
328 .is_some_and(|rest| rest.chars().next().is_none_or(|c| !is_ident_char(c)))
329}
330
331fn namespace_segment_present(line: &str, segment: &str) -> bool {
333 line.match_indices(segment).any(|(i, _)| {
334 let before = line[..i].chars().next_back();
335 let after = line[i + segment.len()..].chars().next();
336 before.is_none_or(|c| c == '\\' || c == ' ') && after.is_none_or(|c| !is_ident_char(c))
337 })
338}
339
340fn is_ident_char(c: char) -> bool {
341 c.is_ascii_alphanumeric() || c == '_'
342}
343
344fn pascal_case(s: &str) -> String {
346 s.split(['-', '_'])
347 .filter(|w| !w.is_empty())
348 .map(|w| {
349 let mut chars = w.chars();
350 match chars.next() {
351 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
352 None => String::new(),
353 }
354 })
355 .collect()
356}
357
358fn word_present(hay: &str, word: &str) -> bool {
361 if word.is_empty() {
362 return false;
363 }
364 let bytes = hay.as_bytes();
365 let mut from = 0;
366 while let Some(rel) = hay[from..].find(word) {
367 let start = from + rel;
368 let end = start + word.len();
369 let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
370 let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
371 if before_ok && after_ok {
372 return true;
373 }
374 from = start + 1;
375 }
376 false
377}
378
379fn is_ident_byte(b: u8) -> bool {
380 b.is_ascii_alphanumeric() || b == b'_'
381}
382
383fn scan_source(dir: &Path, exts: &[&str], names: &[&str], pred: impl Fn(&str) -> bool) -> bool {
386 const SKIP: &[&str] = &["target", "node_modules", "vendor", ".git", "dist", "build"];
387 WalkDir::new(dir)
388 .into_iter()
389 .filter_entry(|e| !SKIP.contains(&e.file_name().to_str().unwrap_or("")))
390 .filter_map(Result::ok)
391 .filter(|e| e.file_type().is_file())
392 .filter(|e| {
393 let p = e.path();
394 let ext_ok = p
395 .extension()
396 .and_then(|x| x.to_str())
397 .is_some_and(|x| exts.contains(&x));
398 let name_ok = p
399 .file_name()
400 .and_then(|n| n.to_str())
401 .is_some_and(|n| names.contains(&n));
402 ext_ok || name_ok
403 })
404 .any(|e| {
405 std::fs::read_to_string(e.path())
406 .map(|text| pred(&text))
407 .unwrap_or(false)
408 })
409}
410
411#[cfg(test)]
412mod tests {
413 #![allow(clippy::unwrap_used)]
414 use super::*;
415
416 #[test]
417 fn npm_detects_require_and_import_forms() {
418 assert!(npm_imports_text("const _ = require('lodash')", "lodash"));
419 assert!(npm_imports_text("import x from \"lodash\"", "lodash"));
420 assert!(npm_imports_text("import { a } from 'lodash/fp'", "lodash"));
421 assert!(npm_imports_text("await import('lodash')", "lodash"));
422 assert!(npm_imports_text("import x from '@scope/pkg'", "@scope/pkg"));
423 assert!(!npm_imports_text("const s = 'lodash'", "lodash"));
425 assert!(!npm_imports_text("require('lodash-es')", "lodash"));
426 assert!(!npm_imports_text("import x from 'react'", "lodash"));
427 }
428
429 #[test]
430 fn julia_detects_using_and_import_whole_word() {
431 assert!(julia_imports_text("using HTTP", "HTTP"));
432 assert!(julia_imports_text(" import HTTP", "HTTP"));
433 assert!(julia_imports_text("using HTTP, JSON", "JSON"));
434 assert!(julia_imports_text("import HTTP: get", "HTTP"));
435 assert!(julia_imports_text("using HTTP.Sub", "HTTP"));
436 assert!(!julia_imports_text("using HTTPClient", "HTTP"));
438 assert!(!julia_imports_text("x = HTTP", "HTTP"));
440 }
441
442 #[test]
443 fn rubygems_detects_require_forms() {
444 assert!(rubygems_imports_text("require 'rack'", "rack"));
445 assert!(rubygems_imports_text("require \"rack\"", "rack"));
446 assert!(rubygems_imports_text("require 'rack/utils'", "rack"));
447 assert!(!rubygems_imports_text("require 'rackup'", "rack"));
449 assert!(!rubygems_imports_text("rack = 1", "rack"));
450 }
451
452 #[test]
453 fn word_present_respects_boundaries() {
454 assert!(word_present("using Foo, Bar", "Foo"));
455 assert!(word_present("a Foo b", "Foo"));
456 assert!(!word_present("Foobar", "Foo"));
457 assert!(!word_present("myFoo", "Foo"));
458 }
459
460 #[test]
461 fn pypi_maps_dist_name_to_module() {
462 assert!(pypi_imports_text("import requests", "requests"));
463 assert!(pypi_imports_text("from flask import Flask", "Flask")); assert!(pypi_imports_text(
465 "import python_dateutil",
466 "python-dateutil"
467 )); assert!(pypi_imports_text("import requests.sessions", "requests"));
469 assert!(!pypi_imports_text("x = requests", "requests"));
471 assert!(!pypi_imports_text("import yaml", "PyYAML"));
472 }
473
474 #[test]
475 fn nuget_matches_using_namespace() {
476 assert!(nuget_imports_text(
477 "using Newtonsoft.Json;",
478 "Newtonsoft.Json"
479 ));
480 assert!(nuget_imports_text(
481 "using Newtonsoft.Json.Linq;",
482 "Newtonsoft.Json"
483 ));
484 assert!(nuget_imports_text("global using Serilog;", "Serilog"));
485 assert!(!nuget_imports_text(
487 "using Newtonsoft.JsonNet;",
488 "Newtonsoft.Json"
489 ));
490 assert!(!nuget_imports_text("var x = Serilog;", "Serilog"));
491 }
492
493 #[test]
494 fn maven_matches_group_import_prefix() {
495 let coord = "org.apache.logging.log4j:log4j-core";
496 assert!(maven_imports_text(
497 "import org.apache.logging.log4j.Logger;",
498 coord
499 ));
500 assert!(maven_imports_text(
501 "import static org.apache.logging.log4j.Level.INFO;",
502 coord
503 ));
504 assert!(!maven_imports_text("import org.slf4j.Logger;", coord));
506 }
507
508 #[test]
509 fn packagist_matches_pascal_cased_namespace() {
510 assert!(packagist_imports_text(
511 "use Monolog\\Logger;",
512 "monolog/monolog"
513 ));
514 assert!(packagist_imports_text(
515 "use Symfony\\Component\\HttpKernel\\Kernel;",
516 "symfony/http-kernel"
517 ));
518 assert!(!packagist_imports_text(
520 "$x = new Monolog();",
521 "monolog/monolog"
522 ));
523 }
524
525 #[test]
526 fn swift_strips_prefix_and_matches_module() {
527 assert!(swift_imports_text("import NIO", "swift-nio"));
528 assert!(swift_imports_text("import Vapor", "vapor/vapor"));
529 assert!(!swift_imports_text("let nio = 1", "swift-nio"));
531 }
532
533 #[test]
534 fn pascal_case_splits_separators() {
535 assert_eq!(pascal_case("http-kernel"), "HttpKernel");
536 assert_eq!(pascal_case("monolog"), "Monolog");
537 assert_eq!(pascal_case("php_unit"), "PhpUnit");
538 }
539
540 #[test]
541 fn hex_matches_pascal_module_usage() {
542 assert!(hex_module_used_text(
544 " Plug.Conn.send_resp(conn)",
545 "plug"
546 ));
547 assert!(hex_module_used_text(
548 " alias Phoenix.Controller",
549 "phoenix"
550 ));
551 assert!(hex_module_used_text(" use Phoenix.Router", "phoenix"));
552 assert!(hex_module_used_text("import FooBar", "foo_bar")); assert!(!hex_module_used_text("MyApp.Plug.call()", "plug"));
555 assert!(!hex_module_used_text("# plug is great", "plug"));
557 }
558
559 #[test]
560 fn ghactions_matches_uses_reference() {
561 assert!(ghactions_uses_text(
562 " - uses: actions/checkout@v4",
563 "actions/checkout"
564 ));
565 assert!(ghactions_uses_text(
567 " - uses: Actions/Checkout@v4",
568 "actions/checkout"
569 ));
570 assert!(ghactions_uses_text(
572 " - uses: github/codeql-action/analyze@v3",
573 "github/codeql-action/analyze"
574 ));
575 assert!(!ghactions_uses_text(
577 " - uses: actions/setup-node@v4",
578 "actions/checkout"
579 ));
580 assert!(!ghactions_uses_text(
581 " name: actions/checkout",
582 "actions/checkout"
583 ));
584 }
585}