Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ corgea scan --fail-on malicious # Exit 1 if any dependency is cla
corgea scan --fail-on HI,malicious # Comma-separated conditions combine
corgea scan --fail # Exit 1 based on project blocking rules
corgea scan --out-format json --out-file r.json # Export (json, html, sarif, markdown)
corgea scan --sbom # Also write a CycloneDX SBOM to bom.json
corgea scan --sbom sbom.cdx.json # SBOM to a custom file
corgea scan --project-name my-service # Override project name
```

Expand Down Expand Up @@ -367,6 +369,7 @@ corgea upload report.json --project-name my-app
```bash
corgea scan --out-format html --out-file report.html
corgea scan --out-format sarif --out-file report.sarif
corgea scan --out-format sarif --out-file report.sarif --sbom bom.json # SARIF + CycloneDX SBOM
```

## Severity Levels
Expand Down
165 changes: 163 additions & 2 deletions src/deps/ecosystems/maven.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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::model::{DependencyEdge, DependencyNode, Ecosystem, PackageId, Scope, SourceType};
use crate::deps::DepsError;

pub fn scan_maven_projects(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> {
Expand Down Expand Up @@ -68,6 +68,14 @@ fn scan_maven_pom(ctx: &mut ScanContext<'_>, dir: &Path, pom_path: &Path) -> Res
Some(package_id.clone()),
false,
));
ctx.graph.edges.push(DependencyEdge {
Comment thread
cursor[bot] marked this conversation as resolved.
from: PackageId::root(),
to: package_id.clone(),
declared_constraint: declared.clone(),
resolved_version: Some(dep.version.clone()),
scope: dep.scope,
source_file: rel.clone(),
});
ctx.graph.nodes.push(DependencyNode {
id: package_id,
name,
Expand All @@ -90,7 +98,160 @@ fn scan_maven_pom(ctx: &mut ScanContext<'_>, dir: &Path, pom_path: &Path) -> Res
}

fn parse_pom_dependencies(content: &str) -> Result<Vec<MavenDep>, DepsError> {
Ok(parse_pom_regex(content))
let props = parse_pom_properties(content);
let (rest, management) = split_dependency_management(content);
let managed: std::collections::HashMap<(String, String), String> = parse_pom_regex(management)
.into_iter()
.filter(|d| !d.version.is_empty())
.map(|d| ((d.group, d.artifact), d.version))
.collect();
let mut deps = parse_pom_regex(&rest);
for dep in &mut deps {
if dep.version.is_empty() {
if let Some(v) = managed.get(&(dep.group.clone(), dep.artifact.clone())) {
dep.version = v.clone();
}
}
dep.version = resolve_placeholders(&dep.version, &props);
}
Ok(deps)
}

/// Split out the `<dependencyManagement>` section: its entries pin versions
/// for the project's dependencies but are not dependencies themselves.
/// Returns (pom without the section, the section's inner content).
fn split_dependency_management(content: &str) -> (String, &str) {
split_section(content, "dependencyManagement").unwrap_or_else(|| (content.to_string(), ""))
}

/// Locate a `<tag>...</tag>` section and split content into (content with
/// the section removed, the section's inner content). `None` if the tag
/// isn't present or is malformed (close before open).
fn split_section<'a>(content: &'a str, tag: &str) -> Option<(String, &'a str)> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let s = content.find(&open)?;
let e = content.find(&close)?;
if s >= e {
return None;
}
let inner = &content[s + open.len()..e];
let rest = format!("{}{}", &content[..s], &content[e + close.len()..]);
Some((rest, inner))
}

/// Collect `<properties>` entries plus the built-in `project.version`.
fn parse_pom_properties(content: &str) -> std::collections::HashMap<String, String> {
let mut props = std::collections::HashMap::new();
if let Some(start) = content.find("<properties>") {
let rest = &content[start + "<properties>".len()..];
if let Some(end) = rest.find("</properties>") {
let mut block = &rest[..end];
while let Some(open_start) = block.find('<') {
let after = &block[open_start + 1..];
let Some(open_end) = after.find('>') else {
break;
};
let tag = after[..open_end].trim();
let body = &after[open_end + 1..];
if tag.starts_with('/') || tag.starts_with('!') || tag.ends_with('/') {
block = body;
continue;
}
let close = format!("</{tag}>");
match body.find(&close) {
Some(close_pos) => {
props.insert(tag.to_string(), body[..close_pos].trim().to_string());
block = &body[close_pos + close.len()..];
}
None => block = body,
}
}
}
}
// Property values may reference other properties; resolve the map to a
// fixed point, bounded to guard against definition cycles.
resolve_props_fixed_point(&mut props);
let project_version = resolve_placeholders(&pom_project_version(content), &props);
if !project_version.is_empty() && !project_version.contains("${") {
props.insert("project.version".to_string(), project_version);
}
// Properties aliasing ${project.version} (e.g. `<shared.version>`) only
// resolve now that project.version itself is in the map.
resolve_props_fixed_point(&mut props);
props
}

/// Resolve `${...}` references among property values to a fixed point,
/// bounded to guard against definition cycles.
fn resolve_props_fixed_point(props: &mut std::collections::HashMap<String, String>) {
for _ in 0..5 {
let resolved: std::collections::HashMap<String, String> = props
.iter()
.map(|(k, v)| (k.clone(), resolve_placeholders(v, props)))
.collect();
if resolved == *props {
break;
}
*props = resolved;
}
}

/// The pom's own `<version>`: first `<version>` before `<dependencies>`,
/// excluding the `<parent>` block. A child that inherits its version has
/// none of its own, so fall back to the parent's (Maven's inheritance rule).
fn pom_project_version(content: &str) -> String {
let head = content.split("<dependencies>").next().unwrap_or(content);
// The project's own <version> lives among its coordinates, before any
// nested section that may carry unrelated <version> tags of its own
// (plugin versions in <build>, managed versions in
// <dependencyManagement>, etc).
let nested_start = [
"<dependencyManagement>",
"<build>",
"<reporting>",
"<profiles>",
]
.iter()
.filter_map(|tag| head.find(tag))
.min();
let head = match nested_start {
Some(pos) => &head[..pos],
None => head,
};
if let Some((cleaned, parent)) = split_section(head, "parent") {
let own = extract_xml_tag(&cleaned, "version");
if !own.is_empty() {
return own;
}
return extract_xml_tag(parent, "version");
}
extract_xml_tag(head, "version")
}

/// Substitute `${name}` placeholders; unresolved ones pass through unchanged.
fn resolve_placeholders(raw: &str, props: &std::collections::HashMap<String, String>) -> String {
if !raw.contains("${") {
return raw.to_string();
}
let mut out = String::new();
let mut rest = raw;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
out.push_str(&rest[start..]);
return out;
};
let key = &after[..end];
match props.get(key) {
Some(v) => out.push_str(v),
Comment thread
cursor[bot] marked this conversation as resolved.
None => out.push_str(&rest[start..start + 2 + end + 1]),
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}

fn parse_pom_regex(content: &str) -> Vec<MavenDep> {
Expand Down
10 changes: 10 additions & 0 deletions src/deps/ecosystems/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ pub fn scan_all(ctx: &mut ScanContext<'_>) -> Result<(), DepsError> {
evaluate::scan_all(ctx)
}

/// Whether `scan_all` builds graph nodes for this ecosystem. Keep in sync
/// with the scanners wired in `evaluate::scan_all` — detect-only ecosystems
/// (Go, Cargo) are found on disk but contribute nothing to the graph or SBOM.
pub fn is_graphed(ecosystem: Ecosystem) -> bool {
matches!(
ecosystem,
Ecosystem::Npm | Ecosystem::PyPI | Ecosystem::Maven
)
}

use crate::deps::model::{ConstraintKind, Ecosystem};

/// Classify a raw declared constraint string.
Expand Down
7 changes: 7 additions & 0 deletions src/deps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ impl Inventory {

/// Scan a directory tree: detect files, build the graph, evaluate policy.
pub fn scan(root: &Path, policy: &Policy) -> Result<Inventory, DepsError> {
if !root.exists() {
return Err(DepsError(format!(
"path does not exist: {}",
root.display()
)));
}

let detected = detect::detect_dependency_files(root);
let mut graph = DependencyGraph::default();
let mut findings = Vec::new();
Expand Down
131 changes: 110 additions & 21 deletions src/deps/report.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,61 @@
use serde_json::{json, Value};
use std::fmt::Write as _;

use crate::deps::model::DependencyGraph;
use crate::deps::detect::DetectedFile;
use crate::deps::model::{DependencyGraph, Ecosystem, PackageId};
use crate::deps::Inventory;

/// Load the policy at `root`, scan the tree, and emit a CycloneDX SBOM.
pub fn sbom(root: &std::path::Path) -> Result<Value, crate::deps::DepsError> {
let policy = crate::deps::run::load_policy(root)?;
let inv = crate::deps::scan(root, &policy)?;
warn_unsupported_ecosystems(&inv.detected_files);
Ok(to_cyclonedx(&inv))
}

/// Print one deduplicated stderr warning per detected ecosystem that
/// `to_cyclonedx` does not emit components for (currently Go and Cargo),
/// so `deps sbom` / `scan --sbom` don't silently produce an empty-looking
/// SBOM for those trees.
pub fn warn_unsupported_ecosystems(detected: &[DetectedFile]) {
let mut by_ecosystem: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<String>,
> = std::collections::BTreeMap::new();

for f in detected {
let Some(label) = unsupported_ecosystem_label(f.ecosystem) else {
continue;
};
let file_name = f
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
by_ecosystem.entry(label).or_default().insert(file_name);
}

for (label, files) in by_ecosystem {
let files: Vec<String> = files.into_iter().collect();
eprintln!(
"Warning: detected {} but {} is not yet included in SBOMs",
files.join(", "),
label
);
}
}

fn unsupported_ecosystem_label(ecosystem: Ecosystem) -> Option<&'static str> {
if crate::deps::ecosystems::is_graphed(ecosystem) {
return None;
}
Some(match ecosystem {
Ecosystem::Go => "Go",
Ecosystem::Cargo => "Cargo",
_ => "this ecosystem",
})
}

pub fn to_json(inv: &Inventory) -> Value {
inventory_to_json(inv)
}
Expand Down Expand Up @@ -56,36 +108,73 @@ fn severity_to_sarif(sev: crate::deps::model::Severity) -> &'static str {
}
}

pub fn to_cyclonedx(graph: &DependencyGraph) -> Value {
let components: Vec<Value> = graph
.nodes
.iter()
.filter(|n| n.name() != "root")
.map(|n| {
json!({
"type": "library",
"name": n.name(),
"version": n.version(),
"purl": n.id().0,
})
})
.collect();
/// A version is resolved when it names a concrete release — not empty, not
/// the `?` placeholder for lockfile-less manifests, not an unsubstituted
/// `${...}` Maven property. Unresolved versions (and the purls fabricated
/// from them) must be omitted, or the document fails CycloneDX 1.7 validation.
fn is_resolved_version(version: &str) -> bool {
!version.is_empty() && version != "?" && !version.contains("${")
}

pub fn to_cyclonedx(inv: &Inventory) -> Value {
let graph = &inv.graph;
// Deduplicate by bom-ref: multi-module trees list the same package once
// per manifest, but the schema sets uniqueItems on components.
let mut components: std::collections::BTreeMap<&str, Value> = std::collections::BTreeMap::new();
for n in graph.nodes.iter().filter(|n| *n.id() != PackageId::root()) {
let mut c = json!({
"type": "library",
"bom-ref": n.id().0,
"name": n.name(),
});
if let Some(v) = n.version().filter(|v| is_resolved_version(v)) {
c["version"] = json!(v);
c["purl"] = json!(n.id().0);
}
components.entry(&n.id().0).or_insert(c);
}
let components: Vec<Value> = components.into_values().collect();

let deps: Vec<Value> = graph
.edges
let mut depends_on: std::collections::BTreeMap<&str, std::collections::BTreeSet<&str>> =
std::collections::BTreeMap::new();
for e in &graph.edges {
depends_on.entry(&e.from.0).or_default().insert(&e.to.0);
}
let deps: Vec<Value> = depends_on
.iter()
.map(|e| {
.map(|(from, tos)| {
json!({
"ref": e.from.0,
"dependsOn": [e.to.0],
"ref": from,
"dependsOn": tos,
})
})
.collect();

let root_name = std::fs::canonicalize(&inv.root)
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "project".to_string());

json!({
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"specVersion": "1.7",
"serialNumber": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"version": 1,
"metadata": {
"timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"tools": {
"components": [{
"type": "application",
"name": "corgea",
"version": env!("CARGO_PKG_VERSION"),
}]
},
"component": {
"type": "application",
"bom-ref": "root",
"name": root_name,
},
},
"components": components,
"dependencies": deps,
})
Expand Down
Loading
Loading