use libverify_core::evidence::{
DependencySignatureEvidence, EvidenceGap, EvidenceState, VerificationOutcome,
};
use crate::client::GitHubClient;
const LOCK_FILE_NAMES: &[&str] = &["package-lock.json", "Cargo.lock", "poetry.lock"];
pub fn collect_pr_dependency_signatures(
client: &GitHubClient,
owner: &str,
repo: &str,
head_sha: &str,
changed_files: &[String],
) -> EvidenceState<Vec<DependencySignatureEvidence>> {
let changed_lock_files: Vec<&str> = changed_files
.iter()
.filter(|f| LOCK_FILE_NAMES.iter().any(|name| f.ends_with(name)))
.map(|f| f.as_str())
.collect();
if changed_lock_files.is_empty() {
return EvidenceState::NotApplicable;
}
let mut all_deps = Vec::new();
let mut gaps = Vec::new();
for lock_path in &changed_lock_files {
match client.get_file_content(owner, repo, lock_path, head_sha) {
Ok(content) => {
let result = if lock_path.ends_with("Cargo.lock") {
parse_cargo_lock(&content)
} else if lock_path.ends_with("package-lock.json") {
parse_package_lock_json(&content)
} else if lock_path.ends_with("poetry.lock") {
parse_poetry_lock(&content)
} else {
continue;
};
match result {
Ok(deps) => all_deps.extend(deps),
Err(e) => {
gaps.push(EvidenceGap::CollectionFailed {
source: "lock-file-parser".to_string(),
subject: lock_path.to_string(),
detail: format!("parse error: {e}"),
});
}
}
}
Err(e) => {
gaps.push(EvidenceGap::CollectionFailed {
source: "github-api".to_string(),
subject: lock_path.to_string(),
detail: format!("{e}"),
});
}
}
}
if all_deps.is_empty() && !gaps.is_empty() {
EvidenceState::missing(gaps)
} else if gaps.is_empty() {
EvidenceState::complete(all_deps)
} else {
EvidenceState::partial(all_deps, gaps)
}
}
pub fn collect_repo_dependency_signatures(
client: &GitHubClient,
owner: &str,
repo: &str,
reference: &str,
) -> EvidenceState<Vec<DependencySignatureEvidence>> {
let tree_result = match client.find_files_in_tree(owner, repo, reference, |path| {
LOCK_FILE_NAMES.iter().any(|name| path.ends_with(name))
}) {
Ok(result) => result,
Err(e) => {
return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
source: "github-tree-api".to_string(),
subject: "lock-file-discovery".to_string(),
detail: format!("{e}"),
}]);
}
};
if tree_result.paths.is_empty() && !tree_result.truncated {
return EvidenceState::NotApplicable;
}
let mut all_deps = Vec::new();
let mut gaps = Vec::new();
if tree_result.truncated {
gaps.push(EvidenceGap::Truncated {
source: "github-tree-api".to_string(),
subject: "repository-tree".to_string(),
});
}
let lock_paths = &tree_result.paths;
let results: Vec<_> = std::thread::scope(|s| {
let handles: Vec<_> = lock_paths
.iter()
.map(|lock_path| {
s.spawn(|| {
let content = client.get_file_content(owner, repo, lock_path, reference);
(lock_path.as_str(), content)
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
for (lock_path, fetch_result) in results {
match fetch_result {
Ok(content) => {
let result = if lock_path.ends_with("Cargo.lock") {
parse_cargo_lock(&content)
} else if lock_path.ends_with("package-lock.json") {
parse_package_lock_json(&content)
} else if lock_path.ends_with("poetry.lock") {
parse_poetry_lock(&content)
} else {
continue;
};
match result {
Ok(deps) => all_deps.extend(deps),
Err(e) => {
gaps.push(EvidenceGap::CollectionFailed {
source: "lock-file-parser".to_string(),
subject: lock_path.to_string(),
detail: format!("parse error: {e}"),
});
}
}
}
Err(e) => {
gaps.push(EvidenceGap::CollectionFailed {
source: "github-api".to_string(),
subject: lock_path.to_string(),
detail: format!("{e}"),
});
}
}
}
all_deps.sort_by(|a, b| {
(&a.name, &a.version, &a.registry).cmp(&(&b.name, &b.version, &b.registry))
});
all_deps
.dedup_by(|a, b| a.name == b.name && a.version == b.version && a.registry == b.registry);
if all_deps.is_empty() && !gaps.is_empty() {
EvidenceState::missing(gaps)
} else if gaps.is_empty() {
EvidenceState::complete(all_deps)
} else {
EvidenceState::partial(all_deps, gaps)
}
}
fn parse_package_lock_json(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
let lock: serde_json::Value = serde_json::from_str(content)?;
let mut deps = Vec::new();
if let Some(packages) = lock.get("packages").and_then(|p| p.as_object()) {
for (path, info) in packages {
if path.is_empty() {
continue;
}
let name = path.strip_prefix("node_modules/").unwrap_or(path);
let is_direct = !name.contains("node_modules/");
push_npm_dep(&mut deps, name, info, is_direct);
}
}
else if let Some(dependencies) = lock.get("dependencies").and_then(|d| d.as_object()) {
parse_npm_v1_deps(&mut deps, dependencies, true);
}
Ok(deps)
}
fn push_npm_dep(
deps: &mut Vec<DependencySignatureEvidence>,
name: &str,
info: &serde_json::Value,
is_direct: bool,
) {
let version = info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let integrity = info.get("integrity").and_then(|i| i.as_str());
let (verification, pinned_digest) = match integrity {
Some(hash) => (VerificationOutcome::ChecksumMatch, Some(hash.to_string())),
None => (
VerificationOutcome::AttestationAbsent {
detail: "no integrity hash in package-lock.json".to_string(),
},
None,
),
};
deps.push(DependencySignatureEvidence {
name: name.to_string(),
version: version.to_string(),
registry: Some("registry.npmjs.org".to_string()),
verification,
signature_mechanism: integrity.map(|_| "checksum".to_string()),
signer_identity: None,
source_repo: None,
source_commit: None,
pinned_digest,
actual_digest: None,
transparency_log_uri: None,
is_direct,
});
}
fn parse_npm_v1_deps(
deps: &mut Vec<DependencySignatureEvidence>,
dependencies: &serde_json::Map<String, serde_json::Value>,
is_direct: bool,
) {
for (name, info) in dependencies {
push_npm_dep(deps, name, info, is_direct);
if let Some(sub_deps) = info.get("dependencies").and_then(|d| d.as_object()) {
parse_npm_v1_deps(deps, sub_deps, false);
}
}
}
fn parse_cargo_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
let mut deps = Vec::new();
let mut current_name: Option<String> = None;
let mut current_version: Option<String> = None;
let mut current_checksum: Option<String> = None;
let mut current_source: Option<String> = None;
let mut in_package = false;
for line in content.lines() {
let line = line.trim();
if line == "[[package]]" {
flush_cargo_package(
&mut deps,
current_name.take(),
current_version.take(),
current_checksum.take(),
current_source.take(),
);
in_package = true;
continue;
}
if in_package {
if let Some(rest) = line.strip_prefix("name = ") {
current_name = Some(unquote(rest));
} else if let Some(rest) = line.strip_prefix("version = ") {
current_version = Some(unquote(rest));
} else if let Some(rest) = line.strip_prefix("checksum = ") {
current_checksum = Some(unquote(rest));
} else if let Some(rest) = line.strip_prefix("source = ") {
current_source = Some(unquote(rest));
}
}
}
flush_cargo_package(
&mut deps,
current_name,
current_version,
current_checksum,
current_source,
);
Ok(deps)
}
fn flush_cargo_package(
deps: &mut Vec<DependencySignatureEvidence>,
name: Option<String>,
version: Option<String>,
checksum: Option<String>,
source: Option<String>,
) {
if let (Some(name), Some(version)) = (name, version) {
let source = match source {
Some(s) => s,
None => return,
};
deps.push(make_cargo_dep(
&name,
&version,
checksum.as_deref(),
&source,
));
}
}
fn make_cargo_dep(
name: &str,
version: &str,
checksum: Option<&str>,
source: &str,
) -> DependencySignatureEvidence {
let (verification, mechanism, pinned_digest, source_commit) = if let Some(cs) = checksum {
(
VerificationOutcome::ChecksumMatch,
Some("checksum".to_string()),
Some(format!("sha256:{cs}")),
None,
)
} else if let Some(commit) = extract_git_commit_pin(source) {
(
VerificationOutcome::ChecksumMatch,
Some("git-commit-pin".to_string()),
Some(format!("git:{commit}")),
Some(commit.to_string()),
)
} else {
(
VerificationOutcome::AttestationAbsent {
detail: "no checksum in Cargo.lock".to_string(),
},
None,
None,
None,
)
};
let (registry, source_repo) = if source.contains("crates.io-index") {
(Some("crates.io".to_string()), None)
} else if let Some(repo_url) = extract_git_repo_url(source) {
(Some(source.to_string()), Some(repo_url))
} else {
(Some(source.to_string()), None)
};
DependencySignatureEvidence {
name: name.to_string(),
version: version.to_string(),
registry,
verification,
signature_mechanism: mechanism,
signer_identity: None,
source_repo,
source_commit,
pinned_digest,
actual_digest: None,
transparency_log_uri: None,
is_direct: true,
}
}
fn extract_git_commit_pin(source: &str) -> Option<&str> {
if !source.starts_with("git+") {
return None;
}
let commit = source.rsplit_once('#').map(|(_, sha)| sha)?;
if commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
Some(commit)
} else {
None
}
}
fn extract_git_repo_url(source: &str) -> Option<String> {
let url = source.strip_prefix("git+")?;
let url = url.split('#').next()?;
let url = url.split('?').next()?;
Some(url.to_string())
}
fn unquote(s: &str) -> String {
s.trim().trim_matches('"').to_string()
}
fn parse_poetry_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
let mut deps = Vec::new();
let mut current_name: Option<String> = None;
let mut current_version: Option<String> = None;
let mut current_has_hash: bool = false;
let mut in_package = false;
let mut in_files_array = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
flush_poetry_package(
&mut deps,
current_name.take(),
current_version.take(),
current_has_hash,
);
in_package = true;
in_files_array = false;
current_has_hash = false;
continue;
}
if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
in_files_array = false;
}
if in_package {
if let Some(rest) = trimmed.strip_prefix("name = ") {
current_name = Some(unquote(rest));
in_files_array = false;
} else if let Some(rest) = trimmed.strip_prefix("version = ") {
current_version = Some(unquote(rest));
in_files_array = false;
} else if trimmed == "files = [" || trimmed.starts_with("files = [") {
in_files_array = true;
if trimmed.contains("hash =") || trimmed.contains("sha256:") {
current_has_hash = true;
}
} else if in_files_array {
if trimmed == "]" {
in_files_array = false;
} else if trimmed.contains("hash =") || trimmed.contains("sha256:") {
current_has_hash = true;
}
}
}
}
flush_poetry_package(&mut deps, current_name, current_version, current_has_hash);
Ok(deps)
}
fn flush_poetry_package(
deps: &mut Vec<DependencySignatureEvidence>,
name: Option<String>,
version: Option<String>,
has_hash: bool,
) {
if let (Some(name), Some(version)) = (name, version) {
let (verification, mechanism) = if has_hash {
(
VerificationOutcome::ChecksumMatch,
Some("checksum".to_string()),
)
} else {
(
VerificationOutcome::AttestationAbsent {
detail: "no file hash in poetry.lock".to_string(),
},
None,
)
};
deps.push(DependencySignatureEvidence {
name,
version,
registry: Some("pypi.org".to_string()),
verification,
signature_mechanism: mechanism,
signer_identity: None,
source_repo: None,
source_commit: None,
pinned_digest: None,
actual_digest: None,
transparency_log_uri: None,
is_direct: true,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cargo_lock_extracts_deps_with_checksum() {
let content = r#"
[[package]]
name = "serde"
version = "1.0.204"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc123def456"
[[package]]
name = "tokio"
version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789xyz"
"#;
let deps = parse_cargo_lock(content).unwrap();
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "serde");
assert_eq!(deps[0].version, "1.0.204");
assert!(deps[0].verification.is_verified());
assert_eq!(
deps[0].pinned_digest,
Some("sha256:abc123def456".to_string())
);
assert_eq!(deps[0].signature_mechanism, Some("checksum".to_string()));
assert_eq!(deps[1].name, "tokio");
}
#[test]
fn parse_cargo_lock_skips_path_dependencies() {
let content = r#"
[[package]]
name = "my-workspace-crate"
version = "0.1.0"
[[package]]
name = "external-dep"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaa"
"#;
let deps = parse_cargo_lock(content).unwrap();
assert_eq!(deps.len(), 1, "path dependency should be skipped");
assert_eq!(deps[0].name, "external-dep");
}
#[test]
fn parse_cargo_lock_empty_content() {
let deps = parse_cargo_lock("").unwrap();
assert!(deps.is_empty());
}
#[test]
fn parse_cargo_lock_git_source_with_commit_pin() {
let content = r#"
[[package]]
name = "git-dep"
version = "0.1.0"
source = "git+https://github.com/example/repo#abc123def456"
"#;
let deps = parse_cargo_lock(content).unwrap();
assert_eq!(deps.len(), 1, "git source should be included");
assert!(
deps[0].verification.is_verified(),
"git commit pin is a form of integrity verification"
);
assert_eq!(
deps[0].signature_mechanism,
Some("git-commit-pin".to_string())
);
assert_eq!(deps[0].pinned_digest, Some("git:abc123def456".to_string()));
assert_eq!(
deps[0].source_repo,
Some("https://github.com/example/repo".to_string())
);
assert_eq!(deps[0].source_commit, Some("abc123def456".to_string()));
}
#[test]
fn parse_cargo_lock_git_source_without_commit_pin() {
let content = r#"
[[package]]
name = "git-dep"
version = "0.1.0"
source = "git+https://github.com/example/repo"
"#;
let deps = parse_cargo_lock(content).unwrap();
assert_eq!(deps.len(), 1);
assert!(
!deps[0].verification.is_verified(),
"git source without commit pin is unverified"
);
}
#[test]
fn parse_cargo_lock_mixed_sources() {
let content = r#"
[[package]]
name = "with-checksum"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaa"
[[package]]
name = "local-dep"
version = "0.1.0"
[[package]]
name = "another"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb"
"#;
let deps = parse_cargo_lock(content).unwrap();
assert_eq!(deps.len(), 2, "local-dep (no source) should be skipped");
assert_eq!(deps[0].name, "with-checksum");
assert!(deps[0].verification.is_verified());
assert_eq!(deps[1].name, "another");
assert!(deps[1].verification.is_verified());
}
#[test]
fn parse_package_lock_v3_with_integrity() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "my-app", "version": "1.0.0" },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57RiUrWo9HuEz+"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0K+"
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].name, "lodash");
assert_eq!(deps[0].version, "4.17.21");
assert!(deps[0].verification.is_verified());
assert_eq!(
deps[0].pinned_digest,
Some("sha512-v2kDEe57RiUrWo9HuEz+".to_string())
);
assert!(deps[0].is_direct);
assert_eq!(deps[1].name, "react");
}
#[test]
fn parse_package_lock_transitive_deps() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/express": {
"version": "4.18.2",
"integrity": "sha512-abc"
},
"node_modules/express/node_modules/body-parser": {
"version": "1.20.0",
"integrity": "sha512-def"
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 2);
assert!(deps[0].is_direct);
assert!(!deps[1].is_direct);
}
#[test]
fn parse_package_lock_no_integrity() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/local-link": {
"version": "0.1.0"
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 1);
assert!(!deps[0].verification.is_verified());
}
#[test]
fn parse_package_lock_scoped_package() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/@babel/core": {
"version": "7.24.0",
"integrity": "sha512-babel-integrity"
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "@babel/core");
assert!(deps[0].verification.is_verified());
}
#[test]
fn parse_package_lock_empty() {
let content = r#"{ "lockfileVersion": 3, "packages": {} }"#;
let deps = parse_package_lock_json(content).unwrap();
assert!(deps.is_empty());
}
#[test]
fn parse_package_lock_v1_format() {
let content = r#"{
"lockfileVersion": 1,
"dependencies": {
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v1-lodash-hash"
},
"express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-express-hash",
"dependencies": {
"body-parser": {
"version": "1.20.0",
"integrity": "sha512-body-parser-hash"
}
}
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 3);
let lodash = deps.iter().find(|d| d.name == "lodash").expect("lodash");
assert!(lodash.is_direct);
assert!(lodash.verification.is_verified());
let express = deps.iter().find(|d| d.name == "express").expect("express");
assert!(express.is_direct);
let body_parser = deps
.iter()
.find(|d| d.name == "body-parser")
.expect("body-parser");
assert!(!body_parser.is_direct, "nested dep should be transitive");
assert!(body_parser.verification.is_verified());
}
#[test]
fn parse_package_lock_v1_no_integrity() {
let content = r#"{
"lockfileVersion": 1,
"dependencies": {
"old-pkg": {
"version": "0.0.1"
}
}
}"#;
let deps = parse_package_lock_json(content).unwrap();
assert_eq!(deps.len(), 1);
assert!(!deps[0].verification.is_verified());
}
#[test]
fn parse_poetry_lock_with_hash() {
let content = r#"
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187423839"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98"},
]
[[package]]
name = "urllib3"
version = "2.0.7"
description = "HTTP library with thread-safe connection pooling"
optional = false
python-versions = ">=3.7"
files = [
{file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776a"},
]
"#;
let deps = parse_poetry_lock(content).unwrap();
assert_eq!(deps.len(), 2);
let requests = deps
.iter()
.find(|d| d.name == "requests")
.expect("requests");
assert_eq!(requests.version, "2.31.0");
assert!(
requests.verification.is_verified(),
"hash present → ChecksumMatch"
);
assert_eq!(requests.signature_mechanism, Some("checksum".to_string()));
assert_eq!(requests.registry, Some("pypi.org".to_string()));
let urllib3 = deps.iter().find(|d| d.name == "urllib3").expect("urllib3");
assert_eq!(urllib3.version, "2.0.7");
assert!(urllib3.verification.is_verified());
}
#[test]
fn parse_poetry_lock_without_hash() {
let content = r#"
[[package]]
name = "legacy-pkg"
version = "0.1.0"
description = "A package without file hashes"
optional = false
python-versions = "*"
[[package]]
name = "another-legacy"
version = "1.2.3"
description = "Also no hashes"
optional = false
python-versions = "*"
"#;
let deps = parse_poetry_lock(content).unwrap();
assert_eq!(deps.len(), 2);
let legacy = deps
.iter()
.find(|d| d.name == "legacy-pkg")
.expect("legacy-pkg");
assert_eq!(legacy.version, "0.1.0");
assert!(
!legacy.verification.is_verified(),
"no hash → AttestationAbsent"
);
assert_eq!(legacy.signature_mechanism, None);
assert_eq!(legacy.registry, Some("pypi.org".to_string()));
let another = deps
.iter()
.find(|d| d.name == "another-legacy")
.expect("another-legacy");
assert!(!another.verification.is_verified());
}
}