use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use walkdir::WalkDir;
pub(crate) const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
fn canonicalize_path(path: &str) -> Result<PathBuf, std::io::Error> {
let canonical = fs::canonicalize(path)?;
if !canonical.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Path is not a directory: {}", canonical.display()),
));
}
Ok(canonical)
}
#[derive(Debug)]
pub struct FileTooLargeError {
pub path: PathBuf,
pub size: u64,
pub limit: u64,
}
impl std::fmt::Display for FileTooLargeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"File too large: {} ({} bytes exceeds {} byte limit)",
self.path.display(),
self.size,
self.limit
)
}
}
impl std::error::Error for FileTooLargeError {}
pub(crate) fn read_file_with_limit(path: &Path, max_size: u64) -> Result<String, std::io::Error> {
let metadata = fs::metadata(path)?;
let file_size = metadata.len();
if file_size > max_size {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FileTooLargeError {
path: path.to_path_buf(),
size: file_size,
limit: max_size,
},
));
}
let mut file = File::open(path)?;
let mut content = String::with_capacity(file_size as usize);
file.read_to_string(&mut content)?;
Ok(content)
}
fn extract_toml_version(value: &toml::Value) -> Option<String> {
match value {
toml::Value::String(s) => clean_version_req(s),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.and_then(clean_version_req),
_ => None,
}
}
fn clean_version_req(raw: &str) -> Option<String> {
let trimmed = raw
.trim()
.trim_start_matches(['^', '~', '=', '>', '<', ' '])
.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum Ecosystem {
Cargo, Npm, PyPI, System, }
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum VersionSource {
Declared,
Locked,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CryptoFinding {
pub path: String,
pub name: String,
pub finding_type: FindingType,
pub crypto_type: CryptoType,
pub ecosystem: Ecosystem,
pub version: Option<String>,
pub version_source: VersionSource,
pub details: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum FindingType {
BinaryLibrary, StaticLibrary, DependencyManifest, TransitiveDependency, SourceCode, }
fn cargo_crypto_crates() -> Vec<(&'static str, CryptoType)> {
vec![
("ring", CryptoType::GeneralCrypto),
("rustls", CryptoType::SslTls),
("openssl", CryptoType::SslTls),
("sodiumoxide", CryptoType::GeneralCrypto),
("chacha20", CryptoType::GeneralCrypto),
("aes", CryptoType::GeneralCrypto),
("sha2", CryptoType::HashFunction),
("sha3", CryptoType::HashFunction),
("blake2", CryptoType::HashFunction),
("blake3", CryptoType::HashFunction),
("argon2", CryptoType::GeneralCrypto),
("ed25519", CryptoType::GeneralCrypto),
("x25519", CryptoType::GeneralCrypto),
("rsa", CryptoType::GeneralCrypto),
("pqcrypto", CryptoType::PostQuantum),
("oqs", CryptoType::PostQuantum),
]
}
#[cfg(test)]
pub(crate) fn npm_crypto_packages_for_test() -> Vec<(&'static str, CryptoType)> {
npm_crypto_packages()
}
fn npm_crypto_packages() -> Vec<(&'static str, CryptoType)> {
vec![
("crypto", CryptoType::GeneralCrypto),
("crypto-js", CryptoType::GeneralCrypto),
("crypto-browserify", CryptoType::GeneralCrypto),
("node-forge", CryptoType::GeneralCrypto),
("jsrsasign", CryptoType::GeneralCrypto),
("openpgp", CryptoType::GeneralCrypto),
("tweetnacl", CryptoType::GeneralCrypto),
("tweetnacl-util", CryptoType::GeneralCrypto),
("libsodium", CryptoType::GeneralCrypto),
("libsodium-wrappers", CryptoType::GeneralCrypto),
("sodium-native", CryptoType::GeneralCrypto),
("@stablelib/chacha20poly1305", CryptoType::GeneralCrypto),
("jose", CryptoType::GeneralCrypto),
("node-jose", CryptoType::GeneralCrypto),
("jsonwebtoken", CryptoType::GeneralCrypto),
("jws", CryptoType::GeneralCrypto),
("jwa", CryptoType::GeneralCrypto),
("ecdsa-sig-formatter", CryptoType::GeneralCrypto),
("elliptic", CryptoType::GeneralCrypto),
("secp256k1", CryptoType::GeneralCrypto),
("eccrypto", CryptoType::GeneralCrypto),
("node-rsa", CryptoType::GeneralCrypto),
("public-encrypt", CryptoType::GeneralCrypto),
("browserify-sign", CryptoType::GeneralCrypto),
("diffie-hellman", CryptoType::GeneralCrypto),
("@noble/curves", CryptoType::GeneralCrypto),
("@noble/secp256k1", CryptoType::GeneralCrypto),
("aes-js", CryptoType::GeneralCrypto),
("browserify-aes", CryptoType::GeneralCrypto),
("@noble/ciphers", CryptoType::GeneralCrypto),
("bcrypt", CryptoType::GeneralCrypto),
("bcryptjs", CryptoType::GeneralCrypto),
("argon2", CryptoType::GeneralCrypto),
("scrypt-js", CryptoType::GeneralCrypto),
("pbkdf2", CryptoType::GeneralCrypto),
("create-hmac", CryptoType::GeneralCrypto),
("randombytes", CryptoType::GeneralCrypto),
("buffer-equal-constant-time", CryptoType::GeneralCrypto),
("@noble/hashes", CryptoType::HashFunction),
("hash.js", CryptoType::HashFunction),
("sha.js", CryptoType::HashFunction),
("create-hash", CryptoType::HashFunction),
("js-sha256", CryptoType::HashFunction),
("js-sha3", CryptoType::HashFunction),
("keccak", CryptoType::HashFunction),
("blakejs", CryptoType::HashFunction),
("md5", CryptoType::HashFunction),
("selfsigned", CryptoType::SslTls),
("pem", CryptoType::SslTls),
("@noble/post-quantum", CryptoType::PostQuantum),
]
}
fn pypi_crypto_packages() -> Vec<(&'static str, CryptoType)> {
vec![
("cryptography", CryptoType::GeneralCrypto),
("pycryptodome", CryptoType::GeneralCrypto),
("pyopenssl", CryptoType::SslTls),
("pynacl", CryptoType::GeneralCrypto),
("bcrypt", CryptoType::GeneralCrypto),
]
}
pub fn normalize_pypi_name(name: &str) -> String {
let lower = name.trim().to_lowercase();
let mut out = String::with_capacity(lower.len());
let mut prev_sep = false;
for c in lower.chars() {
if matches!(c, '-' | '_' | '.') {
if !prev_sep {
out.push('-');
}
prev_sep = true;
} else {
out.push(c);
prev_sep = false;
}
}
out
}
fn match_pypi_crypto(normalized: &str) -> Option<CryptoType> {
pypi_crypto_packages()
.into_iter()
.find(|(canon, _)| normalized == *canon)
.map(|(_, t)| t)
}
fn pypi_findings_from_requirements(content: &str, path: &Path) -> Vec<CryptoFinding> {
let mut out = Vec::new();
for raw in content.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with('-') {
continue;
}
let name_end = line
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
.unwrap_or(line.len());
let raw_name = &line[..name_end];
if raw_name.is_empty() {
continue;
}
let normalized = normalize_pypi_name(raw_name);
let crypto_type = match match_pypi_crypto(&normalized) {
Some(t) => t,
None => continue,
};
let spec = line[name_end..].trim();
let (version, version_source) = if let Some(rest) = spec.strip_prefix("==") {
let ver = rest
.trim()
.split([' ', ';', ','])
.next()
.unwrap_or("")
.trim();
if ver.is_empty() {
(None, VersionSource::Declared)
} else {
(Some(ver.to_string()), VersionSource::Locked)
}
} else {
(clean_version_req(spec), VersionSource::Declared)
};
out.push(CryptoFinding {
path: path.display().to_string(),
name: normalized.clone(),
finding_type: FindingType::DependencyManifest,
crypto_type,
ecosystem: Ecosystem::PyPI,
version,
version_source,
details: format!("Python package dependency: {}", normalized),
});
}
out
}
fn pypi_findings_from_manifest(
path: &Path,
declared: &std::collections::HashSet<String>,
lock: Option<&HashMap<String, String>>,
) -> Vec<CryptoFinding> {
let mut out = Vec::new();
for (canon, crypto_type) in pypi_crypto_packages() {
let locked = lock.and_then(|l| l.get(canon)).cloned();
if declared.contains(canon) {
let (version, version_source) = resolve_version(None, locked);
out.push(CryptoFinding {
path: path.display().to_string(),
name: canon.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type,
ecosystem: Ecosystem::PyPI,
version,
version_source,
details: format!("Python package dependency: {}", canon),
});
} else if let Some(version) = locked {
out.push(CryptoFinding {
path: path.display().to_string(),
name: canon.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type,
ecosystem: Ecosystem::PyPI,
version: Some(version),
version_source: VersionSource::Locked,
details: format!("Transitive Python package (from lockfile): {}", canon),
});
}
}
out
}
fn pep508_name(req: &str) -> Option<&str> {
let req = req.trim();
let end = req
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
.unwrap_or(req.len());
if end == 0 {
None
} else {
Some(&req[..end])
}
}
fn pyproject_declared_names(parsed: &toml::Value) -> std::collections::HashSet<String> {
let mut names = std::collections::HashSet::new();
let poetry = parsed.get("tool").and_then(|t| t.get("poetry"));
if let Some(t) = poetry
.and_then(|p| p.get("dependencies"))
.and_then(|d| d.as_table())
{
for k in t.keys() {
names.insert(normalize_pypi_name(k));
}
}
if let Some(groups) = poetry
.and_then(|p| p.get("group"))
.and_then(|g| g.as_table())
{
for group in groups.values() {
if let Some(deps) = group.get("dependencies").and_then(|d| d.as_table()) {
for k in deps.keys() {
names.insert(normalize_pypi_name(k));
}
}
}
}
if let Some(project) = parsed.get("project") {
let mut arrays: Vec<&toml::Value> = Vec::new();
if let Some(d) = project.get("dependencies") {
arrays.push(d);
}
if let Some(opt) = project
.get("optional-dependencies")
.and_then(|o| o.as_table())
{
arrays.extend(opt.values());
}
for arr in arrays {
if let Some(items) = arr.as_array() {
for item in items {
if let Some(name) = item.as_str().and_then(pep508_name) {
names.insert(normalize_pypi_name(name));
}
}
}
}
}
names
}
fn pipfile_declared_names(parsed: &toml::Value) -> std::collections::HashSet<String> {
let mut names = std::collections::HashSet::new();
for section in ["packages", "dev-packages"] {
if let Some(t) = parsed.get(section).and_then(|v| v.as_table()) {
for k in t.keys() {
names.insert(normalize_pypi_name(k));
}
}
}
names
}
fn resolve_version(
declared: Option<String>,
locked: Option<String>,
) -> (Option<String>, VersionSource) {
match locked {
Some(v) => (Some(v), VersionSource::Locked),
None => (declared, VersionSource::Declared),
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum CryptoType {
SslTls, GeneralCrypto, PostQuantum, HashFunction, Unknown, }
pub struct CryptoScanner {
findings: Vec<CryptoFinding>,
library_patterns: HashMap<String, CryptoType>,
}
impl Default for CryptoScanner {
fn default() -> Self {
Self::new()
}
}
impl CryptoScanner {
pub fn new() -> Self {
let mut library_patterns = HashMap::new();
library_patterns.insert("libssl".to_string(), CryptoType::SslTls);
library_patterns.insert("libcrypto".to_string(), CryptoType::SslTls);
library_patterns.insert("openssl".to_string(), CryptoType::SslTls);
library_patterns.insert("libressl".to_string(), CryptoType::SslTls);
library_patterns.insert("boringssl".to_string(), CryptoType::SslTls);
library_patterns.insert("mbedtls".to_string(), CryptoType::SslTls);
library_patterns.insert("wolfssl".to_string(), CryptoType::SslTls);
library_patterns.insert("libsodium".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("nacl".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("bcrypt".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("cryptopp".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("bouncycastle".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("libgcrypt".to_string(), CryptoType::GeneralCrypto);
library_patterns.insert("liboqs".to_string(), CryptoType::PostQuantum);
library_patterns.insert("kyber".to_string(), CryptoType::PostQuantum);
library_patterns.insert("dilithium".to_string(), CryptoType::PostQuantum);
library_patterns.insert("falcon".to_string(), CryptoType::PostQuantum);
library_patterns.insert("sphincs".to_string(), CryptoType::PostQuantum);
Self {
findings: Vec::new(),
library_patterns,
}
}
pub fn scan(
&mut self,
root_path: &str,
max_depth: Option<usize>,
) -> Result<(), std::io::Error> {
let canonical_path = canonicalize_path(root_path)?;
let path_str = canonical_path.to_string_lossy();
println!("Starting crypto library scan at: {}", path_str);
self.scan_libraries(&path_str, max_depth)?;
self.scan_manifests(&path_str, max_depth)?;
Ok(())
}
fn scan_libraries(
&mut self,
root_path: &str,
max_depth: Option<usize>,
) -> Result<(), std::io::Error> {
let mut walker = WalkDir::new(root_path).follow_links(false);
if let Some(depth) = max_depth {
walker = walker.max_depth(depth);
}
for entry in walker.into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if self.is_library_file(filename) {
if let Some((crypto_name, crypto_type)) = self.identify_crypto_library(filename) {
self.findings.push(CryptoFinding {
path: path.display().to_string(),
name: filename.to_string(),
finding_type: if filename.ends_with(".a") {
FindingType::StaticLibrary
} else {
FindingType::BinaryLibrary
},
crypto_type,
ecosystem: Ecosystem::System,
version: None,
version_source: VersionSource::Declared,
details: format!("Detected: {}", crypto_name),
});
}
}
}
Ok(())
}
fn scan_manifests(
&mut self,
root_path: &str,
max_depth: Option<usize>,
) -> Result<(), std::io::Error> {
let mut walker = WalkDir::new(root_path).follow_links(false);
if let Some(depth) = max_depth {
walker = walker.max_depth(depth);
}
for entry in walker.into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match filename {
"Cargo.toml" => self.scan_cargo_toml(path)?,
"package.json" => self.scan_package_json(path)?,
"requirements.txt" => self.scan_requirements_txt(path)?,
"pyproject.toml" => self.scan_pyproject_toml(path)?,
"Pipfile" => self.scan_pipfile(path)?,
_ => {}
}
}
Ok(())
}
fn scan_cargo_toml(&mut self, path: &Path) -> Result<(), std::io::Error> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()), };
let crypto_crates = cargo_crypto_crates();
let lock = crate::lockfile::resolve_from_sibling_lock(path);
let mut declared: std::collections::HashSet<&str> = std::collections::HashSet::new();
if let Some(deps) = parsed.get("dependencies").and_then(|v| v.as_table()) {
for (crate_name, crypto_type) in &crypto_crates {
if let Some(dep_value) = deps.get(*crate_name) {
declared.insert(*crate_name);
let (version, version_source) = resolve_version(
extract_toml_version(dep_value),
lock.as_ref().and_then(|l| l.get(*crate_name).cloned()),
);
self.findings.push(CryptoFinding {
path: path.display().to_string(),
name: crate_name.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Cargo,
version,
version_source,
details: format!("Rust crate dependency: {}", crate_name),
});
}
}
}
if let Some(lock) = lock.as_ref() {
for (crate_name, crypto_type) in &crypto_crates {
if declared.contains(*crate_name) {
continue;
}
if let Some(version) = lock.get(*crate_name) {
self.findings.push(CryptoFinding {
path: path.display().to_string(),
name: crate_name.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Cargo,
version: Some(version.clone()),
version_source: VersionSource::Locked,
details: format!("Transitive Rust crate (from Cargo.lock): {}", crate_name),
});
}
}
}
Ok(())
}
fn scan_package_json(&mut self, path: &Path) -> Result<(), std::io::Error> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(parsed) => {
let crypto_packages = npm_crypto_packages();
let lock = crate::lockfile::resolve_npm_from_sibling_lock(path);
let mut declared: std::collections::HashSet<&str> =
std::collections::HashSet::new();
if let Some(deps) = parsed.get("dependencies").and_then(|v| v.as_object()) {
for (pkg_name, crypto_type) in &crypto_packages {
if let Some(ver_value) = deps.get(*pkg_name) {
declared.insert(*pkg_name);
let (version, version_source) = resolve_version(
ver_value.as_str().and_then(clean_version_req),
lock.as_ref().and_then(|l| l.get(*pkg_name).cloned()),
);
self.findings.push(CryptoFinding {
path: path.display().to_string(),
name: pkg_name.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Npm,
version,
version_source,
details: format!("Node.js package dependency: {}", pkg_name),
});
}
}
}
if let Some(lock) = lock.as_ref() {
for (pkg_name, crypto_type) in &crypto_packages {
if declared.contains(*pkg_name) {
continue;
}
if let Some(version) = lock.get(*pkg_name) {
self.findings.push(CryptoFinding {
path: path.display().to_string(),
name: pkg_name.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Npm,
version: Some(version.clone()),
version_source: VersionSource::Locked,
details: format!(
"Transitive Node.js package (from package-lock.json): {}",
pkg_name
),
});
}
}
}
}
Err(_) => {
}
}
Ok(())
}
fn scan_requirements_txt(&mut self, path: &Path) -> Result<(), std::io::Error> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
for finding in pypi_findings_from_requirements(&content, path) {
self.findings.push(finding);
}
Ok(())
}
fn scan_pyproject_toml(&mut self, path: &Path) -> Result<(), std::io::Error> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()),
};
let declared = pyproject_declared_names(&parsed);
let lock = crate::lockfile::resolve_pypi_from_sibling_lock(path);
for finding in pypi_findings_from_manifest(path, &declared, lock.as_ref()) {
self.findings.push(finding);
}
Ok(())
}
fn scan_pipfile(&mut self, path: &Path) -> Result<(), std::io::Error> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()),
};
let declared = pipfile_declared_names(&parsed);
let lock = crate::lockfile::resolve_pypi_from_sibling_lock(path);
for finding in pypi_findings_from_manifest(path, &declared, lock.as_ref()) {
self.findings.push(finding);
}
Ok(())
}
fn is_library_file(&self, filename: &str) -> bool {
let extensions = [".so", ".dll", ".dylib", ".a"];
extensions.iter().any(|ext| filename.contains(ext))
}
fn identify_crypto_library(&self, filename: &str) -> Option<(String, CryptoType)> {
let lower_name = filename.to_lowercase();
for (pattern, crypto_type) in &self.library_patterns {
if lower_name.contains(pattern) {
return Some((pattern.clone(), crypto_type.clone()));
}
}
None
}
pub fn get_findings(&self) -> &Vec<CryptoFinding> {
&self.findings
}
pub fn get_statistics(&self) -> HashMap<String, usize> {
let mut stats = HashMap::new();
stats.insert("total".to_string(), self.findings.len());
let mut by_type: HashMap<FindingType, usize> = HashMap::new();
let mut by_crypto: HashMap<CryptoType, usize> = HashMap::new();
for finding in &self.findings {
*by_type.entry(finding.finding_type.clone()).or_insert(0) += 1;
*by_crypto.entry(finding.crypto_type.clone()).or_insert(0) += 1;
}
stats.insert(
"binary_libraries".to_string(),
*by_type.get(&FindingType::BinaryLibrary).unwrap_or(&0),
);
stats.insert(
"static_libraries".to_string(),
*by_type.get(&FindingType::StaticLibrary).unwrap_or(&0),
);
stats.insert(
"manifests".to_string(),
*by_type.get(&FindingType::DependencyManifest).unwrap_or(&0),
);
stats.insert(
"transitive".to_string(),
*by_type
.get(&FindingType::TransitiveDependency)
.unwrap_or(&0),
);
stats.insert(
"ssl_tls".to_string(),
*by_crypto.get(&CryptoType::SslTls).unwrap_or(&0),
);
stats.insert(
"general_crypto".to_string(),
*by_crypto.get(&CryptoType::GeneralCrypto).unwrap_or(&0),
);
stats.insert(
"post_quantum".to_string(),
*by_crypto.get(&CryptoType::PostQuantum).unwrap_or(&0),
);
stats
}
fn finding_to_library_info(&self, finding: &CryptoFinding) -> crate::library::LibraryInfo {
use crate::library::{LibraryCategory, LibraryInfo, LibraryType};
let path = PathBuf::from(&finding.path);
let name = finding.name.clone();
let category = match finding.crypto_type {
CryptoType::SslTls => LibraryCategory::SslTls,
CryptoType::GeneralCrypto => LibraryCategory::GeneralCrypto,
CryptoType::PostQuantum => LibraryCategory::PostQuantum,
CryptoType::HashFunction => LibraryCategory::HashFunction,
CryptoType::Unknown => LibraryCategory::Other,
};
let library_type = match finding.finding_type {
FindingType::BinaryLibrary => LibraryType::SharedLibrary,
FindingType::StaticLibrary => LibraryType::StaticLibrary,
FindingType::DependencyManifest => LibraryType::Manifest,
FindingType::TransitiveDependency => LibraryType::Manifest, FindingType::SourceCode => LibraryType::Manifest, };
let (risk_level, quantum_vulnerable) = self.assess_risk(&name, &category);
let (size, modified) = fs::metadata(&path)
.map(|m| (m.len(), m.modified().unwrap_or(SystemTime::UNIX_EPOCH)))
.unwrap_or((0, SystemTime::UNIX_EPOCH));
let version = finding.version.clone();
LibraryInfo {
name,
path,
category,
library_type,
version,
vendor: None, size,
modified,
risk_level,
quantum_vulnerable,
}
}
fn assess_risk(
&self,
name: &str,
category: &crate::library::LibraryCategory,
) -> (crate::library::RiskLevel, bool) {
use crate::library::{LibraryCategory, RiskLevel};
let lower_name = name.to_lowercase();
if matches!(category, LibraryCategory::PostQuantum) {
return (RiskLevel::None, false);
}
if matches!(category, LibraryCategory::HashFunction) {
return (RiskLevel::Low, false);
}
if lower_name.contains("rsa") || lower_name.contains("dsa") {
return (RiskLevel::High, true);
}
if lower_name.contains("ecc") || lower_name.contains("ecdsa") || lower_name.contains("ecdh")
{
return (RiskLevel::Medium, true);
}
if lower_name.contains("sodium") || lower_name.contains("rustls") {
return (RiskLevel::Low, false);
}
(RiskLevel::Medium, true)
}
pub fn scan_streaming<F>(
&mut self,
root_path: &str,
max_depth: Option<usize>,
mut callback: F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let canonical_path = canonicalize_path(root_path)?;
let path_str = canonical_path.to_string_lossy();
let mut files_scanned = 0;
let mut dirs_scanned = 0;
let estimated_total = None;
let mut walker = WalkDir::new(path_str.as_ref()).follow_links(false);
if let Some(depth) = max_depth {
walker = walker.max_depth(depth);
}
for entry in walker.into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
dirs_scanned += 1;
} else {
files_scanned += 1;
}
if files_scanned % 10 == 0 {
callback(StreamingScanEvent::Progress {
files_scanned,
dirs_scanned,
estimated_total,
current_path: path.to_path_buf(),
});
}
if !path.is_file() {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if self.is_library_file(filename) {
if let Some((crypto_name, crypto_type)) = self.identify_crypto_library(filename) {
let finding = CryptoFinding {
path: path.display().to_string(),
name: filename.to_string(),
finding_type: if filename.ends_with(".a") {
FindingType::StaticLibrary
} else {
FindingType::BinaryLibrary
},
crypto_type,
ecosystem: Ecosystem::System,
version: None,
version_source: VersionSource::Declared,
details: format!("Detected: {}", crypto_name),
};
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
}
match filename {
"Cargo.toml" => {
if let Ok(()) = self.scan_cargo_toml_streaming(path, &mut callback) {
}
}
"package.json" => {
if let Ok(()) = self.scan_package_json_streaming(path, &mut callback) {
}
}
"requirements.txt" => {
if let Ok(()) = self.scan_requirements_txt_streaming(path, &mut callback) {
}
}
"pyproject.toml" => {
if let Ok(()) = self.scan_pyproject_toml_streaming(path, &mut callback) {
}
}
"Pipfile" => {
if let Ok(()) = self.scan_pipfile_streaming(path, &mut callback) {
}
}
_ => {}
}
}
callback(StreamingScanEvent::Progress {
files_scanned,
dirs_scanned,
estimated_total,
current_path: PathBuf::from(root_path),
});
callback(StreamingScanEvent::Complete {
total_libraries: self.findings.len(),
});
Ok(())
}
fn scan_cargo_toml_streaming<F>(
&mut self,
path: &Path,
callback: &mut F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()),
};
let crypto_crates = cargo_crypto_crates();
let lock = crate::lockfile::resolve_from_sibling_lock(path);
let mut declared: std::collections::HashSet<&str> = std::collections::HashSet::new();
if let Some(deps) = parsed.get("dependencies").and_then(|v| v.as_table()) {
for (crate_name, crypto_type) in &crypto_crates {
if let Some(dep_value) = deps.get(*crate_name) {
declared.insert(*crate_name);
let (version, version_source) = resolve_version(
extract_toml_version(dep_value),
lock.as_ref().and_then(|l| l.get(*crate_name).cloned()),
);
let finding = CryptoFinding {
path: path.display().to_string(),
name: crate_name.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Cargo,
version,
version_source,
details: format!("Rust crate dependency: {}", crate_name),
};
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
}
}
if let Some(lock) = lock.as_ref() {
for (crate_name, crypto_type) in &crypto_crates {
if declared.contains(*crate_name) {
continue;
}
if let Some(version) = lock.get(*crate_name) {
let finding = CryptoFinding {
path: path.display().to_string(),
name: crate_name.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Cargo,
version: Some(version.clone()),
version_source: VersionSource::Locked,
details: format!("Transitive Rust crate (from Cargo.lock): {}", crate_name),
};
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
}
}
Ok(())
}
fn scan_package_json_streaming<F>(
&mut self,
path: &Path,
callback: &mut F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
let crypto_packages = npm_crypto_packages();
let lock = crate::lockfile::resolve_npm_from_sibling_lock(path);
let mut declared: std::collections::HashSet<&str> = std::collections::HashSet::new();
if let Some(deps) = parsed.get("dependencies").and_then(|v| v.as_object()) {
for (pkg_name, crypto_type) in &crypto_packages {
if let Some(ver_value) = deps.get(*pkg_name) {
declared.insert(*pkg_name);
let (version, version_source) = resolve_version(
ver_value.as_str().and_then(clean_version_req),
lock.as_ref().and_then(|l| l.get(*pkg_name).cloned()),
);
let finding = CryptoFinding {
path: path.display().to_string(),
name: pkg_name.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Npm,
version,
version_source,
details: format!("Node.js package dependency: {}", pkg_name),
};
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
}
}
if let Some(lock) = lock.as_ref() {
for (pkg_name, crypto_type) in &crypto_packages {
if declared.contains(*pkg_name) {
continue;
}
if let Some(version) = lock.get(*pkg_name) {
let finding = CryptoFinding {
path: path.display().to_string(),
name: pkg_name.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type: crypto_type.clone(),
ecosystem: Ecosystem::Npm,
version: Some(version.clone()),
version_source: VersionSource::Locked,
details: format!(
"Transitive Node.js package (from package-lock.json): {}",
pkg_name
),
};
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
}
}
}
Ok(())
}
fn scan_requirements_txt_streaming<F>(
&mut self,
path: &Path,
callback: &mut F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(());
}
Err(e) => return Err(e),
};
for finding in pypi_findings_from_requirements(&content, path) {
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
Ok(())
}
fn scan_pyproject_toml_streaming<F>(
&mut self,
path: &Path,
callback: &mut F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()),
};
let declared = pyproject_declared_names(&parsed);
let lock = crate::lockfile::resolve_pypi_from_sibling_lock(path);
for finding in pypi_findings_from_manifest(path, &declared, lock.as_ref()) {
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
Ok(())
}
fn scan_pipfile_streaming<F>(
&mut self,
path: &Path,
callback: &mut F,
) -> Result<(), std::io::Error>
where
F: FnMut(StreamingScanEvent),
{
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
Err(e) => return Err(e),
};
let parsed = match toml::from_str::<toml::Value>(&content) {
Ok(p) => p,
Err(_) => return Ok(()),
};
let declared = pipfile_declared_names(&parsed);
let lock = crate::lockfile::resolve_pypi_from_sibling_lock(path);
for finding in pypi_findings_from_manifest(path, &declared, lock.as_ref()) {
let library_info = self.finding_to_library_info(&finding);
self.findings.push(finding);
callback(StreamingScanEvent::LibraryFound(library_info));
}
Ok(())
}
}
pub enum StreamingScanEvent {
Progress {
files_scanned: usize,
dirs_scanned: usize,
estimated_total: Option<usize>,
current_path: PathBuf,
},
LibraryFound(crate::library::LibraryInfo),
Complete {
total_libraries: usize,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn pep503_normalization() {
assert_eq!(normalize_pypi_name("PyOpenSSL"), "pyopenssl");
assert_eq!(normalize_pypi_name("PyNaCl"), "pynacl");
assert_eq!(normalize_pypi_name("ruamel.yaml"), "ruamel-yaml");
assert_eq!(normalize_pypi_name("foo__bar..baz"), "foo-bar-baz");
assert_eq!(normalize_pypi_name(" Cryptography "), "cryptography");
assert_eq!(normalize_pypi_name("urlcon-"), "urlcon-");
assert_eq!(normalize_pypi_name("-lead"), "-lead");
assert_eq!(normalize_pypi_name("trail___"), "trail-");
}
#[test]
fn pep508_name_extraction() {
assert_eq!(pep508_name("cryptography>=41.0"), Some("cryptography"));
assert_eq!(
pep508_name("PyNaCl==1.5.0 ; python_version>='3.8'"),
Some("PyNaCl")
);
assert_eq!(pep508_name("bcrypt[extra]"), Some("bcrypt"));
assert_eq!(pep508_name(">=1.0"), None);
}
#[test]
fn requirements_exact_pin_is_locked_others_declared() {
let content = "\
cryptography==41.0.7
PyNaCl>=1.5.0
# a comment
-r other.txt
bcrypt==4.1.2 # inline comment
requests==2.31.0
";
let findings = pypi_findings_from_requirements(content, Path::new("/p/requirements.txt"));
let crypto = findings.iter().find(|f| f.name == "cryptography").unwrap();
assert_eq!(crypto.version.as_deref(), Some("41.0.7"));
assert_eq!(crypto.version_source, VersionSource::Locked);
let nacl = findings.iter().find(|f| f.name == "pynacl").unwrap();
assert_eq!(nacl.version_source, VersionSource::Declared);
let bcrypt = findings.iter().find(|f| f.name == "bcrypt").unwrap();
assert_eq!(
bcrypt.version.as_deref(),
Some("4.1.2"),
"inline comment stripped"
);
assert_eq!(bcrypt.version_source, VersionSource::Locked);
assert!(findings.iter().all(|f| f.name != "requests"));
}
#[test]
fn manifest_split_declared_vs_transitive() {
let mut declared = std::collections::HashSet::new();
declared.insert("cryptography".to_string());
let mut lock = HashMap::new();
lock.insert("cryptography".to_string(), "41.0.7".to_string());
lock.insert("pynacl".to_string(), "1.5.0".to_string());
let findings =
pypi_findings_from_manifest(Path::new("/p/pyproject.toml"), &declared, Some(&lock));
let crypto = findings.iter().find(|f| f.name == "cryptography").unwrap();
assert_eq!(crypto.finding_type, FindingType::DependencyManifest);
assert_eq!(crypto.version.as_deref(), Some("41.0.7"));
assert_eq!(crypto.version_source, VersionSource::Locked);
let nacl = findings.iter().find(|f| f.name == "pynacl").unwrap();
assert_eq!(nacl.finding_type, FindingType::TransitiveDependency);
assert_eq!(nacl.version_source, VersionSource::Locked);
}
#[test]
fn pyproject_declared_names_poetry_and_pep621() {
let poetry = toml::from_str::<toml::Value>(
"[tool.poetry.dependencies]\ncryptography = \"^41.0\"\n[tool.poetry.group.dev.dependencies]\nbcrypt = \"*\"\n",
)
.unwrap();
let names = pyproject_declared_names(&poetry);
assert!(names.contains("cryptography"));
assert!(names.contains("bcrypt"));
let pep621 = toml::from_str::<toml::Value>(
"[project]\ndependencies = [\"PyNaCl>=1.5\", \"requests\"]\n",
)
.unwrap();
let names = pyproject_declared_names(&pep621);
assert!(names.contains("pynacl"));
assert!(names.contains("requests"));
}
}