use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use crate::rules::common::make_finding_from_offsets;
use crate::rules::Rule;
use crate::{Finding, Language, Severity};
struct SeedEntry {
name: &'static str,
crypto_algorithm: Option<&'static str>,
confidence: f32,
}
const MANIFEST_PQ_CWE: &str = "CWE-327";
const MANIFEST_PQ_DESC: &str = "Dependency uses quantum-vulnerable cryptographic algorithm";
const CARGO_PQ_DESC: &str =
"Dependency uses quantum-vulnerable cryptographic algorithm (dev-dependencies not distinguished)";
const MANIFEST_PQ_DEADLINE: &str = "2033";
fn finalize_manifest_finding(f: &mut Finding, entry: &SeedEntry, pkg_name: &str) {
f.tags = vec!["PQ".into()];
f.crypto_algorithm = entry.crypto_algorithm.map(String::from);
f.confidence = entry.confidence;
f.dep_name = Some(pkg_name.to_string());
}
const CARGO_SEEDS: &[SeedEntry] = &[
SeedEntry {
name: "rsa",
crypto_algorithm: Some("RSA"),
confidence: 0.9,
},
SeedEntry {
name: "p256",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "p384",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "ed25519-dalek",
crypto_algorithm: Some("Ed25519"),
confidence: 0.9,
},
SeedEntry {
name: "x25519-dalek",
crypto_algorithm: Some("X25519"),
confidence: 0.9,
},
SeedEntry {
name: "ecdsa",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "k256",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "secp256k1",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "libsecp256k1",
crypto_algorithm: Some("ECDSA"),
confidence: 0.9,
},
SeedEntry {
name: "ed448-goldilocks",
crypto_algorithm: Some("Ed448"),
confidence: 0.9,
},
SeedEntry {
name: "ring",
crypto_algorithm: None,
confidence: 0.6,
},
SeedEntry {
name: "openssl-sys",
crypto_algorithm: None,
confidence: 0.6,
},
SeedEntry {
name: "openssl",
crypto_algorithm: None,
confidence: 0.6,
},
SeedEntry {
name: "aws-lc-rs",
crypto_algorithm: None,
confidence: 0.6,
},
];
const PIP_PACKAGES: &[SeedEntry] = &[
SeedEntry {
name: "python-rsa",
crypto_algorithm: Some("RSA"),
confidence: 0.95,
},
SeedEntry {
name: "rsa",
crypto_algorithm: Some("RSA"),
confidence: 0.95,
},
SeedEntry {
name: "ecdsa",
crypto_algorithm: Some("ECDSA"),
confidence: 0.95,
},
SeedEntry {
name: "ed25519",
crypto_algorithm: Some("Ed25519"),
confidence: 0.95,
},
SeedEntry {
name: "pynacl",
crypto_algorithm: Some("Ed25519"),
confidence: 0.9,
},
SeedEntry {
name: "paramiko",
crypto_algorithm: Some("RSA"),
confidence: 0.8,
},
SeedEntry {
name: "pyjwt",
crypto_algorithm: None,
confidence: 0.8,
},
SeedEntry {
name: "authlib",
crypto_algorithm: None,
confidence: 0.8,
},
SeedEntry {
name: "python-jose",
crypto_algorithm: None,
confidence: 0.8,
},
SeedEntry {
name: "jwcrypto",
crypto_algorithm: None,
confidence: 0.8,
},
SeedEntry {
name: "fabric",
crypto_algorithm: None,
confidence: 0.7,
},
SeedEntry {
name: "m2crypto",
crypto_algorithm: None,
confidence: 0.6,
},
SeedEntry {
name: "cryptography",
crypto_algorithm: None,
confidence: 0.5,
},
SeedEntry {
name: "pyopenssl",
crypto_algorithm: None,
confidence: 0.5,
},
SeedEntry {
name: "pycryptodome",
crypto_algorithm: None,
confidence: 0.5,
},
SeedEntry {
name: "pycryptodomex",
crypto_algorithm: None,
confidence: 0.5,
},
];
pub struct CargoLockPqCrypto;
impl Rule for CargoLockPqCrypto {
fn id(&self) -> &str {
"manifest/cargo-pq-vulnerable-dep"
}
fn severity(&self) -> Severity {
Severity::High
}
fn cwe(&self) -> Option<&str> {
Some(MANIFEST_PQ_CWE)
}
fn description(&self) -> &str {
CARGO_PQ_DESC
}
fn language(&self) -> Language {
Language::Manifest
}
fn cnsa2_deadline(&self) -> Option<&'static str> {
Some(MANIFEST_PQ_DEADLINE)
}
fn applies_to_path(&self, path: &Path) -> bool {
path.file_name().and_then(|f| f.to_str()) == Some("Cargo.lock")
}
fn check(&self, source: &str, _tree: &tree_sitter::Tree) -> Vec<Finding> {
let Ok(doc) = source.parse::<toml::Value>() else {
return Vec::new();
};
let Some(packages) = doc.get("package").and_then(|p| p.as_array()) else {
return Vec::new();
};
let mut name_to_indices: HashMap<&str, Vec<usize>> = HashMap::new();
let mut name_ver_to_index: HashMap<(&str, &str), usize> = HashMap::new();
let mut graph: Vec<Vec<usize>> = Vec::with_capacity(packages.len());
for (i, pkg) in packages.iter().enumerate() {
if let Some(name) = pkg.get("name").and_then(|n| n.as_str()) {
name_to_indices.entry(name).or_default().push(i);
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
name_ver_to_index.insert((name, ver), i);
}
}
graph.push(Vec::new());
}
for (i, pkg) in packages.iter().enumerate() {
if let Some(deps) = pkg.get("dependencies").and_then(|d| d.as_array()) {
for dep in deps {
let dep_str = match dep.as_str() {
Some(s) => s,
None => continue,
};
if let Some((name, ver)) = dep_str.split_once(' ') {
if let Some(&j) = name_ver_to_index.get(&(name, ver)) {
graph[i].push(j);
}
} else {
if let Some(indices) = name_to_indices.get(dep_str) {
for &j in indices {
graph[i].push(j);
}
}
}
}
}
}
let seed_map: HashMap<&str, &SeedEntry> = CARGO_SEEDS.iter().map(|e| (e.name, e)).collect();
let mut findings = Vec::new();
for (i, pkg) in packages.iter().enumerate() {
let pkg_name = match pkg.get("name").and_then(|n| n.as_str()) {
Some(n) => n,
None => continue,
};
if seed_map.contains_key(pkg_name) {
continue;
}
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
visited.insert(i);
queue.push_back(i);
let mut reached_seeds: HashMap<&str, &SeedEntry> = HashMap::new();
while let Some(node) = queue.pop_front() {
for &neighbor in &graph[node] {
if !visited.insert(neighbor) {
continue;
}
let Some(neighbor_name) =
packages[neighbor].get("name").and_then(|n| n.as_str())
else {
queue.push_back(neighbor);
continue;
};
if let Some(entry) = seed_map.get(neighbor_name) {
reached_seeds
.entry(entry.name)
.and_modify(|existing| {
if entry.confidence > existing.confidence {
*existing = entry;
}
})
.or_insert(entry);
} else {
queue.push_back(neighbor);
}
}
}
if reached_seeds.is_empty() {
continue;
}
let Some((_, best)) = reached_seeds.iter().max_by(|(k1, v1), (k2, v2)| {
v1.confidence
.total_cmp(&v2.confidence)
.then_with(|| k1.cmp(k2))
}) else {
continue;
};
let version_str = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
let name_pat = format!("name = \"{}\"", pkg_name);
let ver_pat = format!("version = \"{}\"", version_str);
let Some((offset, end)) = find_name_version_offset(source, &name_pat, &ver_pat) else {
continue;
};
let desc = if let Some(algo) = best.crypto_algorithm {
format!(
"Crate `{}` transitively depends on `{}` (PQ-vulnerable {})",
pkg_name, best.name, algo
)
} else {
format!(
"Crate `{}` transitively depends on `{}` (uses mixed classical cryptography)",
pkg_name, best.name
)
};
let mut f = make_finding_from_offsets(
self.id(),
self.severity(),
self.cwe(),
&desc,
source,
offset,
end,
);
finalize_manifest_finding(&mut f, best, pkg_name);
findings.push(f);
}
findings
}
}
pub struct RequirementsTxtPqCrypto;
impl Rule for RequirementsTxtPqCrypto {
fn id(&self) -> &str {
"manifest/pip-pq-vulnerable-dep"
}
fn severity(&self) -> Severity {
Severity::High
}
fn cwe(&self) -> Option<&str> {
Some(MANIFEST_PQ_CWE)
}
fn description(&self) -> &str {
MANIFEST_PQ_DESC
}
fn language(&self) -> Language {
Language::Manifest
}
fn cnsa2_deadline(&self) -> Option<&'static str> {
Some(MANIFEST_PQ_DEADLINE)
}
fn applies_to_path(&self, path: &Path) -> bool {
path.file_name().and_then(|f| f.to_str()) == Some("requirements.txt")
}
fn check(&self, source: &str, _tree: &tree_sitter::Tree) -> Vec<Finding> {
let pip_map: HashMap<String, &SeedEntry> = PIP_PACKAGES
.iter()
.map(|e| (e.name.to_lowercase().replace(['_', '.'], "-"), e))
.collect();
let mut findings = Vec::new();
let mut byte_offset = 0usize;
for line in source.lines() {
let line_start = byte_offset;
let line_end = byte_offset + line.len();
byte_offset = if source.as_bytes().get(line_end) == Some(&b'\r')
&& source.as_bytes().get(line_end + 1) == Some(&b'\n')
{
line_end + 2
} else if matches!(source.as_bytes().get(line_end), Some(&b'\r') | Some(&b'\n')) {
line_end + 1
} else {
line_end };
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with('-')
|| trimmed.starts_with("git+")
|| trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
{
continue;
}
let before_marker = trimmed.split(';').next().unwrap_or(trimmed).trim();
let pkg_name = extract_pip_package_name(before_marker);
if pkg_name.is_empty() {
continue;
}
let lookup = pkg_name.to_lowercase().replace(['_', '.'], "-");
if let Some(entry) = pip_map.get(lookup.as_str()) {
let desc = if let Some(algo) = entry.crypto_algorithm {
format!("Package `{}` uses {} (PQ-vulnerable)", pkg_name, algo)
} else {
format!(
"Package `{}` may use PQ-vulnerable algorithms (RSA, ECDSA, Ed25519)",
pkg_name
)
};
let mut f = make_finding_from_offsets(
self.id(),
self.severity(),
self.cwe(),
&desc,
source,
line_start,
line_end,
);
finalize_manifest_finding(&mut f, entry, pkg_name);
if entry.crypto_algorithm.is_none() && entry.confidence <= 0.6 {
f.fix_suggestion = Some(format!(
"Review usage — `{}` also provides PQ-safe primitives (AES, SHA-256)",
pkg_name
));
}
findings.push(f);
}
}
findings
}
}
fn find_name_version_offset(source: &str, name_pat: &str, ver_pat: &str) -> Option<(usize, usize)> {
let mut search_from = 0;
while let Some(pos) = source[search_from..].find(name_pat) {
let abs = search_from + pos;
let after_name = abs + name_pat.len();
let rest = &source[after_name..];
if rest.starts_with('\n') || rest.starts_with("\r\n") {
let ver_start = after_name + if rest.starts_with("\r\n") { 2 } else { 1 };
if source[ver_start..].starts_with(ver_pat) {
return Some((abs, ver_start + ver_pat.len()));
}
}
search_from = abs + 1;
}
None
}
fn extract_pip_package_name(s: &str) -> &str {
let end = s
.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.')
.unwrap_or(s.len());
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::parser::parse_file;
fn dummy_tree(source: &str) -> tree_sitter::Tree {
parse_file(source, Language::Manifest).expect("parse")
}
#[test]
fn extract_pip_name_simple() {
assert_eq!(extract_pip_package_name("requests"), "requests");
}
#[test]
fn extract_pip_name_with_version() {
assert_eq!(extract_pip_package_name("requests>=2.28"), "requests");
assert_eq!(extract_pip_package_name("rsa==4.9"), "rsa");
}
#[test]
fn extract_pip_name_with_extras() {
assert_eq!(extract_pip_package_name("fabric[ssh]>=3.0"), "fabric");
}
#[test]
fn extract_pip_name_with_dots_and_underscores() {
assert_eq!(
extract_pip_package_name("my.package_name>=1.0"),
"my.package_name"
);
}
#[test]
fn offset_finds_exact_pair() {
let src = "[[package]]\nname = \"foo\"\nversion = \"1.0\"\n";
let r = find_name_version_offset(src, "name = \"foo\"", "version = \"1.0\"");
assert_eq!(r, Some((12, 40)));
}
#[test]
fn offset_disambiguates_duplicate_crate_names() {
let src = "\
[[package]]\nname = \"syn\"\nversion = \"1.0\"\n\n\
[[package]]\nname = \"syn\"\nversion = \"2.0\"\n";
let r = find_name_version_offset(src, "name = \"syn\"", "version = \"2.0\"");
let (start, _) = r.unwrap();
assert!(start > 30, "expected second occurrence, got offset {start}");
}
#[test]
fn offset_returns_none_when_missing() {
let src = "[[package]]\nname = \"foo\"\nversion = \"1.0\"\n";
assert!(find_name_version_offset(src, "name = \"bar\"", "version = \"1.0\"").is_none());
}
#[test]
fn offset_returns_none_when_version_doesnt_follow() {
let src = "[[package]]\nname = \"foo\"\nversion = \"1.0\"\n";
assert!(find_name_version_offset(src, "name = \"foo\"", "version = \"9.9\"").is_none());
}
#[test]
fn offset_handles_crlf() {
let src = "[[package]]\r\nname = \"foo\"\r\nversion = \"1.0\"\r\n";
let r = find_name_version_offset(src, "name = \"foo\"", "version = \"1.0\"");
assert!(r.is_some());
}
const CARGO_LOCK_BASIC: &str = "\
[[package]]\n\
name = \"my-app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"rsa\"]\n\
\n\
[[package]]\n\
name = \"rsa\"\n\
version = \"0.9.0\"\n";
#[test]
fn cargo_direct_seed_dep_flagged() {
let tree = dummy_tree(CARGO_LOCK_BASIC);
let findings = CargoLockPqCrypto.check(CARGO_LOCK_BASIC, &tree);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.dep_name.as_deref(), Some("my-app"));
assert_eq!(f.crypto_algorithm.as_deref(), Some("RSA"));
assert_eq!(f.confidence, 0.9);
assert!(f.tags.contains(&"PQ".to_string()));
}
#[test]
fn cargo_seed_crate_itself_not_flagged() {
let tree = dummy_tree(CARGO_LOCK_BASIC);
let findings = CargoLockPqCrypto.check(CARGO_LOCK_BASIC, &tree);
assert!(
findings
.iter()
.all(|f| f.dep_name.as_deref() != Some("rsa")),
"seed crate rsa should not be flagged"
);
}
#[test]
fn cargo_transitive_dep_flagged() {
let src = "\
[[package]]\n\
name = \"app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"mid\"]\n\
\n\
[[package]]\n\
name = \"mid\"\n\
version = \"1.0.0\"\n\
dependencies = [\"p256\"]\n\
\n\
[[package]]\n\
name = \"p256\"\n\
version = \"0.13.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 2);
let names: Vec<_> = findings
.iter()
.filter_map(|f| f.dep_name.as_deref())
.collect();
assert!(names.contains(&"app"));
assert!(names.contains(&"mid"));
assert!(findings
.iter()
.all(|f| f.crypto_algorithm.as_deref() == Some("ECDSA")));
}
#[test]
fn cargo_bfs_stops_at_seed_no_traversal_through() {
let src = "\
[[package]]\n\
name = \"app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"ring\"]\n\
\n\
[[package]]\n\
name = \"ring\"\n\
version = \"0.17.0\"\n\
dependencies = [\"rsa\"]\n\
\n\
[[package]]\n\
name = \"rsa\"\n\
version = \"0.9.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.dep_name.as_deref(), Some("app"));
assert_eq!(f.confidence, 0.6, "should be ring's 0.6, not rsa's 0.9");
assert!(
f.crypto_algorithm.is_none(),
"ring has no specific algorithm"
);
}
#[test]
fn cargo_tier1_beats_tier2_confidence() {
let src = "\
[[package]]\n\
name = \"app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"rsa\", \"ring\"]\n\
\n\
[[package]]\n\
name = \"rsa\"\n\
version = \"0.9.0\"\n\
\n\
[[package]]\n\
name = \"ring\"\n\
version = \"0.17.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].confidence, 0.9);
assert_eq!(findings[0].crypto_algorithm.as_deref(), Some("RSA"));
}
#[test]
fn cargo_no_findings_for_clean_lockfile() {
let src = "\
[[package]]\n\
name = \"serde\"\n\
version = \"1.0.0\"\n\
\n\
[[package]]\n\
name = \"serde_json\"\n\
version = \"1.0.0\"\n\
dependencies = [\"serde\"]\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert!(findings.is_empty());
}
#[test]
fn cargo_invalid_toml_returns_empty() {
let src = "this is not valid toml {{{";
let tree = dummy_tree(src);
assert!(CargoLockPqCrypto.check(src, &tree).is_empty());
}
#[test]
fn cargo_version_qualified_dep_string() {
let src = "\
[[package]]\n\
name = \"app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"rsa 0.9.0\"]\n\
\n\
[[package]]\n\
name = \"rsa\"\n\
version = \"0.9.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("app"));
}
#[test]
fn cargo_multi_version_diamond() {
let src = "\
[[package]]\n\
name = \"app\"\n\
version = \"0.1.0\"\n\
dependencies = [\"syn 2.0.0\"]\n\
\n\
[[package]]\n\
name = \"syn\"\n\
version = \"1.0.0\"\n\
dependencies = [\"rsa\"]\n\
\n\
[[package]]\n\
name = \"syn\"\n\
version = \"2.0.0\"\n\
dependencies = [\"ring\"]\n\
\n\
[[package]]\n\
name = \"rsa\"\n\
version = \"0.9.0\"\n\
\n\
[[package]]\n\
name = \"ring\"\n\
version = \"0.17.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
let app_finding = findings
.iter()
.find(|f| f.dep_name.as_deref() == Some("app"))
.expect("app should be flagged");
assert_eq!(app_finding.confidence, 0.6, "should reach ring, not rsa");
assert!(app_finding.crypto_algorithm.is_none());
}
#[test]
fn pip_detects_known_packages() {
let src = "python-rsa==4.9\nflask>=2.0\ncryptography>=41.0\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
let names: Vec<_> = findings
.iter()
.filter_map(|f| f.dep_name.as_deref())
.collect();
assert!(names.contains(&"python-rsa"));
assert!(names.contains(&"cryptography"));
assert!(!names.contains(&"flask"));
}
#[test]
fn pip_high_confidence_has_algorithm() {
let src = "python-rsa==4.9\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].crypto_algorithm.as_deref(), Some("RSA"));
assert_eq!(findings[0].confidence, 0.95);
}
#[test]
fn pip_kitchen_sink_has_fix_suggestion() {
let src = "cryptography>=41.0\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert!(findings[0].crypto_algorithm.is_none());
assert!(findings[0].fix_suggestion.is_some());
}
#[test]
fn pip_skips_comments_and_options() {
let src = "# a comment\n-r other.txt\n-e .\ngit+https://example.com/foo.git\nhttps://example.com/bar.whl\n";
let tree = dummy_tree(src);
assert!(RequirementsTxtPqCrypto.check(src, &tree).is_empty());
}
#[test]
fn pip_strips_environment_markers() {
let src = "paramiko>=2.0; python_version >= \"3.8\"\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("paramiko"));
}
#[test]
fn pip_strips_extras() {
let src = "fabric[ssh]>=3.0\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("fabric"));
}
#[test]
fn pip_pep503_normalization() {
let src = "PyNaCl\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("PyNaCl"));
}
#[test]
fn pip_crlf_offsets_correct() {
let src = "flask>=2.0\r\npython-rsa==4.9\r\nrequests>=2.28\r\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 2);
assert_eq!(findings[0].column, 1);
assert_eq!(findings[0].end_column, 16);
}
#[test]
fn pip_empty_input_returns_empty() {
let tree = dummy_tree("");
assert!(RequirementsTxtPqCrypto.check("", &tree).is_empty());
}
#[test]
fn cargo_k256_seed_flagged() {
let src = "\
[[package]]\n\
name = \"wallet\"\n\
version = \"0.1.0\"\n\
dependencies = [\"k256\"]\n\
\n\
[[package]]\n\
name = \"k256\"\n\
version = \"0.13.0\"\n";
let tree = dummy_tree(src);
let findings = CargoLockPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("wallet"));
assert_eq!(findings[0].crypto_algorithm.as_deref(), Some("ECDSA"));
}
#[test]
fn pip_jwt_lib_flagged() {
let src = "pyjwt>=2.0\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].dep_name.as_deref(), Some("pyjwt"));
assert!(findings[0].crypto_algorithm.is_none());
assert_eq!(findings[0].confidence, 0.8);
}
#[test]
fn pip_fabric_no_algorithm() {
let src = "fabric>=3.0\n";
let tree = dummy_tree(src);
let findings = RequirementsTxtPqCrypto.check(src, &tree);
assert_eq!(findings.len(), 1);
assert!(
findings[0].crypto_algorithm.is_none(),
"fabric should not attribute RSA"
);
}
}