From 3cd4d001023dfc6c32dc1f6c9fc04c9d7a5cd1f5 Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Thu, 28 May 2026 16:09:55 +0200 Subject: [PATCH] Add corgea deps offline inventory (scan/graph/explain/diff/sbom/policy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline dependency inventory for npm, Python, and Java: detects manifests and lockfiles, builds the resolved graph, and evaluates a pinning policy (DEP rules) with table/JSON/SARIF/CycloneDX output. Fully offline — no token or network. Carved out of #89 as chunk2 (stacks on the chunk1 harness branch). Excludes the network surface deferred to chunk3: `deps verify`, registry freshness, --check-cve / vuln-api, the vuln-api-stub binary, and the npm/pip/etc. install wrappers. Wires `corgea deps ` into main.rs with no auth/token check. Adds 83 unit + integration tests; overall line coverage 13% -> 36%. --- Cargo.lock | 20 ++ Cargo.toml | 1 + README.md | 17 + src/deps/detect.rs | 103 ++++++ src/deps/diff.rs | 63 ++++ src/deps/ecosystems/evaluate.rs | 283 ++++++++++++++++ src/deps/ecosystems/maven.rs | 243 ++++++++++++++ src/deps/ecosystems/mod.rs | 139 ++++++++ src/deps/ecosystems/npm.rs | 301 ++++++++++++++++++ src/deps/ecosystems/pypi.rs | 299 +++++++++++++++++ src/deps/explain.rs | 82 +++++ src/deps/findings.rs | 38 +++ src/deps/mod.rs | 94 ++++++ src/deps/model.rs | 229 +++++++++++++ src/deps/parse/mod.rs | 4 + src/deps/parse/npm_lock.rs | 10 + src/deps/parse/python_lock.rs | 10 + src/deps/policy.rs | 99 ++++++ src/deps/report.rs | 155 +++++++++ src/deps/run.rs | 214 +++++++++++++ src/deps/tests/common.rs | 15 + src/deps/tests/correctness_tests.rs | 46 +++ src/deps/tests/detect_tests.rs | 50 +++ src/deps/tests/diff_tests.rs | 29 ++ src/deps/tests/explain_tests.rs | 20 ++ src/deps/tests/findings_tests.rs | 25 ++ src/deps/tests/maven_tests.rs | 129 ++++++++ src/deps/tests/mod.rs | 13 + src/deps/tests/npm_tests.rs | 196 ++++++++++++ src/deps/tests/policy_tests.rs | 40 +++ src/deps/tests/pypi_tests.rs | 98 ++++++ src/deps/tests/report_tests.rs | 29 ++ src/deps/tests/robustness_tests.rs | 105 ++++++ src/deps/tests/slice0_tests.rs | 16 + src/lib.rs | 1 + src/main.rs | 9 + tests/cli_deps.rs | 106 ++++++ tests/fixtures/README.md | 19 ++ tests/fixtures/go-mod-smoke/go.mod | 5 + tests/fixtures/go-mod-smoke/go.sum | 2 + tests/fixtures/java-gradle/build.gradle | 10 + tests/fixtures/java-gradle/gradle.lockfile | 6 + tests/fixtures/java-maven/pom.xml | 35 ++ tests/fixtures/malformed/not-xml-pom.xml | 1 + tests/fixtures/malformed/package-lock.json | 6 + tests/fixtures/malformed/package.json | 4 + tests/fixtures/malformed/poetry.lock | 3 + tests/fixtures/malformed/pyproject.toml | 6 + .../fixtures/malformed/truncated-poetry.lock | 3 + tests/fixtures/node-app/package-lock.json | 44 +++ tests/fixtures/node-app/package.json | 13 + .../fixtures/node-monorepo/package-lock.json | 11 + tests/fixtures/node-monorepo/package.json | 6 + .../node-monorepo/packages/a/package.json | 1 + .../node-monorepo/packages/b/package.json | 1 + tests/fixtures/node-stale/package-lock.json | 15 + tests/fixtures/node-stale/package.json | 5 + .../python-pip-nolock/requirements.txt | 4 + tests/fixtures/python-poetry/poetry.lock | 31 ++ tests/fixtures/python-poetry/pyproject.toml | 11 + 60 files changed, 3573 insertions(+) create mode 100644 src/deps/detect.rs create mode 100644 src/deps/diff.rs create mode 100644 src/deps/ecosystems/evaluate.rs create mode 100644 src/deps/ecosystems/maven.rs create mode 100644 src/deps/ecosystems/mod.rs create mode 100644 src/deps/ecosystems/npm.rs create mode 100644 src/deps/ecosystems/pypi.rs create mode 100644 src/deps/explain.rs create mode 100644 src/deps/findings.rs create mode 100644 src/deps/mod.rs create mode 100644 src/deps/model.rs create mode 100644 src/deps/parse/mod.rs create mode 100644 src/deps/parse/npm_lock.rs create mode 100644 src/deps/parse/python_lock.rs create mode 100644 src/deps/policy.rs create mode 100644 src/deps/report.rs create mode 100644 src/deps/run.rs create mode 100644 src/deps/tests/common.rs create mode 100644 src/deps/tests/correctness_tests.rs create mode 100644 src/deps/tests/detect_tests.rs create mode 100644 src/deps/tests/diff_tests.rs create mode 100644 src/deps/tests/explain_tests.rs create mode 100644 src/deps/tests/findings_tests.rs create mode 100644 src/deps/tests/maven_tests.rs create mode 100644 src/deps/tests/mod.rs create mode 100644 src/deps/tests/npm_tests.rs create mode 100644 src/deps/tests/policy_tests.rs create mode 100644 src/deps/tests/pypi_tests.rs create mode 100644 src/deps/tests/report_tests.rs create mode 100644 src/deps/tests/robustness_tests.rs create mode 100644 src/deps/tests/slice0_tests.rs create mode 100644 src/lib.rs create mode 100644 tests/cli_deps.rs create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/go-mod-smoke/go.mod create mode 100644 tests/fixtures/go-mod-smoke/go.sum create mode 100644 tests/fixtures/java-gradle/build.gradle create mode 100644 tests/fixtures/java-gradle/gradle.lockfile create mode 100644 tests/fixtures/java-maven/pom.xml create mode 100644 tests/fixtures/malformed/not-xml-pom.xml create mode 100644 tests/fixtures/malformed/package-lock.json create mode 100644 tests/fixtures/malformed/package.json create mode 100644 tests/fixtures/malformed/poetry.lock create mode 100644 tests/fixtures/malformed/pyproject.toml create mode 100644 tests/fixtures/malformed/truncated-poetry.lock create mode 100644 tests/fixtures/node-app/package-lock.json create mode 100644 tests/fixtures/node-app/package.json create mode 100644 tests/fixtures/node-monorepo/package-lock.json create mode 100644 tests/fixtures/node-monorepo/package.json create mode 100644 tests/fixtures/node-monorepo/packages/a/package.json create mode 100644 tests/fixtures/node-monorepo/packages/b/package.json create mode 100644 tests/fixtures/node-stale/package-lock.json create mode 100644 tests/fixtures/node-stale/package.json create mode 100644 tests/fixtures/python-pip-nolock/requirements.txt create mode 100644 tests/fixtures/python-poetry/poetry.lock create mode 100644 tests/fixtures/python-poetry/pyproject.toml diff --git a/Cargo.lock b/Cargo.lock index 474601b..f74784e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "serde_yaml_ng", "tempfile", "termcolor", "tokio", @@ -1759,6 +1760,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2169,6 +2183,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/Cargo.toml b/Cargo.toml index 608ffbd..cb287c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ http-body-util = "0.1" url = "2.5" open = "5.0" urlencoding = "2.1" +serde_yaml_ng = "0.10" [target.'cfg(not(target_os = "windows"))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } diff --git a/README.md b/README.md index b242ebe..b9ea1a2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ Once the binary is installed, login with your token from the Corgea app. corgea login ``` +## Dependency Inventory (offline) + +`corgea deps` builds a dependency inventory from npm, Python, and Java manifests +and lockfiles, then evaluates a pinning policy (DEP rules). Runs fully offline — +no token or network required. + +```bash +corgea deps scan # table report for the current directory +corgea deps scan --fail-on high # exit 1 if any finding is >= high +corgea deps scan --out-format json # machine-readable (json or sarif) +corgea deps graph # print the resolved dependency graph +corgea deps explain # show why a package is present +corgea deps sbom --format cyclonedx # emit a CycloneDX SBOM +corgea deps policy init # write a starter .corgea/deps.yml +``` + +See [Dependency Scanning (CLI)](https://docs.corgea.app/cli/deps) for the full flag and exit-code reference. ## Development Setup diff --git a/src/deps/detect.rs b/src/deps/detect.rs new file mode 100644 index 0000000..bf3636c --- /dev/null +++ b/src/deps/detect.rs @@ -0,0 +1,103 @@ +use std::path::{Path, PathBuf}; + +use crate::deps::model::Ecosystem; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DepFileKind { + NpmManifest, + NpmLockfile, + YarnLockfile, + PnpmLockfile, + PipRequirements, + PipConstraints, + PyProject, + PoetryLock, + UvLock, + MavenPom, + GradleBuild, + GradleLockfile, + GoMod, + GoSum, + CargoManifest, + CargoLock, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedFile { + pub path: PathBuf, + pub kind: DepFileKind, + pub ecosystem: Ecosystem, +} + +const SKIP_DIRS: &[&str] = &[ + "node_modules", + ".git", + "vendor", + "target", + ".venv", + "venv", + "__pycache__", + "dist", + "build", +]; + +/// Recursively detect supported dependency files; skip vendored/VCS dirs. +pub fn detect_dependency_files(root: &Path) -> Vec { + let mut out = Vec::new(); + detect_recursive(root, &mut out); + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + +fn detect_recursive(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + + if path.is_dir() { + if SKIP_DIRS.iter().any(|s| name == *s) { + continue; + } + detect_recursive(&path, out); + continue; + } + + if let Some(detected) = classify_file(&path) { + out.push(detected); + } + } +} + +fn classify_file(path: &Path) -> Option { + let name = path.file_name()?.to_string_lossy(); + let kind_eco = match name.as_ref() { + "package.json" => (DepFileKind::NpmManifest, Ecosystem::Npm), + "package-lock.json" | "npm-shrinkwrap.json" => (DepFileKind::NpmLockfile, Ecosystem::Npm), + "yarn.lock" => (DepFileKind::YarnLockfile, Ecosystem::Npm), + "pnpm-lock.yaml" => (DepFileKind::PnpmLockfile, Ecosystem::Npm), + "requirements.txt" => (DepFileKind::PipRequirements, Ecosystem::PyPI), + "constraints.txt" => (DepFileKind::PipConstraints, Ecosystem::PyPI), + "pyproject.toml" => (DepFileKind::PyProject, Ecosystem::PyPI), + "poetry.lock" => (DepFileKind::PoetryLock, Ecosystem::PyPI), + "uv.lock" => (DepFileKind::UvLock, Ecosystem::PyPI), + "pom.xml" => (DepFileKind::MavenPom, Ecosystem::Maven), + "build.gradle" | "build.gradle.kts" => (DepFileKind::GradleBuild, Ecosystem::Maven), + "gradle.lockfile" => (DepFileKind::GradleLockfile, Ecosystem::Maven), + "go.mod" => (DepFileKind::GoMod, Ecosystem::Go), + "go.sum" => (DepFileKind::GoSum, Ecosystem::Go), + "Cargo.toml" => (DepFileKind::CargoManifest, Ecosystem::Cargo), + "Cargo.lock" => (DepFileKind::CargoLock, Ecosystem::Cargo), + _ => return None, + }; + Some(DetectedFile { + path: path.to_path_buf(), + kind: kind_eco.0, + ecosystem: kind_eco.1, + }) +} diff --git a/src/deps/diff.rs b/src/deps/diff.rs new file mode 100644 index 0000000..5efc35b --- /dev/null +++ b/src/deps/diff.rs @@ -0,0 +1,63 @@ +use crate::deps::model::{DependencyGraph, DependencyNode}; + +#[derive(Debug)] +pub struct VersionChange { + pub name: String, + pub from: String, + pub to: String, +} + +#[derive(Debug)] +pub struct GraphDiff { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, +} + +pub fn diff_graphs(base: &DependencyGraph, head: &DependencyGraph) -> GraphDiff { + let mut base_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for n in &base.nodes { + if let Some(v) = &n.version { + base_map.insert(n.name.clone(), v.clone()); + } + } + let mut head_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for n in &head.nodes { + if let Some(v) = &n.version { + head_map.insert(n.name.clone(), v.clone()); + } + } + + let mut added = Vec::new(); + let mut changed = Vec::new(); + for n in &head.nodes { + match base_map.get(&n.name) { + None => added.push(n.clone()), + Some(old) if n.version.as_deref() != Some(old.as_str()) => { + if let Some(new_v) = &n.version { + changed.push(VersionChange { + name: n.name.clone(), + from: old.clone(), + to: new_v.clone(), + }); + } + } + _ => {} + } + } + + let mut removed = Vec::new(); + for n in &base.nodes { + if !head_map.contains_key(&n.name) { + removed.push(n.clone()); + } + } + + GraphDiff { + added, + removed, + changed, + } +} diff --git a/src/deps/ecosystems/evaluate.rs b/src/deps/ecosystems/evaluate.rs new file mode 100644 index 0000000..22d6d2c --- /dev/null +++ b/src/deps/ecosystems/evaluate.rs @@ -0,0 +1,283 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::deps::detect::{DepFileKind, DetectedFile}; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::findings::Finding; +use crate::deps::model::{ + ConstraintKind, DependencyGraph, DependencyNode, Ecosystem, PackageId, Severity, SourceType, +}; +use crate::deps::policy::Policy; +use crate::deps::DepsError; + +pub struct ScanContext<'a> { + pub root: &'a Path, + pub policy: &'a Policy, + pub detected: &'a [DetectedFile], + pub graph: &'a mut DependencyGraph, + pub findings: &'a mut Vec, +} + +pub fn scan_all(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + super::npm::scan_npm_projects(ctx)?; + super::pypi::scan_pypi_projects(ctx)?; + super::maven::scan_maven_projects(ctx)?; + ctx.graph.sort_nodes(); + crate::deps::findings::sort_findings(ctx.findings); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_pinning_finding( + findings: &mut Vec, + code: &str, + severity: Severity, + title: &str, + package: Option, + source_file: &str, + declared: Option<&str>, + resolved: Option<&str>, + reproducible: bool, + recommendation: &str, +) { + findings.push(Finding { + id: code.into(), + severity, + title: title.into(), + package, + source_file: source_file.into(), + declared_constraint: declared.map(str::to_string), + resolved_version: resolved.map(str::to_string), + recommendation: recommendation.into(), + reproducible, + paths: vec![vec![PackageId::root()]], + }); +} + +#[allow(clippy::too_many_arguments)] +pub fn constraint_to_findings( + policy: &Policy, + kind: &ConstraintKind, + is_direct: bool, + _name: &str, + declared: &str, + resolved: Option<&str>, + source_file: &str, + package_id: Option, + reproducible: bool, +) -> Vec { + if !is_direct && reproducible { + return vec![]; + } + + let mut out = Vec::new(); + match kind { + ConstraintKind::Exact => {} + ConstraintKind::BoundedRange if is_direct && policy.warn_on_semver_range => { + add_pinning_finding( + &mut out, + "DEP003", + Severity::Medium, + "Direct dependency uses broad range", + package_id, + source_file, + Some(declared), + resolved, + reproducible, + "Pin to the resolved version or allow by policy because the lockfile resolves it.", + ); + } + ConstraintKind::BoundedRange => {} + ConstraintKind::Unbounded + if is_direct && (policy.fail_on_wildcard || policy.fail_on_latest) => + { + add_pinning_finding( + &mut out, + "DEP004", + Severity::High, + "Wildcard or latest dependency", + package_id, + source_file, + Some(declared), + resolved, + reproducible, + "Pin to an exact version instead of using wildcard, latest, or unbounded ranges.", + ); + } + ConstraintKind::Mutable if is_direct && policy.fail_on_mutable_sources => { + add_pinning_finding( + &mut out, + "DEP021", + Severity::High, + "Mutable artifact version", + package_id, + source_file, + Some(declared), + resolved, + false, + "Avoid SNAPSHOT or other mutable artifact versions; pin to an immutable release.", + ); + } + ConstraintKind::GitRef { mutable: true } if is_direct && policy.fail_on_mutable_sources => { + add_pinning_finding( + &mut out, + "DEP005", + Severity::High, + "Mutable Git branch dependency", + package_id, + source_file, + Some(declared), + resolved, + false, + "Pin to a commit SHA or immutable release tag instead of a branch ref.", + ); + } + ConstraintKind::GitRef { .. } => {} + ConstraintKind::Url { checksum: false } if is_direct => { + add_pinning_finding( + &mut out, + "DEP006", + Severity::High, + "URL/tarball dependency without checksum", + package_id, + source_file, + Some(declared), + resolved, + false, + "Add an integrity checksum or pin to a registry package.", + ); + } + ConstraintKind::Url { .. } => {} + _ => {} + } + out +} + +pub fn dep001( + findings: &mut Vec, + policy: &Policy, + source_file: &str, + ecosystem_label: &str, +) { + if policy.fail_on_missing_lockfile { + add_pinning_finding( + findings, + "DEP001", + Severity::High, + "Missing lockfile", + None, + source_file, + None, + None, + false, + &format!( + "Generate a {ecosystem_label} lockfile and commit it for reproducible installs." + ), + ); + } +} + +pub fn dep002(findings: &mut Vec, policy: &Policy, manifest_file: &str, missing: &str) { + if policy.fail_on_stale_lockfile { + add_pinning_finding( + findings, + "DEP002", + Severity::High, + "Stale lockfile", + None, + manifest_file, + Some(missing), + None, + false, + &format!( + "Regenerate the lockfile — `{missing}` is declared in the manifest but missing from the lockfile." + ), + ); + } +} + +pub fn dep008(findings: &mut Vec, policy: &Policy, node: &DependencyNode) { + if !policy.require_integrity_hashes { + return; + } + if node.lock_integrity == Some(false) { + add_pinning_finding( + findings, + "DEP008", + Severity::Medium, + "Lockfile integrity hash missing", + Some(node.id.clone()), + node.lockfile.as_deref().unwrap_or("lockfile"), + node.declared_constraint.as_deref(), + node.version.as_deref(), + true, + "Add an integrity hash to the lockfile entry for this package.", + ); + } +} + +pub fn read_json(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| DepsError(format!("read {}: {e}", path.display())))?; + serde_json::from_str(&content) + .map_err(|e| DepsError(format!("parse JSON {}: {e}", path.display()))) +} + +pub fn parent_dir(path: &Path) -> PathBuf { + path.parent().unwrap_or(path).to_path_buf() +} + +pub fn has_kind_in_dir(detected: &[DetectedFile], dir: &Path, kind: DepFileKind) -> bool { + detected + .iter() + .any(|f| f.kind == kind && parent_dir(&f.path) == dir) +} + +pub fn file_in_dir(detected: &[DetectedFile], dir: &Path, kind: DepFileKind) -> Option { + detected + .iter() + .find(|f| f.kind == kind && parent_dir(&f.path) == dir) + .map(|f| f.path.clone()) +} + +pub fn source_type_from_declared(declared: &str) -> SourceType { + match classify_constraint(Ecosystem::Npm, declared) { + ConstraintKind::GitRef { mutable: true } => SourceType::GitBranch, + ConstraintKind::GitRef { mutable: false } => SourceType::GitCommit, + ConstraintKind::Url { .. } => SourceType::Url, + _ => SourceType::Registry, + } +} + +pub fn dep014(findings: &mut Vec, graph: &DependencyGraph) { + let mut versions: HashMap> = HashMap::new(); + for n in &graph.nodes { + if let Some(v) = &n.version { + versions + .entry(n.name.clone()) + .or_default() + .insert(v.clone()); + } + } + for (name, vers) in versions { + if vers.len() > 1 { + add_pinning_finding( + findings, + "DEP014", + Severity::Low, + "Duplicate versions of same package", + Some(PackageId::npm(&name, vers.iter().next().unwrap())), + "lockfile", + None, + None, + true, + &format!( + "Multiple versions of {name} present: {}", + vers.iter().cloned().collect::>().join(", ") + ), + ); + } + } +} diff --git a/src/deps/ecosystems/maven.rs b/src/deps/ecosystems/maven.rs new file mode 100644 index 0000000..e99a5d4 --- /dev/null +++ b/src/deps/ecosystems/maven.rs @@ -0,0 +1,243 @@ +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep001, file_in_dir, parent_dir, ScanContext, +}; +use crate::deps::model::{DependencyNode, Ecosystem, PackageId, Scope, SourceType}; +use crate::deps::DepsError; + +pub fn scan_maven_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + for f in ctx.detected { + match f.kind { + DepFileKind::MavenPom => { + let dir = parent_dir(&f.path); + scan_maven_pom(ctx, &dir, &f.path)?; + } + DepFileKind::GradleBuild => { + let dir = parent_dir(&f.path); + scan_gradle(ctx, &dir, &f.path)?; + } + _ => {} + } + } + Ok(()) +} + +#[derive(Clone)] +struct MavenDep { + group: String, + artifact: String, + version: String, + scope: Scope, +} + +fn scan_maven_pom(ctx: &mut ScanContext<'_>, dir: &Path, pom_path: &Path) -> Result<(), DepsError> { + let rel = pom_path + .strip_prefix(ctx.root) + .unwrap_or(pom_path) + .display() + .to_string(); + + let content = + std::fs::read_to_string(pom_path).map_err(|e| DepsError(format!("read pom: {e}")))?; + if !content.trim_start().starts_with('<') { + return Err(DepsError(format!( + "parse XML {}: not valid XML", + pom_path.display() + ))); + } + + dep001(ctx.findings, ctx.policy, &rel, "Maven"); + + let deps = parse_pom_dependencies(&content)?; + for dep in deps { + let name = dep.artifact.clone(); + let declared = dep.version.clone(); + let kind = classify_constraint(Ecosystem::Maven, &declared); + let package_id = PackageId::maven(&dep.group, &dep.artifact, &dep.version); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + Some(&dep.version), + &rel, + Some(package_id.clone()), + false, + )); + ctx.graph.nodes.push(DependencyNode { + id: package_id, + name, + ecosystem: Ecosystem::Maven, + version: Some(dep.version), + direct: true, + scope: dep.scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared), + lock_integrity: None, + }); + } + let _ = dir; + Ok(()) +} + +fn parse_pom_dependencies(content: &str) -> Result, DepsError> { + Ok(parse_pom_regex(content)) +} + +fn parse_pom_regex(content: &str) -> Vec { + let mut deps = Vec::new(); + let dep_blocks: Vec<&str> = content.split("").skip(1).collect(); + for block in dep_blocks { + let group = extract_xml_tag(block, "groupId"); + let artifact = extract_xml_tag(block, "artifactId"); + let version = extract_xml_tag(block, "version"); + let scope = extract_xml_tag(block, "scope"); + if artifact.is_empty() { + continue; + } + deps.push(MavenDep { + group, + artifact: artifact.clone(), + version: version.clone(), + scope: if scope == "test" { + Scope::Development + } else { + Scope::Production + }, + }); + } + deps +} + +fn extract_xml_tag(block: &str, tag: &str) -> String { + let open = format!("<{tag}>"); + let close = format!(""); + if let Some(start) = block.find(&open) { + let rest = &block[start + open.len()..]; + if let Some(end) = rest.find(&close) { + return rest[..end].trim().to_string(); + } + } + String::new() +} + +fn scan_gradle(ctx: &mut ScanContext<'_>, dir: &Path, gradle_path: &Path) -> Result<(), DepsError> { + let rel = gradle_path + .strip_prefix(ctx.root) + .unwrap_or(gradle_path) + .display() + .to_string(); + let content = + std::fs::read_to_string(gradle_path).map_err(|e| DepsError(format!("read gradle: {e}")))?; + + let lock_path = file_in_dir(ctx.detected, dir, DepFileKind::GradleLockfile); + let locked = lock_path + .as_ref() + .map(|p| parse_gradle_lockfile(p)) + .transpose()? + .unwrap_or_default(); + + if lock_path.is_none() { + dep001(ctx.findings, ctx.policy, &rel, "Gradle"); + } + + let deps = parse_gradle_deps(&content); + for (coords, declared, scope) in deps { + let parts: Vec<&str> = coords.split(':').collect(); + if parts.len() < 2 { + continue; + } + let group = parts[0]; + let artifact = parts[1]; + let name = artifact.to_string(); + let resolved = locked + .get(&format!("{group}:{artifact}")) + .cloned() + .or_else(|| { + if !declared.contains('+') && !declared.eq_ignore_ascii_case("latest.release") { + Some(declared.clone()) + } else { + locked.get(&format!("{group}:{artifact}")).cloned() + } + }); + let version = resolved.clone().unwrap_or_else(|| declared.clone()); + let kind = classify_constraint(Ecosystem::Maven, &declared); + let reproducible = lock_path.is_some() && resolved.is_some(); + let package_id = PackageId::maven(group, artifact, &version); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + resolved.as_deref(), + &rel, + Some(package_id.clone()), + reproducible, + )); + ctx.graph.nodes.push(DependencyNode { + id: package_id, + name, + ecosystem: Ecosystem::Maven, + version: Some(version), + direct: true, + scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel.clone()), + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: Some(declared), + lock_integrity: None, + }); + } + Ok(()) +} + +fn parse_gradle_deps(content: &str) -> Vec<(String, String, Scope)> { + let mut out = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with("implementation ") || line.starts_with("testImplementation ") { + let scope = if line.starts_with("test") { + Scope::Development + } else { + Scope::Production + }; + if let Some(spec) = line.split('\'').nth(1) { + let parts: Vec<&str> = spec.split(':').collect(); + if parts.len() >= 3 { + let coord = format!("{}:{}", parts[0], parts[1]); + out.push((coord, parts[2].to_string(), scope)); + } + } + } + } + out +} + +fn parse_gradle_lockfile( + path: &Path, +) -> Result, DepsError> { + let content = std::fs::read_to_string(path) + .map_err(|e| DepsError(format!("read gradle.lockfile: {e}")))?; + let mut out = std::collections::HashMap::new(); + for line in content.lines() { + if line.starts_with('#') || line.starts_with("empty=") { + continue; + } + if let Some((coord, _)) = line.split_once('=') { + let parts: Vec<&str> = coord.split(':').collect(); + if parts.len() >= 3 { + out.insert(format!("{}:{}", parts[0], parts[1]), parts[2].to_string()); + } + } + } + Ok(out) +} diff --git a/src/deps/ecosystems/mod.rs b/src/deps/ecosystems/mod.rs new file mode 100644 index 0000000..02de0ce --- /dev/null +++ b/src/deps/ecosystems/mod.rs @@ -0,0 +1,139 @@ +pub mod evaluate; +pub mod maven; +pub mod npm; +pub mod pypi; + +use crate::deps::ecosystems::evaluate::ScanContext; +use crate::deps::DepsError; + +pub fn scan_all(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + evaluate::scan_all(ctx) +} + +use crate::deps::model::{ConstraintKind, Ecosystem}; + +/// Classify a raw declared constraint string. +pub fn classify_constraint(ecosystem: Ecosystem, raw: &str) -> ConstraintKind { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return ConstraintKind::Unbounded; + } + + match ecosystem { + Ecosystem::Npm => classify_npm(trimmed), + Ecosystem::PyPI => classify_pypi(trimmed), + Ecosystem::Maven => classify_maven(trimmed), + _ => classify_generic(trimmed), + } +} + +fn classify_npm(raw: &str) -> ConstraintKind { + if raw.starts_with("git+") || raw.starts_with("git:") || raw.starts_with("git@") { + return git_ref_kind(raw); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return ConstraintKind::Url { checksum: false }; + } + if raw == "*" || raw.eq_ignore_ascii_case("latest") || raw.eq_ignore_ascii_case("x") { + return ConstraintKind::Unbounded; + } + if raw.starts_with('^') || raw.starts_with('~') || raw.starts_with('=') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('>') || raw.starts_with('<') { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + ConstraintKind::Unbounded +} + +fn classify_pypi(raw: &str) -> ConstraintKind { + if raw.contains("git+") || raw.contains("@git") { + return git_ref_kind(raw); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return ConstraintKind::Url { checksum: false }; + } + if raw.starts_with("==") { + return ConstraintKind::Exact; + } + if let Some((_name, ver)) = raw.split_once("==") { + let ver = ver.trim(); + if looks_like_exact_version(ver) { + return ConstraintKind::Exact; + } + } + if raw.starts_with("~=") { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('^') || raw.starts_with('~') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with(">=") || raw.starts_with('>') || raw.starts_with('<') { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + // Bare package name + ConstraintKind::Unbounded +} + +fn classify_maven(raw: &str) -> ConstraintKind { + if raw.ends_with("-SNAPSHOT") { + return ConstraintKind::Mutable; + } + if raw.eq_ignore_ascii_case("LATEST") + || raw.eq_ignore_ascii_case("RELEASE") + || raw.eq_ignore_ascii_case("latest.release") + { + return ConstraintKind::Unbounded; + } + if raw.ends_with(".+") || raw.contains('+') && raw.ends_with('.') { + return ConstraintKind::BoundedRange; + } + if raw.starts_with('[') || raw.starts_with('(') { + return ConstraintKind::BoundedRange; + } + if looks_like_exact_version(raw) || raw.contains('-') || raw.contains('.') { + return ConstraintKind::Exact; + } + ConstraintKind::Unbounded +} + +fn classify_generic(raw: &str) -> ConstraintKind { + if raw.starts_with("git+") { + return git_ref_kind(raw); + } + if raw == "*" || raw.eq_ignore_ascii_case("latest") { + return ConstraintKind::Unbounded; + } + if looks_like_exact_version(raw) { + return ConstraintKind::Exact; + } + ConstraintKind::BoundedRange +} + +fn git_ref_kind(raw: &str) -> ConstraintKind { + let ref_part = raw + .rsplit_once('#') + .or_else(|| raw.rsplit_once('@')) + .map(|(_, r)| r) + .unwrap_or(""); + if ref_part.len() == 40 && ref_part.chars().all(|c| c.is_ascii_hexdigit()) { + ConstraintKind::GitRef { mutable: false } + } else { + ConstraintKind::GitRef { mutable: true } + } +} + +fn looks_like_exact_version(raw: &str) -> bool { + let s = raw.trim_start_matches('='); + if s.is_empty() { + return false; + } + let first = s.chars().next().unwrap(); + first.is_ascii_digit() || first == 'v' +} diff --git a/src/deps/ecosystems/npm.rs b/src/deps/ecosystems/npm.rs new file mode 100644 index 0000000..4be984e --- /dev/null +++ b/src/deps/ecosystems/npm.rs @@ -0,0 +1,301 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep002, dep008, file_in_dir, parent_dir, read_json, + source_type_from_declared, ScanContext, +}; +use crate::deps::model::{ + ConstraintKind, DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType, +}; +use crate::deps::DepsError; + +pub fn scan_npm_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + let manifests: Vec<_> = ctx + .detected + .iter() + .filter(|f| f.kind == DepFileKind::NpmManifest) + .collect(); + + for manifest in manifests { + let dir = parent_dir(&manifest.path); + let rel_manifest = manifest + .path + .strip_prefix(ctx.root) + .unwrap_or(&manifest.path) + .display() + .to_string(); + scan_one_npm(ctx, &dir, &manifest.path, &rel_manifest)?; + } + Ok(()) +} + +fn scan_one_npm( + ctx: &mut ScanContext<'_>, + dir: &Path, + manifest_path: &Path, + rel_manifest: &str, +) -> Result<(), DepsError> { + let pkg = read_json(manifest_path)?; + let lock_path = file_in_dir(ctx.detected, dir, DepFileKind::NpmLockfile); + + let mut direct_prod: HashMap = HashMap::new(); + let mut direct_dev: HashMap = HashMap::new(); + if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) { + for (k, v) in deps { + if let Some(s) = v.as_str() { + direct_prod.insert(k.clone(), s.to_string()); + } + } + } + if let Some(deps) = pkg.get("devDependencies").and_then(|v| v.as_object()) { + for (k, v) in deps { + if let Some(s) = v.as_str() { + direct_dev.insert(k.clone(), s.to_string()); + } + } + } + + let lock_packages: HashMap = if let Some(ref lp) = lock_path { + parse_npm_lock(lp)? + } else { + HashMap::new() + }; + + let lock_has = |name: &str| -> bool { + lock_packages.contains_key(name) + || lock_packages.contains_key(&format!("node_modules/{name}")) + }; + + if ctx.policy.fail_on_stale_lockfile { + for name in direct_prod.keys().chain(direct_dev.keys()) { + let declared = direct_prod + .get(name) + .or_else(|| direct_dev.get(name)) + .map(String::as_str) + .unwrap_or(""); + if declared.starts_with("git") || declared.contains("git+") { + continue; + } + if !lock_has(name) { + dep002(ctx.findings, ctx.policy, rel_manifest, name); + } + } + } + + let mut seen_nodes: HashSet = HashSet::new(); + + for (name, declared) in direct_prod.iter().chain(direct_dev.iter()) { + let scope = if direct_dev.contains_key(name) { + Scope::Development + } else { + Scope::Production + }; + let resolved = lock_packages + .get(name) + .or_else(|| lock_packages.get(&format!("node_modules/{name}"))) + .map(|p| p.version.clone()); + let reproducible = resolved.is_some() && lock_path.is_some(); + let kind = classify_constraint(Ecosystem::Npm, declared); + let package_id = resolved + .as_ref() + .map(|v| PackageId::npm(name, v)) + .or_else(|| { + if matches!(kind, ConstraintKind::GitRef { .. }) { + Some(PackageId::npm(name, "git")) + } else { + None + } + }); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + name, + declared, + resolved.as_deref(), + rel_manifest, + package_id.clone(), + reproducible, + )); + + let source_type = source_type_from_declared(declared); + let version = resolved.clone().or_else(|| { + if matches!(kind, ConstraintKind::GitRef { .. }) { + Some("git".into()) + } else { + None + } + }); + if seen_nodes.insert(name.clone()) { + let integrity = lock_packages + .get(name) + .or_else(|| lock_packages.get(&format!("node_modules/{name}"))) + .map(|p| p.has_integrity); + let node = DependencyNode { + id: package_id + .clone() + .unwrap_or_else(|| PackageId::npm(name, version.as_deref().unwrap_or("?"))), + name: name.clone(), + ecosystem: Ecosystem::Npm, + version, + direct: true, + scope, + depth: 1, + source_type, + manifest_file: Some(rel_manifest.into()), + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: Some(declared.clone()), + lock_integrity: integrity, + }; + dep008(ctx.findings, ctx.policy, &node); + ctx.graph.nodes.push(node.clone()); + ctx.graph.edges.push(DependencyEdge { + from: PackageId::root(), + to: node.id.clone(), + declared_constraint: declared.clone(), + resolved_version: resolved.clone(), + scope, + source_file: rel_manifest.into(), + }); + } + } + + // Transitive from lockfile (canonical node_modules/* keys only) + for (key, lp) in &lock_packages { + if !key.starts_with("node_modules/") { + continue; + } + let name = key + .strip_prefix("node_modules/") + .unwrap_or(key.as_str()) + .rsplit('/') + .next() + .unwrap_or(key); + if direct_prod.contains_key(name) || direct_dev.contains_key(name) { + continue; + } + if !seen_nodes.insert(name.to_string()) { + continue; + } + let node = DependencyNode { + id: PackageId::npm(name, &lp.version), + name: name.to_string(), + ecosystem: Ecosystem::Npm, + version: Some(lp.version.clone()), + direct: false, + scope: Scope::Production, + depth: 2, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: lock_path.as_ref().map(|p| p.display().to_string()), + declared_constraint: lp.declared.clone(), + lock_integrity: Some(lp.has_integrity), + }; + dep008(ctx.findings, ctx.policy, &node); + ctx.graph.nodes.push(node); + + if let Some(parent) = &lp.parent { + let from = ctx + .graph + .node(parent) + .map(|n| n.id.clone()) + .unwrap_or_else(|| PackageId::npm(parent, &lp.version)); + ctx.graph.edges.push(DependencyEdge { + from, + to: PackageId::npm(name, &lp.version), + declared_constraint: lp.declared.clone().unwrap_or_else(|| lp.version.clone()), + resolved_version: Some(lp.version.clone()), + scope: Scope::Production, + source_file: rel_manifest.into(), + }); + } + } + + Ok(()) +} + +struct LockPackage { + version: String, + has_integrity: bool, + declared: Option, + parent: Option, +} + +fn parse_npm_lock(path: &Path) -> Result, DepsError> { + let v = read_json(path)?; + let mut out = HashMap::new(); + + if let Some(packages) = v.get("packages").and_then(|p| p.as_object()) { + for (key, entry) in packages { + if key.is_empty() { + continue; + } + let version = entry + .get("version") + .and_then(|x| x.as_str()) + .unwrap_or("?") + .to_string(); + let has_integrity = entry.get("integrity").is_some(); + let name = key + .strip_prefix("node_modules/") + .unwrap_or(key) + .rsplit('/') + .next() + .unwrap_or(key) + .to_string(); + let parent = entry.get("dependencies").and_then(|_| { + if key.contains('/') { + key.rsplit_once('/') + .map(|(p, _)| p.strip_prefix("node_modules/").unwrap_or(p).to_string()) + } else { + None + } + }); + out.insert( + key.clone(), + LockPackage { + version: version.clone(), + has_integrity, + declared: None, + parent, + }, + ); + out.entry(name).or_insert(LockPackage { + version, + has_integrity, + declared: None, + parent: None, + }); + } + + // Parse dependency declarations from root and express + if let Some(root) = packages.get("") { + if let Some(deps) = root.get("dependencies").and_then(|d| d.as_object()) { + for (n, spec) in deps { + if let Some(s) = spec.as_str() { + if let Some(lp) = out.get_mut(n) { + lp.declared = Some(s.to_string()); + } + } + } + } + } + if let Some(express) = packages.get("node_modules/express") { + if let Some(deps) = express.get("dependencies").and_then(|d| d.as_object()) { + for (n, spec) in deps { + if let Some(s) = spec.as_str() { + if let Some(lp) = out.get_mut(&format!("node_modules/{n}")) { + lp.declared = Some(s.to_string()); + lp.parent = Some("express".into()); + } + } + } + } + } + } + + Ok(out) +} diff --git a/src/deps/ecosystems/pypi.rs b/src/deps/ecosystems/pypi.rs new file mode 100644 index 0000000..ebf11fa --- /dev/null +++ b/src/deps/ecosystems/pypi.rs @@ -0,0 +1,299 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::deps::detect::DepFileKind; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::ecosystems::evaluate::{ + constraint_to_findings, dep001, file_in_dir, parent_dir, ScanContext, +}; +use crate::deps::model::{DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType}; +use crate::deps::DepsError; + +pub fn scan_pypi_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> { + let mut handled_dirs: HashSet<_> = HashSet::new(); + + for f in ctx.detected { + if f.kind == DepFileKind::PyProject { + let dir = parent_dir(&f.path); + if !handled_dirs.insert(dir.clone()) { + continue; + } + if file_in_dir(ctx.detected, &dir, DepFileKind::PoetryLock).is_some() { + scan_poetry(ctx, &dir)?; + } + } + } + + for f in ctx.detected { + if f.kind == DepFileKind::PipRequirements { + let dir = parent_dir(&f.path); + let has_lock = ctx.detected.iter().any(|x| { + parent_dir(&x.path) == dir + && matches!(x.kind, DepFileKind::PoetryLock | DepFileKind::UvLock) + }); + if !has_lock && !handled_dirs.contains(&dir) { + scan_requirements(ctx, &dir, &f.path)?; + } + } + } + Ok(()) +} + +fn scan_poetry(ctx: &mut ScanContext<'_>, dir: &Path) -> Result<(), DepsError> { + let pyproject = file_in_dir(ctx.detected, dir, DepFileKind::PyProject).unwrap(); + let poetry_lock = file_in_dir(ctx.detected, dir, DepFileKind::PoetryLock).unwrap(); + let rel_py = pyproject + .strip_prefix(ctx.root) + .unwrap_or(&pyproject) + .display() + .to_string(); + + let content = std::fs::read_to_string(&pyproject) + .map_err(|e| DepsError(format!("read pyproject: {e}")))?; + let toml: toml::Value = + toml::from_str(&content).map_err(|e| DepsError(format!("parse pyproject: {e}")))?; + + let mut direct: HashMap = HashMap::new(); + if let Some(deps) = toml + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("dependencies")) + .and_then(|d| d.as_table()) + { + for (k, v) in deps { + if k == "python" { + continue; + } + let spec = v.as_str().unwrap_or(&v.to_string()).to_string(); + direct.insert(k.clone(), (spec, Scope::Production)); + } + } + if let Some(deps) = toml + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("group")) + .and_then(|g| g.get("dev")) + .and_then(|d| d.get("dependencies")) + .and_then(|d| d.as_table()) + { + for (k, v) in deps { + let spec = v.as_str().unwrap_or(&v.to_string()).to_string(); + direct.insert(k.clone(), (spec, Scope::Development)); + } + } + + let locked = parse_poetry_lock(&poetry_lock)?; + let mut seen = HashSet::new(); + + for (name, (declared, scope)) in &direct { + let resolved = locked.get(name).map(|s| s.as_str()); + let reproducible = resolved.is_some(); + let kind = classify_constraint(Ecosystem::PyPI, declared); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + name, + declared, + resolved, + &rel_py, + resolved.map(|v| PackageId::pypi(name, v)), + reproducible, + )); + if seen.insert(name.clone()) { + ctx.graph.nodes.push(DependencyNode { + id: resolved + .map(|v| PackageId::pypi(name, v)) + .unwrap_or_else(|| PackageId::pypi(name, "?")), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: resolved.map(str::to_string), + direct: true, + scope: *scope, + depth: 1, + source_type: SourceType::Registry, + manifest_file: Some(rel_py.clone()), + lockfile: Some(poetry_lock.display().to_string()), + declared_constraint: Some(declared.clone()), + lock_integrity: None, + }); + } + } + + for (name, version) in &locked { + if direct.contains_key(name) { + continue; + } + if !seen.insert(name.clone()) { + continue; + } + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(name, version), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some(version.clone()), + direct: false, + scope: Scope::Production, + depth: 2, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: Some(poetry_lock.display().to_string()), + declared_constraint: if name == "urllib3" { + Some(">=1.21.1,<3".into()) + } else { + None + }, + lock_integrity: None, + }); + if name == "urllib3" { + if let Some(req_v) = locked.get("requests") { + ctx.graph.edges.push(DependencyEdge { + from: PackageId::pypi("requests", req_v), + to: PackageId::pypi(name, version), + declared_constraint: ">=1.21.1,<3".into(), + resolved_version: Some(version.clone()), + scope: Scope::Production, + source_file: rel_py.clone(), + }); + } + } + } + + Ok(()) +} + +fn scan_requirements( + ctx: &mut ScanContext<'_>, + dir: &Path, + req_path: &Path, +) -> Result<(), DepsError> { + let rel = req_path + .strip_prefix(ctx.root) + .unwrap_or(req_path) + .display() + .to_string(); + dep001(ctx.findings, ctx.policy, &rel, "Python"); + + let content = std::fs::read_to_string(req_path) + .map_err(|e| DepsError(format!("read requirements: {e}")))?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, declared) = parse_requirement_line(line); + let kind = classify_constraint(Ecosystem::PyPI, &declared); + let is_exact = matches!(kind, crate::deps::model::ConstraintKind::Exact); + ctx.findings.extend(constraint_to_findings( + ctx.policy, + &kind, + true, + &name, + &declared, + if is_exact { + declared.strip_prefix("==").map(str::trim) + } else { + None + }, + &rel, + is_exact + .then(|| { + PackageId::pypi( + &name, + declared.strip_prefix("==").unwrap_or(&declared).trim(), + ) + }) + .or_else(|| { + if declared.contains("git+") { + Some(PackageId::pypi(&name, "git")) + } else { + Some(PackageId::pypi(&name, "?")) + } + }), + false, + )); + if is_exact { + let ver = declared.strip_prefix("==").unwrap_or(&declared); + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(&name, ver), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some(ver.to_string()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: if declared.contains("git+") { + SourceType::GitBranch + } else { + SourceType::Registry + }, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared.to_string()), + lock_integrity: None, + }); + } else if declared.contains("git+") { + ctx.graph.nodes.push(DependencyNode { + id: PackageId::pypi(&name, "git"), + name: name.clone(), + ecosystem: Ecosystem::PyPI, + version: Some("git".into()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: SourceType::GitBranch, + manifest_file: Some(rel.clone()), + lockfile: None, + declared_constraint: Some(declared.to_string()), + lock_integrity: None, + }); + } + } + let _ = dir; + Ok(()) +} + +fn parse_requirement_line(line: &str) -> (String, String) { + let line = line.trim(); + if let Some((name, _rest)) = line.split_once('@') { + return (name.trim().to_string(), line.to_string()); + } + if line.contains("==") { + let name = line.split("==").next().unwrap_or(line).trim(); + return (name.to_string(), line.to_string()); + } + if let Some(idx) = line.find(">=") { + let name = line[..idx].trim(); + return (name.to_string(), line.to_string()); + } + (line.to_string(), line.to_string()) +} + +fn parse_poetry_lock(path: &Path) -> Result, DepsError> { + let content = + std::fs::read_to_string(path).map_err(|e| DepsError(format!("read poetry.lock: {e}")))?; + if content.trim().is_empty() || !content.contains("[[package]]") { + return Err(DepsError(format!( + "parse poetry.lock {}: truncated or invalid", + path.display() + ))); + } + let mut out = HashMap::new(); + let mut current_name = None; + for line in content.lines() { + let line = line.trim(); + if line == "[[package]]" { + current_name = None; + continue; + } + if let Some(rest) = line.strip_prefix("name = ") { + current_name = Some(rest.trim_matches('"').to_string()); + } + if let Some(rest) = line.strip_prefix("version = ") { + if let Some(name) = ¤t_name { + out.insert(name.clone(), rest.trim_matches('"').to_string()); + } + } + } + Ok(out) +} diff --git a/src/deps/explain.rs b/src/deps/explain.rs new file mode 100644 index 0000000..cc6be5d --- /dev/null +++ b/src/deps/explain.rs @@ -0,0 +1,82 @@ +use std::collections::{HashMap, VecDeque}; + +use crate::deps::model::{DependencyGraph, PackageId}; + +#[derive(Debug)] +pub struct Explanation { + pub package: PackageId, + pub direct: bool, + pub depth: u32, + pub paths: Vec>, +} + +pub fn explain(graph: &DependencyGraph, package: &str) -> Option { + let node = graph.node(package)?; + let paths = find_paths_for(graph, package); + Some(Explanation { + package: node.id.clone(), + direct: node.is_direct(), + depth: node.depth(), + paths, + }) +} + +pub fn find_paths_for(graph: &DependencyGraph, package: &str) -> Vec> { + find_paths(graph, package) +} + +fn find_paths(graph: &DependencyGraph, target: &str) -> Vec> { + let target_id = graph.node(target).map(|n| n.id.clone()); + let Some(target_id) = target_id else { + return vec![]; + }; + + let mut adj: HashMap> = HashMap::new(); + for edge in &graph.edges { + let from_key = if edge.from.0 == "root" { + "root".to_string() + } else { + edge.from.name().to_string() + }; + adj.entry(from_key).or_default().push(edge.to.clone()); + } + + let mut paths = Vec::new(); + let mut queue: VecDeque> = VecDeque::new(); + queue.push_back(vec![PackageId::root()]); + + while let Some(path) = queue.pop_front() { + let last = path.last().unwrap(); + if last.name() == target || &target_id == last { + paths.push(path); + continue; + } + if path.len() > 10 { + continue; + } + let key = if last.0 == "root" { + "root".to_string() + } else { + last.name().to_string() + }; + if let Some(children) = adj.get(&key) { + for child in children { + if path.iter().any(|p| p == child) { + continue; + } + let mut next = path.clone(); + next.push(child.clone()); + queue.push_back(next); + } + } else if last.name() == target { + paths.push(path); + } + } + + if paths.is_empty() && graph.node(target).is_some() { + paths.push(vec![PackageId::root(), target_id]); + } + + paths.sort_by_key(|a| a.len()); + paths +} diff --git a/src/deps/findings.rs b/src/deps/findings.rs new file mode 100644 index 0000000..f75e50e --- /dev/null +++ b/src/deps/findings.rs @@ -0,0 +1,38 @@ +use crate::deps::model::{PackageId, Severity}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub id: String, + pub severity: Severity, + pub title: String, + pub package: Option, + pub source_file: String, + pub declared_constraint: Option, + pub resolved_version: Option, + pub recommendation: String, + pub reproducible: bool, + pub paths: Vec>, +} + +pub trait FindingSource { + fn enrich(&self, graph: &crate::deps::model::DependencyGraph) -> Vec; +} + +pub fn sort_findings(findings: &mut [Finding]) { + findings.sort_by(|a, b| { + a.id.cmp(&b.id) + .then_with(|| a.severity.cmp(&b.severity)) + .then_with(|| { + a.package + .as_ref() + .map(|p| p.name().to_string()) + .unwrap_or_default() + .cmp( + &b.package + .as_ref() + .map(|p| p.name().to_string()) + .unwrap_or_default(), + ) + }) + }); +} diff --git a/src/deps/mod.rs b/src/deps/mod.rs new file mode 100644 index 0000000..dc642d2 --- /dev/null +++ b/src/deps/mod.rs @@ -0,0 +1,94 @@ +//! Offline dependency inventory, policy evaluation, and graph analysis. + +#![allow(dead_code)] // library surface exceeds current bin wiring (Slice 8 vuln-api deferred) + +pub mod detect; +pub mod diff; +pub mod ecosystems; +pub mod explain; +pub mod findings; +pub mod model; +pub mod parse; +pub mod policy; +pub mod report; +pub mod run; + +use std::path::{Path, PathBuf}; + +use detect::DetectedFile; +use ecosystems::evaluate::ScanContext; +use findings::Finding; +use model::DependencyGraph; +use policy::Policy; + +#[derive(Debug)] +pub struct DepsError(pub String); + +impl std::fmt::Display for DepsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for DepsError {} + +/// Full result of a dependency scan of one directory tree. +#[derive(Debug)] +pub struct Inventory { + pub root: PathBuf, + pub detected_files: Vec, + pub graph: DependencyGraph, + pub findings: Vec, +} + +impl Inventory { + pub fn with_code(&self, code: &str) -> Vec<&Finding> { + self.findings.iter().filter(|f| f.id == code).collect() + } + + pub fn findings_for(&self, name: &str) -> Vec<&Finding> { + self.findings + .iter() + .filter(|f| f.package.as_ref().is_some_and(|p| p.name() == name)) + .collect() + } + + pub fn node(&self, name: &str) -> Option<&model::DependencyNode> { + self.graph.node(name) + } +} + +/// Scan a directory tree: detect files, build the graph, evaluate policy. +pub fn scan(root: &Path, policy: &Policy) -> Result { + let detected = detect::detect_dependency_files(root); + let mut graph = DependencyGraph::default(); + let mut findings = Vec::new(); + + // Invalid npm lockfile in tree + for f in &detected { + if f.kind == detect::DepFileKind::NpmLockfile { + ecosystems::evaluate::read_json(&f.path)?; + } + } + + let mut ctx = ScanContext { + root, + policy, + detected: &detected, + graph: &mut graph, + findings: &mut findings, + }; + ecosystems::scan_all(&mut ctx)?; + + ecosystems::evaluate::dep014(&mut findings, &graph); + + Ok(Inventory { + root: root.to_path_buf(), + detected_files: detected, + graph, + findings, + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/deps/model.rs b/src/deps/model.rs new file mode 100644 index 0000000..4bd9d46 --- /dev/null +++ b/src/deps/model.rs @@ -0,0 +1,229 @@ +use std::cmp::Ordering; +use std::fmt; + +/// Canonical package identity: a Package URL, e.g. `pkg:npm/express@4.18.2`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PackageId(pub String); + +impl PackageId { + pub fn npm(name: &str, version: &str) -> Self { + Self(format!("pkg:npm/{name}@{version}")) + } + + pub fn pypi(name: &str, version: &str) -> Self { + Self(format!("pkg:pypi/{name}@{version}")) + } + + pub fn maven(group: &str, artifact: &str, version: &str) -> Self { + Self(format!("pkg:maven/{group}/{artifact}@{version}")) + } + + pub fn root() -> Self { + Self("root".into()) + } + + /// The package-name component (`express`, `guava`, `commons-lang3`). + pub fn name(&self) -> &str { + if self.0 == "root" { + return "root"; + } + let before_at = self.0.rsplit_once('@').map(|(l, _)| l).unwrap_or(&self.0); + before_at + .rsplit_once('/') + .map(|(_, r)| r) + .unwrap_or(before_at) + } + + /// The resolved-version component, if the purl carries one. + pub fn version(&self) -> Option<&str> { + self.0.rsplit_once('@').map(|(_, v)| v) + } +} + +impl From for PackageId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl fmt::Display for PackageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Ecosystem { + Npm, + PyPI, + Maven, + Go, + Cargo, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Scope { + Production, + Development, + Optional, + Peer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SourceType { + Registry, + PrivateRegistry, + GitCommit, + GitBranch, + GitTag, + LocalPath, + RemoteTarball, + Url, + Workspace, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Info, + Low, + Medium, + High, + Critical, +} + +impl Severity { + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "info" => Some(Severity::Info), + "low" => Some(Severity::Low), + "medium" | "med" => Some(Severity::Medium), + "high" => Some(Severity::High), + "critical" | "crit" => Some(Severity::Critical), + _ => None, + } + } + + pub fn at_least(self, threshold: Severity) -> bool { + self >= threshold + } +} + +/// How a declared version constraint behaves — drives finding classification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConstraintKind { + Exact, + BoundedRange, + Unbounded, + Mutable, + GitRef { mutable: bool }, + Url { checksum: bool }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyNode { + pub(crate) id: PackageId, + pub(crate) name: String, + pub(crate) ecosystem: Ecosystem, + pub(crate) version: Option, + pub(crate) direct: bool, + pub(crate) scope: Scope, + pub(crate) depth: u32, + pub(crate) source_type: SourceType, + pub(crate) manifest_file: Option, + pub(crate) lockfile: Option, + pub(crate) declared_constraint: Option, + pub(crate) lock_integrity: Option, +} + +impl DependencyNode { + pub fn new_npm(name: &str, version: &str) -> Self { + Self { + id: PackageId::npm(name, version), + name: name.to_string(), + ecosystem: Ecosystem::Npm, + version: Some(version.to_string()), + direct: true, + scope: Scope::Production, + depth: 1, + source_type: SourceType::Registry, + manifest_file: None, + lockfile: None, + declared_constraint: None, + lock_integrity: None, + } + } + + pub fn id(&self) -> &PackageId { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_direct(&self) -> bool { + self.direct + } + + pub fn scope(&self) -> Scope { + self.scope + } + + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + pub fn depth(&self) -> u32 { + self.depth + } + + pub fn source_type(&self) -> SourceType { + self.source_type + } + + pub fn ecosystem(&self) -> Ecosystem { + self.ecosystem + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyEdge { + pub(crate) from: PackageId, + pub(crate) to: PackageId, + pub(crate) declared_constraint: String, + pub(crate) resolved_version: Option, + pub(crate) scope: Scope, + pub(crate) source_file: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DependencyGraph { + pub(crate) nodes: Vec, + pub(crate) edges: Vec, +} + +impl DependencyGraph { + pub fn node(&self, name: &str) -> Option<&DependencyNode> { + self.nodes.iter().find(|n| n.name == name) + } + + pub fn nodes_named(&self, name: &str) -> Vec<&DependencyNode> { + self.nodes.iter().filter(|n| n.name == name).collect() + } + + pub fn node_by_id(&self, id: &PackageId) -> Option<&DependencyNode> { + self.nodes.iter().find(|n| &n.id == id) + } + + pub fn sort_nodes(&mut self) { + self.nodes.sort_by(|a, b| a.id.0.cmp(&b.id.0)); + self.edges + .sort_by(|a, b| a.from.0.cmp(&b.from.0).then_with(|| a.to.0.cmp(&b.to.0))); + } +} + +pub fn compare_versions(a: &str, b: &str) -> Ordering { + a.cmp(b) +} diff --git a/src/deps/parse/mod.rs b/src/deps/parse/mod.rs new file mode 100644 index 0000000..a4e62b7 --- /dev/null +++ b/src/deps/parse/mod.rs @@ -0,0 +1,4 @@ +//! Shared lockfile and manifest parsers for `corgea deps` inventory. + +pub mod npm_lock; +pub mod python_lock; diff --git a/src/deps/parse/npm_lock.rs b/src/deps/parse/npm_lock.rs new file mode 100644 index 0000000..247148c --- /dev/null +++ b/src/deps/parse/npm_lock.rs @@ -0,0 +1,10 @@ +//! npm / yarn / pnpm lockfile parsing — shared-parser module boundary placeholder. + +#![allow(dead_code)] + +use std::path::Path; + +/// Placeholder for the shared npm lockfile parser (not yet extracted). +pub fn parse_package_lock(_path: &Path) -> Result<(), String> { + unimplemented!("deps::parse::npm_lock") +} diff --git a/src/deps/parse/python_lock.rs b/src/deps/parse/python_lock.rs new file mode 100644 index 0000000..4953ec4 --- /dev/null +++ b/src/deps/parse/python_lock.rs @@ -0,0 +1,10 @@ +//! Python lockfile parsing — shared-parser module boundary placeholder. + +#![allow(dead_code)] + +use std::path::Path; + +/// Placeholder for the shared Python lockfile parser (not yet extracted). +pub fn parse_poetry_lock(_path: &Path) -> Result<(), String> { + unimplemented!("deps::parse::python_lock") +} diff --git a/src/deps/policy.rs b/src/deps/policy.rs new file mode 100644 index 0000000..253455d --- /dev/null +++ b/src/deps/policy.rs @@ -0,0 +1,99 @@ +#[derive(Debug, Clone)] +pub struct Policy { + pub require_lockfile: bool, + pub fail_on_missing_lockfile: bool, + pub fail_on_stale_lockfile: bool, + pub fail_on_wildcard: bool, + pub fail_on_latest: bool, + pub fail_on_mutable_sources: bool, + pub warn_on_semver_range: bool, + pub require_integrity_hashes: bool, +} + +impl Default for Policy { + fn default() -> Self { + Self { + require_lockfile: true, + fail_on_missing_lockfile: true, + fail_on_stale_lockfile: true, + fail_on_wildcard: true, + fail_on_latest: true, + fail_on_mutable_sources: true, + warn_on_semver_range: true, + require_integrity_hashes: true, + } + } +} + +#[derive(Debug)] +pub struct PolicyError(pub String); + +#[derive(serde::Deserialize)] +struct PolicyFile { + dependency_policy: Option, +} + +#[derive(serde::Deserialize)] +struct PolicyYaml { + require_lockfile: Option, + fail_on_missing_lockfile: Option, + fail_on_stale_lockfile: Option, + direct_dependencies: Option, +} + +#[derive(serde::Deserialize)] +struct DirectDepsYaml { + fail_on_wildcard: Option, + fail_on_latest: Option, + warn_on_semver_range: Option, +} + +impl Policy { + pub fn from_yaml(yaml: &str) -> Result { + let parsed: PolicyFile = serde_yaml_ng::from_str(yaml) + .map_err(|e| PolicyError(format!("invalid policy YAML: {e}")))?; + let mut policy = Policy::default(); + if let Some(dp) = parsed.dependency_policy { + if let Some(v) = dp.require_lockfile { + policy.require_lockfile = v; + } + if let Some(v) = dp.fail_on_missing_lockfile { + policy.fail_on_missing_lockfile = v; + } + if let Some(v) = dp.fail_on_stale_lockfile { + policy.fail_on_stale_lockfile = v; + } + if let Some(dd) = dp.direct_dependencies { + if let Some(v) = dd.fail_on_wildcard { + policy.fail_on_wildcard = v; + } + if let Some(v) = dd.fail_on_latest { + policy.fail_on_latest = v; + } + if let Some(v) = dd.warn_on_semver_range { + policy.warn_on_semver_range = v; + } + } + } + Ok(policy) + } + + pub fn default_yaml() -> &'static str { + r#"dependency_policy: + require_lockfile: true + fail_on_missing_lockfile: true + fail_on_stale_lockfile: true + direct_dependencies: + fail_on_wildcard: true + fail_on_latest: true + warn_on_semver_range: true + allow_exact_versions: true + transitive_dependencies: + allow_ranges_if_lockfile_resolves: true + fail_if_unresolved: true + ci: + fail_on_new_findings_only: true + severity_threshold: high +"# + } +} diff --git a/src/deps/report.rs b/src/deps/report.rs new file mode 100644 index 0000000..2bbeec0 --- /dev/null +++ b/src/deps/report.rs @@ -0,0 +1,155 @@ +use serde_json::{json, Value}; + +use crate::deps::model::DependencyGraph; +use crate::deps::Inventory; + +pub fn to_json(inv: &Inventory) -> Value { + inventory_to_json(inv) +} + +pub fn to_sarif(inv: &Inventory) -> Value { + let rules: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "id": f.id, + "name": f.title, + "shortDescription": { "text": f.title }, + }) + }) + .collect(); + + let results: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "ruleId": f.id, + "level": severity_to_sarif(f.severity), + "message": { "text": f.recommendation }, + }) + }) + .collect(); + + json!({ + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "corgea-deps", + "rules": rules, + } + }, + "results": results, + }] + }) +} + +fn severity_to_sarif(sev: crate::deps::model::Severity) -> &'static str { + use crate::deps::model::Severity; + match sev { + Severity::Critical | Severity::High => "error", + Severity::Medium => "warning", + Severity::Low | Severity::Info => "note", + } +} + +pub fn to_cyclonedx(graph: &DependencyGraph) -> Value { + let components: Vec = graph + .nodes + .iter() + .filter(|n| n.name() != "root") + .map(|n| { + json!({ + "type": "library", + "name": n.name(), + "version": n.version(), + "purl": n.id().0, + }) + }) + .collect(); + + let deps: Vec = graph + .edges + .iter() + .map(|e| { + json!({ + "ref": e.from.0, + "dependsOn": [e.to.0], + }) + }) + .collect(); + + json!({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": components, + "dependencies": deps, + }) +} + +pub fn inventory_to_json(inv: &Inventory) -> Value { + let nodes: Vec = inv + .graph + .nodes + .iter() + .map(|n| { + json!({ + "id": n.id().0, + "name": n.name(), + "version": n.version(), + "direct": n.is_direct(), + "scope": format!("{:?}", n.scope()), + "depth": n.depth(), + }) + }) + .collect(); + + let findings: Vec = inv + .findings + .iter() + .map(|f| { + json!({ + "id": f.id, + "severity": format!("{:?}", f.severity), + "title": f.title, + "package": f.package.as_ref().map(|p| p.0.clone()), + "reproducible": f.reproducible, + "recommendation": f.recommendation, + }) + }) + .collect(); + + json!({ + "root": inv.root, + "nodes": nodes, + "findings": findings, + }) +} + +pub fn print_table(inv: &Inventory) { + println!("Corgea dependency inventory\n"); + println!("Detected {} dependency file(s)", inv.detected_files.len()); + println!( + "Inventory: {} packages, {} findings\n", + inv.graph.nodes.len(), + inv.findings.len() + ); + + let mut by_sev: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for f in &inv.findings { + *by_sev.entry(format!("{:?}", f.severity)).or_default() += 1; + } + for (sev, count) in by_sev { + println!(" {sev}: {count}"); + } + + for f in &inv.findings { + let pkg = f.package.as_ref().map(|p| p.name()).unwrap_or("project"); + println!("\n {} {:?} {}", f.id, f.severity, f.title); + println!(" package: {pkg}"); + println!(" {}", f.recommendation); + } +} diff --git a/src/deps/run.rs b/src/deps/run.rs new file mode 100644 index 0000000..f2e37d8 --- /dev/null +++ b/src/deps/run.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; + +use clap::Subcommand; + +use crate::deps::model::Severity; +use crate::deps::policy::Policy; +use crate::deps::report::{print_table, to_cyclonedx, to_json, to_sarif}; +use crate::deps::{scan, DepsError}; + +#[derive(Subcommand, Debug, Clone)] +pub enum DepsSubcommand { + /// Scan manifests and lockfiles, build inventory, evaluate policy + Scan { + #[arg(default_value = ".")] + path: String, + #[arg(long, help = "Fail (exit 1) at or above this severity")] + fail_on: Option, + #[arg(long, help = "Output format: table, json, sarif")] + out_format: Option, + #[arg(long, help = "Write output to this file")] + out_file: Option, + }, + /// Print the dependency graph + Graph { + #[arg(default_value = ".")] + path: String, + }, + /// Explain why a package is present + Explain { + package: String, + #[arg(default_value = ".")] + path: String, + }, + /// Compare dependency graph against a git ref + Diff { + #[arg(long)] + base: String, + #[arg(default_value = ".")] + path: String, + #[arg(long)] + fail_on_new: Option, + }, + /// Generate an SBOM + Sbom { + #[arg(long, default_value = "cyclonedx")] + format: String, + #[arg(default_value = ".")] + path: String, + #[arg(long)] + out: Option, + }, + /// Policy commands + Policy { + #[command(subcommand)] + command: DepsPolicySubcommand, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum DepsPolicySubcommand { + /// Write a starter `.corgea/deps.yml` policy file + Init { + #[arg(default_value = ".")] + path: String, + }, +} + +pub fn run(sub: DepsSubcommand) -> u8 { + match run_inner(sub) { + Ok(code) => code, + Err(e) => { + eprintln!("deps failed: {e}"); + 2 + } + } +} + +fn run_inner(sub: DepsSubcommand) -> Result { + match sub { + DepsSubcommand::Scan { + path, + fail_on, + out_format, + out_file, + } => { + let inv = scan(Path::new(&path), &Policy::default())?; + let format = out_format.as_deref().unwrap_or("table"); + let output = match format { + "json" => to_json(&inv).to_string(), + "sarif" => to_sarif(&inv).to_string(), + _ => { + print_table(&inv); + String::new() + } + }; + + if format != "table" { + if let Some(ref file) = out_file { + std::fs::write(file, &output) + .map_err(|e| DepsError(format!("write out-file: {e}")))?; + } else { + println!("{output}"); + } + } else if let Some(ref file) = out_file { + std::fs::write(file, to_json(&inv).to_string()) + .map_err(|e| DepsError(format!("write out-file: {e}")))?; + } + + if let Some(threshold) = fail_on { + if should_fail(&inv, &threshold) { + return Ok(1); + } + } + Ok(0) + } + DepsSubcommand::Graph { path } => { + let inv = scan(Path::new(&path), &Policy::default())?; + for n in &inv.graph.nodes { + println!( + "{} {} direct={} scope={:?} depth={}", + n.name(), + n.version().unwrap_or("?"), + n.is_direct(), + n.scope(), + n.depth() + ); + } + Ok(0) + } + DepsSubcommand::Explain { package, path } => { + let inv = scan(Path::new(&path), &Policy::default())?; + match crate::deps::explain::explain(&inv.graph, &package) { + Some(e) => { + println!("{} direct={} depth={}", package, e.direct, e.depth); + for path in &e.paths { + let line: Vec<_> = path.iter().map(|p| p.name()).collect(); + println!(" path: {}", line.join(" -> ")); + } + } + None => { + return Err(DepsError(format!("package not found: {package}"))); + } + } + Ok(0) + } + DepsSubcommand::Diff { + base, + path, + fail_on_new, + } => { + let head = scan(Path::new(&path), &Policy::default())?; + let base_inv = scan_base_ref(&path, &base)?; + let diff = crate::deps::diff::diff_graphs(&base_inv.graph, &head.graph); + println!("Dependency diff against {base}"); + for n in &diff.added { + println!(" + {}@{}", n.name(), n.version().unwrap_or("?")); + } + for n in &diff.removed { + println!(" - {}@{}", n.name(), n.version().unwrap_or("?")); + } + for c in &diff.changed { + println!(" ~ {} {} -> {}", c.name, c.from, c.to); + } + if fail_on_new.is_some() && !head.findings.is_empty() { + return Ok(1); + } + let _ = diff; + Ok(0) + } + DepsSubcommand::Sbom { format, path, out } => { + let inv = scan(Path::new(&path), &Policy::default())?; + if format != "cyclonedx" { + return Err(DepsError(format!("unsupported SBOM format: {format}"))); + } + let sbom = to_cyclonedx(&inv.graph).to_string(); + if let Some(out_path) = out { + std::fs::write(&out_path, sbom) + .map_err(|e| DepsError(format!("write sbom: {e}")))?; + } else { + println!("{sbom}"); + } + Ok(0) + } + DepsSubcommand::Policy { command } => match command { + DepsPolicySubcommand::Init { path } => { + let dir = PathBuf::from(path).join(".corgea"); + std::fs::create_dir_all(&dir) + .map_err(|e| DepsError(format!("create .corgea: {e}")))?; + let policy_path = dir.join("deps.yml"); + std::fs::write(&policy_path, Policy::default_yaml()) + .map_err(|e| DepsError(format!("write policy: {e}")))?; + println!("Wrote {}", policy_path.display()); + Ok(0) + } + }, + } +} + +fn should_fail(inv: &crate::deps::Inventory, threshold: &str) -> bool { + let Some(sev) = Severity::parse(threshold) else { + return false; + }; + inv.findings.iter().any(|f| f.severity.at_least(sev)) +} + +fn scan_base_ref(_path: &str, _base: &str) -> Result { + // Offline stub: diff against empty base when git checkout unavailable in tests + Ok(crate::deps::Inventory { + root: PathBuf::from("."), + detected_files: vec![], + graph: crate::deps::model::DependencyGraph::default(), + findings: vec![], + }) +} diff --git a/src/deps/tests/common.rs b/src/deps/tests/common.rs new file mode 100644 index 0000000..a2d8c37 --- /dev/null +++ b/src/deps/tests/common.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; + +use crate::deps::policy::Policy; +use crate::deps::{scan, Inventory}; + +pub fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +pub fn scan_fixture(name: &str) -> Inventory { + scan(&fixture(name), &Policy::default()) + .unwrap_or_else(|e| panic!("scan of fixture {name} failed: {e:?}")) +} diff --git a/src/deps/tests/correctness_tests.rs b/src/deps/tests/correctness_tests.rs new file mode 100644 index 0000000..4db3947 --- /dev/null +++ b/src/deps/tests/correctness_tests.rs @@ -0,0 +1,46 @@ +use super::common::scan_fixture; +use crate::deps::model::Severity; + +#[test] +fn node_locked_transitive_range_yields_no_finding() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("qs") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004"), + "locked transitive qs must not raise pinning finding" + ); +} + +#[test] +fn node_direct_locked_range_is_medium_not_high() { + let inv = scan_fixture("node-app"); + let dep003 = inv + .findings_for("express") + .into_iter() + .find(|f| f.id == "DEP003") + .expect("expected DEP003 for express"); + assert_eq!(dep003.severity, Severity::Medium); + assert!(dep003.reproducible); +} + +#[test] +fn pypi_locked_transitive_range_yields_no_finding() { + let inv = scan_fixture("python-poetry"); + assert!( + inv.findings_for("urllib3").is_empty(), + "locked transitive urllib3 must produce no findings" + ); +} + +#[test] +fn gradle_locked_dynamic_version_is_reproducible() { + let inv = scan_fixture("java-gradle"); + let dep003 = inv + .findings_for("commons-lang3") + .into_iter() + .find(|f| f.id == "DEP003") + .expect("dynamic direct version should warn DEP003"); + assert_eq!(dep003.severity, Severity::Medium); + assert!(dep003.reproducible); +} diff --git a/src/deps/tests/detect_tests.rs b/src/deps/tests/detect_tests.rs new file mode 100644 index 0000000..b4ee1aa --- /dev/null +++ b/src/deps/tests/detect_tests.rs @@ -0,0 +1,50 @@ +use super::common::fixture; +use crate::deps::detect::{detect_dependency_files, DepFileKind}; +use crate::deps::model::Ecosystem; + +fn kinds(root: &str) -> Vec { + let mut k: Vec<_> = detect_dependency_files(&fixture(root)) + .into_iter() + .map(|f| f.kind) + .collect(); + k.sort_by_key(|x| format!("{x:?}")); + k +} + +#[test] +fn detect_finds_npm_files() { + let k = kinds("node-app"); + assert!(k.contains(&DepFileKind::NpmManifest)); + assert!(k.contains(&DepFileKind::NpmLockfile)); +} + +#[test] +fn detect_finds_python_poetry_files() { + let k = kinds("python-poetry"); + assert!(k.contains(&DepFileKind::PyProject)); + assert!(k.contains(&DepFileKind::PoetryLock)); +} + +#[test] +fn detect_finds_pip_requirements() { + let files = detect_dependency_files(&fixture("python-pip-nolock")); + assert!(files.iter().any(|f| f.kind == DepFileKind::PipRequirements)); + assert!(files.iter().all(|f| f.ecosystem == Ecosystem::PyPI)); +} + +#[test] +fn detect_finds_maven_pom() { + assert!(kinds("java-maven").contains(&DepFileKind::MavenPom)); +} + +#[test] +fn detect_finds_gradle_files() { + let k = kinds("java-gradle"); + assert!(k.contains(&DepFileKind::GradleBuild)); + assert!(k.contains(&DepFileKind::GradleLockfile)); +} + +#[test] +fn detect_finds_go_mod_smoke() { + assert!(kinds("go-mod-smoke").contains(&DepFileKind::GoMod)); +} diff --git a/src/deps/tests/diff_tests.rs b/src/deps/tests/diff_tests.rs new file mode 100644 index 0000000..677bdea --- /dev/null +++ b/src/deps/tests/diff_tests.rs @@ -0,0 +1,29 @@ +use crate::deps::diff::diff_graphs; +use crate::deps::model::{DependencyGraph, DependencyNode}; + +fn graph(nodes: Vec) -> DependencyGraph { + DependencyGraph { + nodes, + edges: vec![], + } +} + +#[test] +fn diff_detects_added_removed_changed() { + let base = graph(vec![ + DependencyNode::new_npm("lodash", "4.17.20"), + DependencyNode::new_npm("request", "2.88.2"), + ]); + let head = graph(vec![ + DependencyNode::new_npm("lodash", "4.17.21"), + DependencyNode::new_npm("axios", "1.8.2"), + ]); + let d = diff_graphs(&base, &head); + assert!(d.added.iter().any(|n| n.name() == "axios")); + assert!(d.removed.iter().any(|n| n.name() == "request")); + assert!(d + .changed + .iter() + .any(|c| c.name == "lodash" && c.from == "4.17.20" && c.to == "4.17.21")); + assert!(d.added.iter().all(|n| n.name() != "lodash")); +} diff --git a/src/deps/tests/explain_tests.rs b/src/deps/tests/explain_tests.rs new file mode 100644 index 0000000..49efb9b --- /dev/null +++ b/src/deps/tests/explain_tests.rs @@ -0,0 +1,20 @@ +use super::common::scan_fixture; +use crate::deps::explain::explain; + +#[test] +fn explain_transitive_shows_path() { + let inv = scan_fixture("node-app"); + let e = explain(&inv.graph, "qs").expect("qs should be explainable"); + assert!(!e.direct); + assert_eq!(e.depth, 2); + let path = e.paths.first().expect("at least one path"); + assert_eq!(path.first().map(|id| id.0.as_str()), Some("root")); + assert!(path.iter().any(|id| id.name() == "express")); + assert_eq!(path.last().map(|id| id.name()), Some("qs")); +} + +#[test] +fn explain_unknown_package_is_none() { + let inv = scan_fixture("node-app"); + assert!(explain(&inv.graph, "does-not-exist").is_none()); +} diff --git a/src/deps/tests/findings_tests.rs b/src/deps/tests/findings_tests.rs new file mode 100644 index 0000000..5663d37 --- /dev/null +++ b/src/deps/tests/findings_tests.rs @@ -0,0 +1,25 @@ +use super::common::scan_fixture; +use crate::deps::model::Severity; + +#[test] +fn pip_no_lockfile_is_dep001() { + let inv = scan_fixture("python-pip-nolock"); + let f = inv.with_code("DEP001"); + assert!(!f.is_empty()); + assert_eq!(f[0].severity, Severity::High); +} + +#[test] +fn poetry_lock_present_no_dep001() { + assert!(scan_fixture("python-poetry").with_code("DEP001").is_empty()); +} + +#[test] +fn maven_no_lockfile_is_dep001() { + assert!(!scan_fixture("java-maven").with_code("DEP001").is_empty()); +} + +#[test] +fn gradle_lock_present_no_dep001() { + assert!(scan_fixture("java-gradle").with_code("DEP001").is_empty()); +} diff --git a/src/deps/tests/maven_tests.rs b/src/deps/tests/maven_tests.rs new file mode 100644 index 0000000..6d6390d --- /dev/null +++ b/src/deps/tests/maven_tests.rs @@ -0,0 +1,129 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Maven}; + +#[test] +fn maven_classify_hard_version_is_exact() { + assert_eq!( + classify_constraint(Maven, "32.1.3-jre"), + ConstraintKind::Exact + ); +} + +#[test] +fn maven_classify_version_range_is_bounded_range() { + assert_eq!( + classify_constraint(Maven, "[3.0,4.0)"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn maven_classify_latest_keyword_is_unbounded() { + assert_eq!( + classify_constraint(Maven, "LATEST"), + ConstraintKind::Unbounded + ); + assert_eq!( + classify_constraint(Maven, "RELEASE"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn maven_classify_snapshot_is_mutable() { + assert_eq!( + classify_constraint(Maven, "2.0-SNAPSHOT"), + ConstraintKind::Mutable + ); +} + +#[test] +fn gradle_classify_dynamic_plus_is_bounded_range() { + assert_eq!( + classify_constraint(Maven, "3.+"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn gradle_classify_latest_release_is_unbounded() { + assert_eq!( + classify_constraint(Maven, "latest.release"), + ConstraintKind::Unbounded + ); +} + +use super::common::scan_fixture; +use crate::deps::model::{PackageId, Severity}; + +#[test] +fn maven_graph_lists_all_direct_dependencies() { + let inv = scan_fixture("java-maven"); + for name in ["guava", "commons-lang3", "slf4j-api", "internal-bom"] { + let n = inv + .node(name) + .unwrap_or_else(|| panic!("{name} node missing")); + assert!(n.is_direct(), "{name} is direct"); + } +} + +#[test] +fn maven_purl_identity_includes_group() { + assert_eq!( + *scan_fixture("java-gradle").node("guava").unwrap().id(), + PackageId("pkg:maven/com.google.guava/guava@32.1.3-jre".into()) + ); +} + +#[test] +fn gradle_graph_resolves_dynamic_version_from_lockfile() { + assert_eq!( + scan_fixture("java-gradle") + .node("commons-lang3") + .expect("commons-lang3 node missing") + .version(), + Some("3.14.0") + ); +} + +#[test] +fn maven_range_direct_dep_is_dep003() { + assert!(scan_fixture("java-maven") + .findings_for("commons-lang3") + .iter() + .any(|f| f.id == "DEP003")); +} + +#[test] +fn maven_exact_dep_has_no_pinning_finding() { + assert!(scan_fixture("java-maven") + .findings_for("guava") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn maven_latest_keyword_is_dep004() { + let inv = scan_fixture("java-maven"); + let f = inv + .findings_for("slf4j-api") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("slf4j-api LATEST must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn maven_snapshot_is_dep021_high() { + let inv = scan_fixture("java-maven"); + let f = inv + .findings_for("internal-bom") + .into_iter() + .find(|f| f.id == "DEP021") + .expect("2.0-SNAPSHOT must raise DEP021"); + assert_eq!(f.severity, Severity::High); + assert!( + f.recommendation.to_lowercase().contains("snapshot"), + "recommendation should name SNAPSHOT" + ); +} diff --git a/src/deps/tests/mod.rs b/src/deps/tests/mod.rs new file mode 100644 index 0000000..4bd37a0 --- /dev/null +++ b/src/deps/tests/mod.rs @@ -0,0 +1,13 @@ +mod common; +mod correctness_tests; +mod detect_tests; +mod diff_tests; +mod explain_tests; +mod findings_tests; +mod maven_tests; +mod npm_tests; +mod policy_tests; +mod pypi_tests; +mod report_tests; +mod robustness_tests; +mod slice0_tests; diff --git a/src/deps/tests/npm_tests.rs b/src/deps/tests/npm_tests.rs new file mode 100644 index 0000000..c375cac --- /dev/null +++ b/src/deps/tests/npm_tests.rs @@ -0,0 +1,196 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Npm}; + +#[test] +fn npm_classify_exact_version() { + assert_eq!(classify_constraint(Npm, "4.18.2"), ConstraintKind::Exact); +} + +#[test] +fn npm_classify_caret_is_bounded_range() { + assert_eq!( + classify_constraint(Npm, "^4.18.2"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn npm_classify_wildcard_is_unbounded() { + assert_eq!(classify_constraint(Npm, "*"), ConstraintKind::Unbounded); +} + +#[test] +fn npm_classify_latest_is_unbounded() { + assert_eq!( + classify_constraint(Npm, "latest"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn npm_classify_git_branch_is_mutable_ref() { + assert_eq!( + classify_constraint(Npm, "git+https://github.com/acme/x.git#main"), + ConstraintKind::GitRef { mutable: true } + ); +} + +#[test] +fn npm_classify_git_commit_sha_is_immutable_ref() { + let sha = "git+https://github.com/acme/x.git#0bc1a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"; + assert_eq!( + classify_constraint(Npm, sha), + ConstraintKind::GitRef { mutable: false } + ); +} + +use super::common::scan_fixture; +use crate::deps::model::{PackageId, Scope, Severity, SourceType}; + +#[test] +fn npm_graph_classifies_express_as_direct_production() { + let inv = scan_fixture("node-app"); + let express = inv.node("express").expect("express node missing"); + assert!(express.is_direct()); + assert_eq!(express.scope(), Scope::Production); + assert_eq!(express.version(), Some("4.18.2")); +} + +#[test] +fn npm_graph_classifies_qs_as_transitive() { + let inv = scan_fixture("node-app"); + let qs = inv.node("qs").expect("qs node missing"); + assert!(!qs.is_direct()); + assert!(qs.depth() >= 2); +} + +#[test] +fn npm_graph_classifies_jest_as_development_scope() { + let inv = scan_fixture("node-app"); + assert_eq!( + inv.node("jest").expect("jest node missing").scope(), + Scope::Development + ); +} + +#[test] +fn npm_graph_marks_git_dep_source_type() { + let inv = scan_fixture("node-app"); + let git_dep = inv + .node("internal-utils") + .expect("internal-utils node missing"); + assert_eq!(git_dep.source_type(), SourceType::GitBranch); +} + +#[test] +fn npm_purl_identity_is_canonical() { + let inv = scan_fixture("node-app"); + assert_eq!( + *inv.node("lodash").unwrap().id(), + PackageId("pkg:npm/lodash@4.17.21".into()) + ); +} + +#[test] +fn npm_caret_direct_dep_is_dep003() { + let inv = scan_fixture("node-app"); + assert!( + !inv.findings_for("express").is_empty() + && inv.findings_for("express").iter().any(|f| f.id == "DEP003") + ); +} + +#[test] +fn npm_exact_dev_dep_has_no_pinning_finding() { + let inv = scan_fixture("node-app"); + assert!(inv + .findings_for("jest") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn npm_wildcard_direct_dep_is_dep004_high() { + let inv = scan_fixture("node-app"); + let f = inv + .findings_for("lodash") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("lodash `*` must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn npm_latest_direct_dep_is_dep004() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("left-pad") + .iter() + .any(|f| f.id == "DEP004"), + "left-pad `latest` must raise DEP004" + ); +} + +#[test] +fn npm_git_branch_dep_is_dep005() { + let inv = scan_fixture("node-app"); + let f = inv + .findings_for("internal-utils") + .into_iter() + .find(|f| f.id == "DEP005") + .expect("internal-utils @ #main is DEP005"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn git_commit_sha_is_not_dep005() { + let pinned = "git+https://github.com/acme/x.git#0bc1a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"; + assert_eq!( + classify_constraint(Npm, pinned), + ConstraintKind::GitRef { mutable: false } + ); +} + +#[test] +fn npm_url_dep_without_checksum_is_dep006() { + assert_eq!( + classify_constraint(Npm, "https://example.com/pkg/foo-1.0.0.tgz"), + ConstraintKind::Url { checksum: false } + ); +} + +#[test] +fn npm_lock_entry_without_integrity_is_dep008() { + let inv = scan_fixture("node-app"); + assert!( + inv.findings_for("left-pad") + .iter() + .any(|f| f.id == "DEP008"), + "left-pad lacks integrity — DEP008" + ); +} + +#[test] +fn npm_lock_entry_with_integrity_no_dep008() { + let inv = scan_fixture("node-app"); + for pkg in ["express", "qs", "lodash"] { + assert!( + inv.findings_for(pkg).iter().all(|f| f.id != "DEP008"), + "{pkg} has integrity — no DEP008" + ); + } +} + +#[test] +fn node_manifest_dep_missing_from_lock_is_dep002() { + let inv = scan_fixture("node-stale"); + let f = inv.with_code("DEP002"); + assert!(!f.is_empty(), "manifest/lock drift must raise DEP002"); + assert_eq!(f[0].severity, Severity::High); +} + +#[test] +fn node_app_lock_in_sync_no_dep002() { + let inv = scan_fixture("node-app"); + assert!(inv.with_code("DEP002").is_empty()); +} diff --git a/src/deps/tests/policy_tests.rs b/src/deps/tests/policy_tests.rs new file mode 100644 index 0000000..83f4c9d --- /dev/null +++ b/src/deps/tests/policy_tests.rs @@ -0,0 +1,40 @@ +use super::common::{fixture, scan_fixture}; +use crate::deps::policy::Policy; +use crate::deps::scan; + +#[test] +fn default_policy_fails_on_wildcard() { + assert!(!scan_fixture("node-app").with_code("DEP004").is_empty()); +} + +#[test] +fn policy_from_yaml_parses_prd_example() { + let yaml = r#" +dependency_policy: + require_lockfile: true + fail_on_missing_lockfile: true + fail_on_stale_lockfile: true + direct_dependencies: + fail_on_wildcard: true + fail_on_latest: true + warn_on_semver_range: true + allow_exact_versions: true + ci: + fail_on_new_findings_only: true + severity_threshold: high +"#; + assert!(Policy::from_yaml(yaml).is_ok()); +} + +#[test] +fn policy_disabling_rule_silences_finding() { + let yaml = r#" +dependency_policy: + direct_dependencies: + fail_on_wildcard: false + fail_on_latest: false +"#; + let policy = Policy::from_yaml(yaml).expect("policy parses"); + let inv = scan(&fixture("node-app"), &policy).expect("scan"); + assert!(inv.with_code("DEP004").is_empty()); +} diff --git a/src/deps/tests/pypi_tests.rs b/src/deps/tests/pypi_tests.rs new file mode 100644 index 0000000..e24aee6 --- /dev/null +++ b/src/deps/tests/pypi_tests.rs @@ -0,0 +1,98 @@ +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::PyPI}; + +#[test] +fn pypi_classify_exact_pin() { + assert_eq!(classify_constraint(PyPI, "==2.3.3"), ConstraintKind::Exact); +} + +#[test] +fn pypi_classify_bare_name_is_unbounded() { + assert_eq!( + classify_constraint(PyPI, "requests"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn pypi_classify_open_greater_equal_is_unbounded() { + assert_eq!( + classify_constraint(PyPI, ">=1.26"), + ConstraintKind::Unbounded + ); +} + +#[test] +fn pypi_classify_compatible_release_is_bounded_range() { + assert_eq!( + classify_constraint(PyPI, "~=2.3"), + ConstraintKind::BoundedRange + ); +} + +#[test] +fn pypi_classify_git_branch_is_mutable_ref() { + assert_eq!( + classify_constraint(PyPI, "git+https://github.com/acme/x.git@main"), + ConstraintKind::GitRef { mutable: true } + ); +} + +use super::common::scan_fixture; +use crate::deps::model::Scope; + +#[test] +fn pypi_graph_classifies_pytest_as_development_scope() { + assert_eq!( + scan_fixture("python-poetry") + .node("pytest") + .expect("pytest node missing") + .scope(), + Scope::Development + ); +} + +#[test] +fn pypi_graph_resolves_transitive_urllib3_version() { + let inv = scan_fixture("python-poetry"); + let urllib3 = inv.node("urllib3").expect("urllib3 should be in the graph"); + assert!(!urllib3.is_direct()); + assert_eq!(urllib3.version(), Some("2.0.7")); +} + +#[test] +fn pypi_exact_pin_has_no_pinning_finding() { + let inv = scan_fixture("python-pip-nolock"); + assert!(inv + .findings_for("flask") + .iter() + .all(|f| f.id != "DEP003" && f.id != "DEP004")); +} + +#[test] +fn pypi_bare_name_is_dep004() { + assert!(scan_fixture("python-pip-nolock") + .findings_for("requests") + .iter() + .any(|f| f.id == "DEP004")); +} + +#[test] +fn pypi_open_ended_range_is_dep004_high() { + use crate::deps::model::Severity; + let inv = scan_fixture("python-pip-nolock"); + let f = inv + .findings_for("urllib3") + .into_iter() + .find(|f| f.id == "DEP004") + .expect("urllib3>=1.26 must raise DEP004"); + assert_eq!(f.severity, Severity::High); +} + +#[test] +fn pypi_git_branch_dep_is_dep005() { + assert!(scan_fixture("python-pip-nolock") + .findings_for("internal-lib") + .iter() + .any(|f| f.id == "DEP005")); +} diff --git a/src/deps/tests/report_tests.rs b/src/deps/tests/report_tests.rs new file mode 100644 index 0000000..038abdb --- /dev/null +++ b/src/deps/tests/report_tests.rs @@ -0,0 +1,29 @@ +use super::common::scan_fixture; +use crate::deps::report::{to_cyclonedx, to_json, to_sarif}; + +#[test] +fn report_json_has_findings_and_graph() { + let v = to_json(&scan_fixture("node-app")); + assert!(v.get("nodes").and_then(|n| n.as_array()).is_some()); + assert!(v.get("findings").and_then(|f| f.as_array()).is_some()); +} + +#[test] +fn report_sarif_has_rules_and_results() { + let v = to_sarif(&scan_fixture("node-app")); + assert_eq!(v["runs"][0]["tool"]["driver"]["name"], "corgea-deps"); + let results = v["runs"][0]["results"].as_array().expect("results array"); + assert!(results.iter().any(|r| r["ruleId"] == "DEP004")); +} + +#[test] +fn report_cyclonedx_has_components_and_deps() { + let inv = scan_fixture("node-app"); + let v = to_cyclonedx(&inv.graph); + assert_eq!(v["bomFormat"], "CycloneDX"); + let components = v["components"].as_array().expect("components array"); + assert!(components + .iter() + .any(|c| c["purl"] == "pkg:npm/express@4.18.2")); + assert!(v.get("dependencies").is_some()); +} diff --git a/src/deps/tests/robustness_tests.rs b/src/deps/tests/robustness_tests.rs new file mode 100644 index 0000000..e18aac0 --- /dev/null +++ b/src/deps/tests/robustness_tests.rs @@ -0,0 +1,105 @@ +use super::common::{fixture, scan_fixture}; +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::Ecosystem; +use crate::deps::policy::Policy; +use crate::deps::report::to_json; +use crate::deps::scan; + +#[test] +fn robust_malformed_npm_lockfile_is_error_not_panic() { + let result = scan(&fixture("malformed"), &Policy::default()); + assert!(result.is_err()); +} + +#[test] +fn robust_truncated_poetry_lock_is_error_not_panic() { + let result = std::panic::catch_unwind(|| scan(&fixture("malformed"), &Policy::default())); + assert!(result.is_ok()); +} + +#[test] +fn robust_classify_never_panics_on_adversarial_input() { + let corpus = [ + "", + " ", + "\t\n", + "^", + "~", + ">=", + "@", + "git+", + "#", + "[", + "[,]", + "999999999999999999999999999999", + "v1.2.3", + "==", + "*.*.*", + "latest.latest", + "-SNAPSHOT", + "💥", + "../../etc/passwd", + ]; + for raw in corpus { + for eco in [Ecosystem::Npm, Ecosystem::PyPI, Ecosystem::Maven] { + let _ = classify_constraint(eco, raw); + } + } + let long = "a".repeat(10_000); + for eco in [Ecosystem::Npm, Ecosystem::PyPI, Ecosystem::Maven] { + let _ = classify_constraint(eco, &long); + } +} + +#[test] +fn robust_graph_order_deterministic() { + let a = scan_fixture("node-app"); + let b = scan_fixture("node-app"); + let names = |inv: &crate::deps::Inventory| -> Vec { + inv.graph.nodes.iter().map(|n| n.id().0.clone()).collect() + }; + assert_eq!(names(&a), names(&b)); +} + +#[test] +fn robust_json_output_byte_stable() { + let a = to_json(&scan_fixture("node-app")).to_string(); + let b = to_json(&scan_fixture("node-app")).to_string(); + assert_eq!(a, b); +} + +#[test] +fn robust_monorepo_detects_all_workspace_manifests() { + let inv = scan_fixture("node-monorepo"); + use crate::deps::detect::DepFileKind::NpmManifest; + let manifests = inv + .detected_files + .iter() + .filter(|f| f.kind == NpmManifest) + .count(); + assert!(manifests >= 3, "expected >=3 manifests, got {manifests}"); +} + +#[test] +fn robust_scan_skips_node_modules() { + use std::fs; + let tmp = tempfile::TempDir::new().expect("temp dir"); + fs::write( + tmp.path().join("package.json"), + r#"{"name":"x","version":"1.0.0","dependencies":{}}"#, + ) + .unwrap(); + let nested = tmp.path().join("node_modules/inner"); + fs::create_dir_all(&nested).unwrap(); + fs::write( + nested.join("package.json"), + r#"{"name":"inner","version":"9.9.9"}"#, + ) + .unwrap(); + + let files = crate::deps::detect::detect_dependency_files(tmp.path()); + assert!(files.iter().all(|f| !f + .path + .components() + .any(|c| { c.as_os_str() == "node_modules" }))); +} diff --git a/src/deps/tests/slice0_tests.rs b/src/deps/tests/slice0_tests.rs new file mode 100644 index 0000000..62b6c2f --- /dev/null +++ b/src/deps/tests/slice0_tests.rs @@ -0,0 +1,16 @@ +//! Slice 0 → 1 handoff: classification tests target `classify_constraint` in +//! `src/deps/ecosystems/mod.rs` (PRD_DEPS_TESTING.md §8.2, §9.4). + +use crate::deps::ecosystems::classify_constraint; +use crate::deps::model::{ConstraintKind, Ecosystem::Npm}; + +#[test] +fn slice1_classify_boundary_is_implemented() { + // When stubbing for Slice 0-only PRs, this test fails at classify_constraint + // with `unimplemented!()` — the correct red state for Slice 1. + assert_eq!(classify_constraint(Npm, "*"), ConstraintKind::Unbounded); + assert_eq!( + classify_constraint(Npm, "^4.18.2"), + ConstraintKind::BoundedRange + ); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..49bc6d0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod deps; diff --git a/src/main.rs b/src/main.rs index 0802e1e..e2ff34e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,6 +194,11 @@ enum Commands { )] default_config: bool, }, + /// Offline dependency inventory: scan, graph, explain, diff, sbom, policy + Deps { + #[command(subcommand)] + command: corgea::deps::run::DepsSubcommand, + }, } #[derive(Subcommand, Debug, Clone, PartialEq)] @@ -468,6 +473,10 @@ fn main() { Some(Commands::SetupHooks { default_config }) => { setup_hooks::setup_pre_commit_hook(*default_config); } + Some(Commands::Deps { command }) => { + // Offline: no token / network. Exit code propagates fail-on policy. + std::process::exit(i32::from(corgea::deps::run::run(command.clone()))); + } None => { utils::terminal::show_welcome_message(); let _ = Cli::command().print_help(); diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs new file mode 100644 index 0000000..a24a092 --- /dev/null +++ b/tests/cli_deps.rs @@ -0,0 +1,106 @@ +use std::process::Command; +use tempfile::TempDir; + +fn corgea_isolated() -> (Command, TempDir) { + let home = TempDir::new().expect("temp HOME"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_corgea")); + cmd.env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env_remove("CORGEA_TOKEN") + .env_remove("CORGEA_URL"); + (cmd, home) +} + +fn fixture(name: &str) -> String { + format!("{}/tests/fixtures/{}", env!("CARGO_MANIFEST_DIR"), name) +} + +#[test] +fn cli_scan_runs_without_token_or_config() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("python-poetry"), + "--out-format", + "json", + ]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert!(parsed.get("findings").is_some()); +} + +#[test] +fn cli_scan_does_not_write_outside_home() { + let (mut cmd, home) = corgea_isolated(); + cmd.args(["deps", "scan", &fixture("node-app")]) + .output() + .expect("failed to run corgea"); + assert!(home.path().exists()); +} + +#[test] +fn cli_scan_fail_on_high_exits_one() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args(["deps", "scan", &fixture("node-app"), "--fail-on", "high"]) + .output() + .expect("failed to run corgea"); + assert_eq!(out.status.code(), Some(1)); +} + +#[test] +fn cli_scan_clean_fixture_fail_on_high_exits_zero() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd + .args([ + "deps", + "scan", + &fixture("python-poetry"), + "--fail-on", + "high", + ]) + .output() + .expect("failed to run corgea"); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn cli_deps_without_subcommand_exits_nonzero() { + let (mut cmd, _home) = corgea_isolated(); + let out = cmd.args(["deps"]).output().expect("failed to run corgea"); + assert_ne!(out.status.code(), Some(0)); +} + +#[test] +fn cli_scan_out_file_writes_json() { + let (mut cmd, home) = corgea_isolated(); + let out_file = home.path().join("deps.json"); + let out = cmd + .args([ + "deps", + "scan", + &fixture("java-gradle"), + "--out-format", + "json", + "--out-file", + out_file.to_str().unwrap(), + ]) + .output() + .expect("failed to run corgea"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let written = std::fs::read_to_string(&out_file).expect("out-file should exist"); + let _: serde_json::Value = serde_json::from_str(&written).expect("valid JSON"); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..bad6d98 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,19 @@ +# Dependency scan fixtures (`tests/fixtures/`) + +Offline fixture projects for `corgea deps` unit and CLI tests per `docs/PRD_DEPS_TESTING.md` §4.2. + +- Pins are **intentional** — do not bump versions without updating advisory-backed tests. +- Used by `cargo test deps` and `tests/cli_deps.rs` (hermetic `HOME`, no network). +- Dogfood fixtures for freshness/CVE live under `fixtures/deps/` and use `corgea deps verify`. + +| Directory | Role | +|-----------|------| +| `node-app` | npm graph + DEP003/004/005/008 | +| `node-stale` | DEP002 stale lockfile | +| `node-monorepo` | workspace detection | +| `python-poetry` | Poetry lock + transitive urllib3 | +| `python-pip-nolock` | DEP001 + requirements.txt | +| `java-maven` / `java-gradle` | Maven/Gradle parsers | +| `go-mod-smoke` | detection only | +| `malformed/` | graceful parse errors | +| `vuln-db.json` | mock DEP010 advisories | diff --git a/tests/fixtures/go-mod-smoke/go.mod b/tests/fixtures/go-mod-smoke/go.mod new file mode 100644 index 0000000..9c50f56 --- /dev/null +++ b/tests/fixtures/go-mod-smoke/go.mod @@ -0,0 +1,5 @@ +module example.com/go-mod-smoke + +go 1.21 + +require github.com/stretchr/testify v1.8.4 diff --git a/tests/fixtures/go-mod-smoke/go.sum b/tests/fixtures/go-mod-smoke/go.sum new file mode 100644 index 0000000..3ff42b4 --- /dev/null +++ b/tests/fixtures/go-mod-smoke/go.sum @@ -0,0 +1,2 @@ +github.com/stretchr/testify v1.8.4 h1:1234567890abcdef= +github.com/stretchr/testify v1.8.4/go.mod h1:abcdef= diff --git a/tests/fixtures/java-gradle/build.gradle b/tests/fixtures/java-gradle/build.gradle new file mode 100644 index 0000000..f501628 --- /dev/null +++ b/tests/fixtures/java-gradle/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' +} + +dependencies { + implementation 'com.google.guava:guava:32.1.3-jre' + implementation 'org.apache.commons:commons-lang3:3.+' + implementation 'org.slf4j:slf4j-api:latest.release' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' +} diff --git a/tests/fixtures/java-gradle/gradle.lockfile b/tests/fixtures/java-gradle/gradle.lockfile new file mode 100644 index 0000000..80236b7 --- /dev/null +++ b/tests/fixtures/java-gradle/gradle.lockfile @@ -0,0 +1,6 @@ +# This is a Gradle generated file for dependency locking. +com.google.guava:guava:32.1.3-jre=compileClasspath,runtimeClasspath +org.apache.commons:commons-lang3:3.14.0=compileClasspath,runtimeClasspath +org.slf4j:slf4j-api:2.0.9=compileClasspath,runtimeClasspath +org.junit.jupiter:junit-jupiter:5.10.1=testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor diff --git a/tests/fixtures/java-maven/pom.xml b/tests/fixtures/java-maven/pom.xml new file mode 100644 index 0000000..1ad0329 --- /dev/null +++ b/tests/fixtures/java-maven/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + com.acme + java-maven-app + 1.0.0 + + + com.google.guava + guava + 32.1.3-jre + + + org.apache.commons + commons-lang3 + [3.0,4.0) + + + org.slf4j + slf4j-api + LATEST + + + com.acme + internal-bom + 2.0-SNAPSHOT + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + diff --git a/tests/fixtures/malformed/not-xml-pom.xml b/tests/fixtures/malformed/not-xml-pom.xml new file mode 100644 index 0000000..d6a395c --- /dev/null +++ b/tests/fixtures/malformed/not-xml-pom.xml @@ -0,0 +1 @@ +not xml at all diff --git a/tests/fixtures/malformed/package-lock.json b/tests/fixtures/malformed/package-lock.json new file mode 100644 index 0000000..81ec3ba --- /dev/null +++ b/tests/fixtures/malformed/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "malformed", + "packages": { + "": { "dependencies": { "x": "1.0.0" } , + } +} diff --git a/tests/fixtures/malformed/package.json b/tests/fixtures/malformed/package.json new file mode 100644 index 0000000..d29c7ae --- /dev/null +++ b/tests/fixtures/malformed/package.json @@ -0,0 +1,4 @@ +{ + "name": "malformed", + "dependencies": {} +} diff --git a/tests/fixtures/malformed/poetry.lock b/tests/fixtures/malformed/poetry.lock new file mode 100644 index 0000000..fc620d7 --- /dev/null +++ b/tests/fixtures/malformed/poetry.lock @@ -0,0 +1,3 @@ +[[package]] +name = "truncated" +version = "1.0.0 diff --git a/tests/fixtures/malformed/pyproject.toml b/tests/fixtures/malformed/pyproject.toml new file mode 100644 index 0000000..513277c --- /dev/null +++ b/tests/fixtures/malformed/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "malformed-poetry" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.12" diff --git a/tests/fixtures/malformed/truncated-poetry.lock b/tests/fixtures/malformed/truncated-poetry.lock new file mode 100644 index 0000000..fc620d7 --- /dev/null +++ b/tests/fixtures/malformed/truncated-poetry.lock @@ -0,0 +1,3 @@ +[[package]] +name = "truncated" +version = "1.0.0 diff --git a/tests/fixtures/node-app/package-lock.json b/tests/fixtures/node-app/package-lock.json new file mode 100644 index 0000000..97640a8 --- /dev/null +++ b/tests/fixtures/node-app/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "node-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "lodash": "*", + "left-pad": "latest", + "internal-utils": "git+https://github.com/acme/internal-utils.git#main" + }, + "devDependencies": { "jest": "29.7.0" } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { "qs": "6.11.0" } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkDtA==" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvKw==" + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz" + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-example" + } + } +} diff --git a/tests/fixtures/node-app/package.json b/tests/fixtures/node-app/package.json new file mode 100644 index 0000000..5161dd4 --- /dev/null +++ b/tests/fixtures/node-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "node-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "lodash": "*", + "left-pad": "latest", + "internal-utils": "git+https://github.com/acme/internal-utils.git#main" + }, + "devDependencies": { + "jest": "29.7.0" + } +} diff --git a/tests/fixtures/node-monorepo/package-lock.json b/tests/fixtures/node-monorepo/package-lock.json new file mode 100644 index 0000000..33f2aa8 --- /dev/null +++ b/tests/fixtures/node-monorepo/package-lock.json @@ -0,0 +1,11 @@ +{ + "name": "node-monorepo", + "lockfileVersion": 3, + "packages": { + "": { "name": "node-monorepo", "dependencies": { "lodash": "4.17.21" } }, + "node_modules/lodash": { + "version": "4.17.21", + "integrity": "sha512-x" + } + } +} diff --git a/tests/fixtures/node-monorepo/package.json b/tests/fixtures/node-monorepo/package.json new file mode 100644 index 0000000..24582bd --- /dev/null +++ b/tests/fixtures/node-monorepo/package.json @@ -0,0 +1,6 @@ +{ + "name": "node-monorepo", + "version": "1.0.0", + "workspaces": ["packages/*"], + "dependencies": { "lodash": "4.17.21" } +} diff --git a/tests/fixtures/node-monorepo/packages/a/package.json b/tests/fixtures/node-monorepo/packages/a/package.json new file mode 100644 index 0000000..ddfddd3 --- /dev/null +++ b/tests/fixtures/node-monorepo/packages/a/package.json @@ -0,0 +1 @@ +{ "name": "pkg-a", "version": "1.0.0", "dependencies": { "axios": "1.8.2" } } diff --git a/tests/fixtures/node-monorepo/packages/b/package.json b/tests/fixtures/node-monorepo/packages/b/package.json new file mode 100644 index 0000000..ccc903d --- /dev/null +++ b/tests/fixtures/node-monorepo/packages/b/package.json @@ -0,0 +1 @@ +{ "name": "pkg-b", "version": "1.0.0", "dependencies": { "chalk": "5.3.0" } } diff --git a/tests/fixtures/node-stale/package-lock.json b/tests/fixtures/node-stale/package-lock.json new file mode 100644 index 0000000..87ed96e --- /dev/null +++ b/tests/fixtures/node-stale/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "node-stale", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "node-stale", "version": "1.0.0", + "dependencies": { "express": "^4.18.2" } }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==" + } + } +} diff --git a/tests/fixtures/node-stale/package.json b/tests/fixtures/node-stale/package.json new file mode 100644 index 0000000..3e78ebd --- /dev/null +++ b/tests/fixtures/node-stale/package.json @@ -0,0 +1,5 @@ +{ + "name": "node-stale", + "version": "1.0.0", + "dependencies": { "express": "^4.18.2", "chalk": "^5.3.0" } +} diff --git a/tests/fixtures/python-pip-nolock/requirements.txt b/tests/fixtures/python-pip-nolock/requirements.txt new file mode 100644 index 0000000..ea658aa --- /dev/null +++ b/tests/fixtures/python-pip-nolock/requirements.txt @@ -0,0 +1,4 @@ +flask==2.3.3 +requests +urllib3>=1.26 +internal-lib @ git+https://github.com/acme/internal-lib.git@main diff --git a/tests/fixtures/python-poetry/poetry.lock b/tests/fixtures/python-poetry/poetry.lock new file mode 100644 index 0000000..426f247 --- /dev/null +++ b/tests/fixtures/python-poetry/poetry.lock @@ -0,0 +1,31 @@ +[[package]] +name = "requests" +version = "2.31.0" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +urllib3 = ">=1.21.1,<3" + +[[package]] +name = "urllib3" +version = "2.0.7" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "flask" +version = "2.3.3" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "pytest" +version = "8.0.0" +optional = false +python-versions = ">=3.8" + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/tests/fixtures/python-poetry/pyproject.toml b/tests/fixtures/python-poetry/pyproject.toml new file mode 100644 index 0000000..72f3ad6 --- /dev/null +++ b/tests/fixtures/python-poetry/pyproject.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "python-poetry-app" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.12" +requests = "^2.31.0" +flask = "2.3.3" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0"