use std::path::Path;
use fleetreach_core::{DependencyKind, Ecosystem, FleetReport, Occurrence};
use walkdir::WalkDir;
use crate::config::Config;
pub fn assess(report: &mut FleetReport, config: &Config) {
for finding in &mut report.vulnerabilities {
if finding.ecosystem.is_cargo() {
assess_cargo_symbols(finding, config);
} else if let Some(scan) = import_scanner(finding.ecosystem) {
assess_tier_c_imports(finding, config, scan);
}
}
}
fn assess_cargo_symbols(finding: &mut fleetreach_core::VulnFinding, config: &Config) {
if finding.affected_functions.is_empty() {
return; }
let names: Vec<&str> = finding
.affected_functions
.iter()
.map(|p| p.rsplit("::").next().unwrap_or(p.as_str()))
.collect();
let repos: std::collections::BTreeSet<&str> = finding
.occurrences
.iter()
.filter_map(|o| match o {
Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
Occurrence::Toolchain { .. } => None,
})
.collect();
let found = repos.iter().any(|repo_id| {
config
.repos
.iter()
.find(|r| r.id.0 == *repo_id)
.is_some_and(|r| source_mentions_symbol(&r.path, &names))
});
finding.reachable = Some(found);
}
fn source_mentions_symbol(dir: &Path, names: &[&str]) -> bool {
scan_source(dir, &["rs"], &[], |text| {
names.iter().any(|n| mentions(text, n))
})
}
fn mentions(text: &str, name: &str) -> bool {
text.contains(&format!("{name}("))
|| text.contains(&format!(".{name}"))
|| text.contains(&format!("::{name}"))
}
type ImportPredicate = fn(text: &str, package: &str) -> bool;
fn import_scanner(eco: Ecosystem) -> Option<(&'static [&'static str], ImportPredicate)> {
match eco {
Ecosystem::Npm => Some((&["js", "mjs", "cjs", "ts", "tsx", "jsx"], npm_imports_text)),
Ecosystem::Julia => Some((&["jl"], julia_imports_text)),
Ecosystem::RubyGems => Some((&["rb", "rake"], rubygems_imports_text)),
Ecosystem::Pypi => Some((&["py"], pypi_imports_text)),
Ecosystem::NuGet => Some((&["cs", "fs", "vb"], nuget_imports_text)),
Ecosystem::Maven => Some((&["java", "kt", "scala", "groovy"], maven_imports_text)),
Ecosystem::Packagist => Some((&["php"], packagist_imports_text)),
Ecosystem::Swift => Some((&["swift"], swift_imports_text)),
Ecosystem::Hex => Some((&["ex", "exs"], hex_module_used_text)),
Ecosystem::GitHubActions => Some((&["yml", "yaml"], ghactions_uses_text)),
_ => None,
}
}
fn assess_tier_c_imports(
finding: &mut fleetreach_core::VulnFinding,
config: &Config,
(exts, pred): (&'static [&'static str], ImportPredicate),
) {
let imported = finding.occurrences.iter().any(|o| match o {
Occurrence::InRepo {
repo,
package,
dependency_kind: DependencyKind::Direct,
..
} => config
.repos
.iter()
.find(|r| r.id.0 == repo.0)
.is_some_and(|r| scan_source(&r.path, exts, &[], |text| pred(text, package))),
_ => false,
});
if imported {
finding.reachable = Some(true);
}
}
fn npm_imports_text(text: &str, pkg: &str) -> bool {
let specifiers = [
format!("'{pkg}'"),
format!("\"{pkg}\""),
format!("'{pkg}/"),
format!("\"{pkg}/"),
];
text.lines().any(|line| {
(line.contains("require") || line.contains("import") || line.contains("from"))
&& specifiers.iter().any(|s| line.contains(s.as_str()))
})
}
fn julia_imports_text(text: &str, pkg: &str) -> bool {
text.lines().any(|line| {
let t = line.trim_start();
(t.starts_with("using ") || t.starts_with("import ")) && word_present(line, pkg)
})
}
fn rubygems_imports_text(text: &str, pkg: &str) -> bool {
let needles = [
format!("'{pkg}'"),
format!("\"{pkg}\""),
format!("'{pkg}/"),
format!("\"{pkg}/"),
];
text.lines()
.any(|line| line.contains("require") && needles.iter().any(|n| line.contains(n.as_str())))
}
fn pypi_imports_text(text: &str, pkg: &str) -> bool {
let module = pkg.to_ascii_lowercase().replace(['-', '.'], "_");
let candidates = [module, pkg.to_ascii_lowercase()];
text.lines().any(|line| {
let t = line.trim_start();
(t.starts_with("import ") || t.starts_with("from "))
&& candidates
.iter()
.any(|c| !c.is_empty() && word_present(line, c))
})
}
fn nuget_imports_text(text: &str, pkg: &str) -> bool {
text.lines().any(|line| {
let t = line.trim_start();
t.strip_prefix("using ")
.or_else(|| t.strip_prefix("global using "))
.map(str::trim_start)
.is_some_and(|rest| namespace_starts_with(rest, pkg))
})
}
fn maven_imports_text(text: &str, pkg: &str) -> bool {
let Some((group, _artifact)) = pkg.split_once(':') else {
return false;
};
if group.is_empty() {
return false;
}
text.lines().any(|line| {
let t = line.trim_start();
t.strip_prefix("import ")
.map(|r| r.strip_prefix("static ").unwrap_or(r))
.map(str::trim_start)
.is_some_and(|rest| namespace_starts_with(rest, group))
})
}
fn packagist_imports_text(text: &str, pkg: &str) -> bool {
let candidates: Vec<String> = pkg
.split('/')
.map(pascal_case)
.filter(|c| !c.is_empty())
.collect();
if candidates.is_empty() {
return false;
}
text.lines().any(|line| {
let t = line.trim_start();
t.starts_with("use ") && candidates.iter().any(|c| namespace_segment_present(t, c))
})
}
fn swift_imports_text(text: &str, pkg: &str) -> bool {
let id = pkg.rsplit('/').next().unwrap_or(pkg);
let stripped = id
.strip_prefix("swift-")
.or_else(|| id.strip_prefix("Swift"))
.unwrap_or(id);
let candidates = [id.to_string(), stripped.replace('-', "")];
text.lines().any(|line| {
let t = line.trim_start();
t.strip_prefix("import ").map(str::trim).is_some_and(|m| {
candidates
.iter()
.any(|c| !c.is_empty() && m.eq_ignore_ascii_case(c))
})
})
}
fn hex_module_used_text(text: &str, pkg: &str) -> bool {
let module = pascal_case(pkg);
if module.is_empty() {
return false;
}
text.lines().any(|line| {
let t = line.trim_start();
let directive = (t.starts_with("alias ")
|| t.starts_with("import ")
|| t.starts_with("use ")
|| t.starts_with("require "))
&& word_present(t, &module);
directive || module_qualified(line, &module)
})
}
fn module_qualified(line: &str, module: &str) -> bool {
let needle = format!("{module}.");
line.match_indices(&needle).any(|(i, _)| {
line[..i]
.chars()
.next_back()
.is_none_or(|c| !is_ident_char(c) && c != '.')
})
}
fn ghactions_uses_text(text: &str, pkg: &str) -> bool {
let needle = format!("{pkg}@");
text.lines().any(|line| {
let low = line.to_ascii_lowercase();
low.contains("uses:") && low.contains(&needle)
})
}
fn namespace_starts_with(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix)
.is_some_and(|rest| rest.chars().next().is_none_or(|c| !is_ident_char(c)))
}
fn namespace_segment_present(line: &str, segment: &str) -> bool {
line.match_indices(segment).any(|(i, _)| {
let before = line[..i].chars().next_back();
let after = line[i + segment.len()..].chars().next();
before.is_none_or(|c| c == '\\' || c == ' ') && after.is_none_or(|c| !is_ident_char(c))
})
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn pascal_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
fn word_present(hay: &str, word: &str) -> bool {
if word.is_empty() {
return false;
}
let bytes = hay.as_bytes();
let mut from = 0;
while let Some(rel) = hay[from..].find(word) {
let start = from + rel;
let end = start + word.len();
let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn scan_source(dir: &Path, exts: &[&str], names: &[&str], pred: impl Fn(&str) -> bool) -> bool {
const SKIP: &[&str] = &["target", "node_modules", "vendor", ".git", "dist", "build"];
WalkDir::new(dir)
.into_iter()
.filter_entry(|e| !SKIP.contains(&e.file_name().to_str().unwrap_or("")))
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
let p = e.path();
let ext_ok = p
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| exts.contains(&x));
let name_ok = p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| names.contains(&n));
ext_ok || name_ok
})
.any(|e| {
std::fs::read_to_string(e.path())
.map(|text| pred(&text))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn npm_detects_require_and_import_forms() {
assert!(npm_imports_text("const _ = require('lodash')", "lodash"));
assert!(npm_imports_text("import x from \"lodash\"", "lodash"));
assert!(npm_imports_text("import { a } from 'lodash/fp'", "lodash"));
assert!(npm_imports_text("await import('lodash')", "lodash"));
assert!(npm_imports_text("import x from '@scope/pkg'", "@scope/pkg"));
assert!(!npm_imports_text("const s = 'lodash'", "lodash"));
assert!(!npm_imports_text("require('lodash-es')", "lodash"));
assert!(!npm_imports_text("import x from 'react'", "lodash"));
}
#[test]
fn julia_detects_using_and_import_whole_word() {
assert!(julia_imports_text("using HTTP", "HTTP"));
assert!(julia_imports_text(" import HTTP", "HTTP"));
assert!(julia_imports_text("using HTTP, JSON", "JSON"));
assert!(julia_imports_text("import HTTP: get", "HTTP"));
assert!(julia_imports_text("using HTTP.Sub", "HTTP"));
assert!(!julia_imports_text("using HTTPClient", "HTTP"));
assert!(!julia_imports_text("x = HTTP", "HTTP"));
}
#[test]
fn rubygems_detects_require_forms() {
assert!(rubygems_imports_text("require 'rack'", "rack"));
assert!(rubygems_imports_text("require \"rack\"", "rack"));
assert!(rubygems_imports_text("require 'rack/utils'", "rack"));
assert!(!rubygems_imports_text("require 'rackup'", "rack"));
assert!(!rubygems_imports_text("rack = 1", "rack"));
}
#[test]
fn word_present_respects_boundaries() {
assert!(word_present("using Foo, Bar", "Foo"));
assert!(word_present("a Foo b", "Foo"));
assert!(!word_present("Foobar", "Foo"));
assert!(!word_present("myFoo", "Foo"));
}
#[test]
fn pypi_maps_dist_name_to_module() {
assert!(pypi_imports_text("import requests", "requests"));
assert!(pypi_imports_text("from flask import Flask", "Flask")); assert!(pypi_imports_text(
"import python_dateutil",
"python-dateutil"
)); assert!(pypi_imports_text("import requests.sessions", "requests"));
assert!(!pypi_imports_text("x = requests", "requests"));
assert!(!pypi_imports_text("import yaml", "PyYAML"));
}
#[test]
fn nuget_matches_using_namespace() {
assert!(nuget_imports_text(
"using Newtonsoft.Json;",
"Newtonsoft.Json"
));
assert!(nuget_imports_text(
"using Newtonsoft.Json.Linq;",
"Newtonsoft.Json"
));
assert!(nuget_imports_text("global using Serilog;", "Serilog"));
assert!(!nuget_imports_text(
"using Newtonsoft.JsonNet;",
"Newtonsoft.Json"
));
assert!(!nuget_imports_text("var x = Serilog;", "Serilog"));
}
#[test]
fn maven_matches_group_import_prefix() {
let coord = "org.apache.logging.log4j:log4j-core";
assert!(maven_imports_text(
"import org.apache.logging.log4j.Logger;",
coord
));
assert!(maven_imports_text(
"import static org.apache.logging.log4j.Level.INFO;",
coord
));
assert!(!maven_imports_text("import org.slf4j.Logger;", coord));
}
#[test]
fn packagist_matches_pascal_cased_namespace() {
assert!(packagist_imports_text(
"use Monolog\\Logger;",
"monolog/monolog"
));
assert!(packagist_imports_text(
"use Symfony\\Component\\HttpKernel\\Kernel;",
"symfony/http-kernel"
));
assert!(!packagist_imports_text(
"$x = new Monolog();",
"monolog/monolog"
));
}
#[test]
fn swift_strips_prefix_and_matches_module() {
assert!(swift_imports_text("import NIO", "swift-nio"));
assert!(swift_imports_text("import Vapor", "vapor/vapor"));
assert!(!swift_imports_text("let nio = 1", "swift-nio"));
}
#[test]
fn pascal_case_splits_separators() {
assert_eq!(pascal_case("http-kernel"), "HttpKernel");
assert_eq!(pascal_case("monolog"), "Monolog");
assert_eq!(pascal_case("php_unit"), "PhpUnit");
}
#[test]
fn hex_matches_pascal_module_usage() {
assert!(hex_module_used_text(
" Plug.Conn.send_resp(conn)",
"plug"
));
assert!(hex_module_used_text(
" alias Phoenix.Controller",
"phoenix"
));
assert!(hex_module_used_text(" use Phoenix.Router", "phoenix"));
assert!(hex_module_used_text("import FooBar", "foo_bar")); assert!(!hex_module_used_text("MyApp.Plug.call()", "plug"));
assert!(!hex_module_used_text("# plug is great", "plug"));
}
#[test]
fn ghactions_matches_uses_reference() {
assert!(ghactions_uses_text(
" - uses: actions/checkout@v4",
"actions/checkout"
));
assert!(ghactions_uses_text(
" - uses: Actions/Checkout@v4",
"actions/checkout"
));
assert!(ghactions_uses_text(
" - uses: github/codeql-action/analyze@v3",
"github/codeql-action/analyze"
));
assert!(!ghactions_uses_text(
" - uses: actions/setup-node@v4",
"actions/checkout"
));
assert!(!ghactions_uses_text(
" name: actions/checkout",
"actions/checkout"
));
}
}