mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-14 15:04:56 +03:00
771 lines
28 KiB
Python
771 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""SBOM supply chain vulnerability analysis agent.
|
|
|
|
Parses CycloneDX and SPDX JSON SBOMs, correlates components against the NVD 2.0 API
|
|
for known CVEs, builds dependency graphs with networkx, calculates risk scores, and
|
|
generates compliance reports.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
|
|
try:
|
|
import requests
|
|
HAS_REQUESTS = True
|
|
except ImportError:
|
|
HAS_REQUESTS = False
|
|
|
|
try:
|
|
import networkx as nx
|
|
HAS_NETWORKX = True
|
|
except ImportError:
|
|
HAS_NETWORKX = False
|
|
|
|
try:
|
|
from packaging.version import Version, InvalidVersion
|
|
HAS_PACKAGING = True
|
|
except ImportError:
|
|
HAS_PACKAGING = False
|
|
|
|
# NVD API 2.0 configuration
|
|
NVD_CVE_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
NVD_CPE_API = "https://services.nvd.nist.gov/rest/json/cpes/2.0"
|
|
NVD_RATE_LIMIT_NO_KEY = 6.0 # seconds between requests without API key
|
|
NVD_RATE_LIMIT_WITH_KEY = 0.6 # seconds between requests with API key
|
|
NVD_RESULTS_PER_PAGE = 50
|
|
|
|
# CVSS severity thresholds
|
|
SEVERITY_THRESHOLDS = {
|
|
"CRITICAL": 9.0,
|
|
"HIGH": 7.0,
|
|
"MEDIUM": 4.0,
|
|
"LOW": 0.1,
|
|
}
|
|
|
|
|
|
class SBOMComponent:
|
|
"""Represents a single software component extracted from an SBOM."""
|
|
|
|
def __init__(self, name, version, purl=None, cpe=None, component_type="library",
|
|
licenses=None, supplier=None):
|
|
self.name = name
|
|
self.version = version
|
|
self.purl = purl
|
|
self.cpe = cpe
|
|
self.component_type = component_type
|
|
self.licenses = licenses or []
|
|
self.supplier = supplier
|
|
self.cves = []
|
|
self.max_cvss = 0.0
|
|
self.risk_level = "NONE"
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"name": self.name,
|
|
"version": self.version,
|
|
"purl": self.purl,
|
|
"cpe": self.cpe,
|
|
"type": self.component_type,
|
|
"licenses": self.licenses,
|
|
"cves": self.cves,
|
|
"max_cvss": self.max_cvss,
|
|
"risk_level": self.risk_level,
|
|
}
|
|
|
|
|
|
def detect_sbom_format(sbom_data):
|
|
"""Detect whether the SBOM is CycloneDX or SPDX format."""
|
|
if isinstance(sbom_data, dict):
|
|
if sbom_data.get("bomFormat") == "CycloneDX":
|
|
return "cyclonedx"
|
|
if "spdxVersion" in sbom_data:
|
|
return "spdx"
|
|
if "components" in sbom_data and any(
|
|
"purl" in c for c in sbom_data.get("components", [])
|
|
):
|
|
return "cyclonedx"
|
|
if "packages" in sbom_data:
|
|
return "spdx"
|
|
return "unknown"
|
|
|
|
|
|
def parse_cyclonedx(sbom_data):
|
|
"""Parse CycloneDX JSON SBOM and extract components and dependencies."""
|
|
components = []
|
|
dependencies = {}
|
|
|
|
spec_version = sbom_data.get("specVersion", "unknown")
|
|
print(f" Format: CycloneDX v{spec_version}")
|
|
|
|
for comp in sbom_data.get("components", []):
|
|
name = comp.get("name", "unknown")
|
|
version = comp.get("version", "unknown")
|
|
purl = comp.get("purl")
|
|
cpe = None
|
|
|
|
# Extract CPE from multiple possible locations
|
|
if "cpe" in comp:
|
|
cpe = comp["cpe"]
|
|
for prop in comp.get("properties", []):
|
|
if prop.get("name") == "syft:cpe23" or "cpe" in prop.get("name", "").lower():
|
|
cpe = prop.get("value")
|
|
break
|
|
|
|
# Extract licenses
|
|
licenses = []
|
|
for lic_entry in comp.get("licenses", []):
|
|
lic = lic_entry.get("license", {})
|
|
if "id" in lic:
|
|
licenses.append(lic["id"])
|
|
elif "name" in lic:
|
|
licenses.append(lic["name"])
|
|
|
|
component = SBOMComponent(
|
|
name=name,
|
|
version=version,
|
|
purl=purl,
|
|
cpe=cpe,
|
|
component_type=comp.get("type", "library"),
|
|
licenses=licenses,
|
|
supplier=comp.get("supplier", {}).get("name"),
|
|
)
|
|
components.append(component)
|
|
|
|
# Parse dependency graph
|
|
for dep_entry in sbom_data.get("dependencies", []):
|
|
ref = dep_entry.get("ref", "")
|
|
depends_on = dep_entry.get("dependsOn", [])
|
|
dependencies[ref] = depends_on
|
|
|
|
return components, dependencies
|
|
|
|
|
|
def parse_spdx(sbom_data):
|
|
"""Parse SPDX JSON SBOM and extract components and dependencies."""
|
|
components = []
|
|
dependencies = {}
|
|
|
|
spdx_version = sbom_data.get("spdxVersion", "unknown")
|
|
print(f" Format: SPDX {spdx_version}")
|
|
|
|
spdx_id_to_purl = {}
|
|
|
|
for pkg in sbom_data.get("packages", []):
|
|
name = pkg.get("name", "unknown")
|
|
version = pkg.get("versionInfo", "unknown")
|
|
spdx_id = pkg.get("SPDXID", "")
|
|
purl = None
|
|
cpe = None
|
|
|
|
for ref in pkg.get("externalRefs", []):
|
|
ref_type = ref.get("referenceType", "")
|
|
locator = ref.get("referenceLocator", "")
|
|
if ref_type == "purl" or "purl" in ref_type.lower():
|
|
purl = locator
|
|
elif ref_type == "cpe23Type" or "cpe" in ref_type.lower():
|
|
cpe = locator
|
|
|
|
licenses = []
|
|
concluded = pkg.get("licenseConcluded", "NOASSERTION")
|
|
if concluded and concluded != "NOASSERTION":
|
|
licenses.append(concluded)
|
|
declared = pkg.get("licenseDeclared", "NOASSERTION")
|
|
if declared and declared != "NOASSERTION" and declared not in licenses:
|
|
licenses.append(declared)
|
|
|
|
component = SBOMComponent(
|
|
name=name,
|
|
version=version,
|
|
purl=purl,
|
|
cpe=cpe,
|
|
component_type="library",
|
|
licenses=licenses,
|
|
supplier=pkg.get("supplier"),
|
|
)
|
|
components.append(component)
|
|
spdx_id_to_purl[spdx_id] = purl or f"{name}@{version}"
|
|
|
|
# Parse relationships
|
|
for rel in sbom_data.get("relationships", []):
|
|
rel_type = rel.get("relationshipType", "")
|
|
if rel_type == "DEPENDS_ON":
|
|
parent_id = rel.get("spdxElementId", "")
|
|
child_id = rel.get("relatedSpdxElement", "")
|
|
parent_ref = spdx_id_to_purl.get(parent_id, parent_id)
|
|
child_ref = spdx_id_to_purl.get(child_id, child_id)
|
|
if parent_ref not in dependencies:
|
|
dependencies[parent_ref] = []
|
|
dependencies[parent_ref].append(child_ref)
|
|
|
|
return components, dependencies
|
|
|
|
|
|
def parse_sbom(sbom_path):
|
|
"""Load and parse an SBOM file, auto-detecting the format."""
|
|
if not os.path.isfile(sbom_path):
|
|
raise FileNotFoundError(f"SBOM file not found: {sbom_path}")
|
|
|
|
with open(sbom_path, "r", encoding="utf-8") as f:
|
|
sbom_data = json.load(f)
|
|
|
|
fmt = detect_sbom_format(sbom_data)
|
|
print(f"\n[INFO] Parsing SBOM: {sbom_path}")
|
|
|
|
if fmt == "cyclonedx":
|
|
return parse_cyclonedx(sbom_data), fmt
|
|
elif fmt == "spdx":
|
|
return parse_spdx(sbom_data), fmt
|
|
else:
|
|
raise ValueError(
|
|
f"Unrecognized SBOM format. Expected CycloneDX or SPDX JSON. "
|
|
f"Keys found: {list(sbom_data.keys())[:10]}"
|
|
)
|
|
|
|
|
|
def query_nvd_by_cpe(cpe_name, api_key=None):
|
|
"""Query NVD 2.0 API for CVEs matching a CPE name."""
|
|
if not HAS_REQUESTS:
|
|
return []
|
|
|
|
params = {"cpeName": cpe_name, "resultsPerPage": NVD_RESULTS_PER_PAGE}
|
|
headers = {}
|
|
if api_key:
|
|
headers["apiKey"] = api_key
|
|
|
|
try:
|
|
resp = requests.get(NVD_CVE_API, params=params, headers=headers, timeout=30)
|
|
if resp.status_code == 403:
|
|
print(f" [WARN] NVD API rate limited. Waiting...", file=sys.stderr)
|
|
time.sleep(NVD_RATE_LIMIT_NO_KEY * 2)
|
|
resp = requests.get(NVD_CVE_API, params=params, headers=headers, timeout=30)
|
|
resp.raise_for_status()
|
|
return resp.json().get("vulnerabilities", [])
|
|
except requests.RequestException as e:
|
|
print(f" [WARN] NVD API error for {cpe_name}: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def query_nvd_by_keyword(keyword, api_key=None):
|
|
"""Query NVD 2.0 API for CVEs matching a keyword search."""
|
|
if not HAS_REQUESTS:
|
|
return []
|
|
|
|
params = {"keywordSearch": keyword, "resultsPerPage": NVD_RESULTS_PER_PAGE}
|
|
headers = {}
|
|
if api_key:
|
|
headers["apiKey"] = api_key
|
|
|
|
try:
|
|
resp = requests.get(NVD_CVE_API, params=params, headers=headers, timeout=30)
|
|
resp.raise_for_status()
|
|
return resp.json().get("vulnerabilities", [])
|
|
except requests.RequestException as e:
|
|
print(f" [WARN] NVD keyword search error for '{keyword}': {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def extract_cve_info(vuln_entry):
|
|
"""Extract structured CVE information from an NVD API response entry."""
|
|
cve_data = vuln_entry.get("cve", {})
|
|
cve_id = cve_data.get("id", "UNKNOWN")
|
|
|
|
# Extract CVSS score (prefer v3.1, fallback to v3.0, then v2.0)
|
|
cvss_score = 0.0
|
|
cvss_version = "none"
|
|
metrics = cve_data.get("metrics", {})
|
|
|
|
for version_key in ["cvssMetricV31", "cvssMetricV30", "cvssMetricV2"]:
|
|
metric_list = metrics.get(version_key, [])
|
|
if metric_list:
|
|
cvss_data = metric_list[0].get("cvssData", {})
|
|
cvss_score = cvss_data.get("baseScore", 0.0)
|
|
cvss_version = cvss_data.get("version", version_key)
|
|
break
|
|
|
|
# Extract description
|
|
descriptions = cve_data.get("descriptions", [])
|
|
description = ""
|
|
for desc in descriptions:
|
|
if desc.get("lang") == "en":
|
|
description = desc.get("value", "")
|
|
break
|
|
|
|
# Determine severity
|
|
severity = "LOW"
|
|
for level, threshold in sorted(SEVERITY_THRESHOLDS.items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
if cvss_score >= threshold:
|
|
severity = level
|
|
break
|
|
|
|
# Check for known exploited (CISA KEV indicator)
|
|
is_kev = False
|
|
for ref in cve_data.get("references", []):
|
|
if "cisa.gov" in ref.get("url", "").lower():
|
|
is_kev = True
|
|
break
|
|
|
|
return {
|
|
"cve_id": cve_id,
|
|
"cvss_score": cvss_score,
|
|
"cvss_version": cvss_version,
|
|
"severity": severity,
|
|
"description": description[:300],
|
|
"is_kev": is_kev,
|
|
"published": cve_data.get("published", ""),
|
|
}
|
|
|
|
|
|
def correlate_cves(components, api_key=None, skip_nvd=False):
|
|
"""Correlate all SBOM components against NVD for known vulnerabilities."""
|
|
rate_limit = NVD_RATE_LIMIT_WITH_KEY if api_key else NVD_RATE_LIMIT_NO_KEY
|
|
total = len(components)
|
|
vuln_count = 0
|
|
|
|
print(f"\n[INFO] Correlating {total} components against NVD CVE database...")
|
|
if not api_key:
|
|
print(f" [NOTE] No NVD API key. Rate limited to 1 request per {rate_limit}s.")
|
|
print(f" Get a free key at: https://nvd.nist.gov/developers/request-an-api-key")
|
|
|
|
if skip_nvd:
|
|
print(f" [NOTE] NVD queries skipped (--skip-nvd flag). Using offline mode.")
|
|
return components
|
|
|
|
for idx, comp in enumerate(components):
|
|
print(f" [{idx+1}/{total}] {comp.name}@{comp.version}...", end="", flush=True)
|
|
|
|
vulns = []
|
|
# Try CPE-based search first (most precise)
|
|
if comp.cpe:
|
|
vulns = query_nvd_by_cpe(comp.cpe, api_key)
|
|
|
|
# Fallback to keyword search if no CPE or no results
|
|
if not vulns:
|
|
keyword = f"{comp.name} {comp.version}"
|
|
vulns = query_nvd_by_keyword(keyword, api_key)
|
|
|
|
# Process results
|
|
for v in vulns:
|
|
cve_info = extract_cve_info(v)
|
|
if cve_info["cvss_score"] > 0:
|
|
comp.cves.append(cve_info)
|
|
if cve_info["cvss_score"] > comp.max_cvss:
|
|
comp.max_cvss = cve_info["cvss_score"]
|
|
|
|
# Assign risk level
|
|
if comp.max_cvss >= SEVERITY_THRESHOLDS["CRITICAL"]:
|
|
comp.risk_level = "CRITICAL"
|
|
elif comp.max_cvss >= SEVERITY_THRESHOLDS["HIGH"]:
|
|
comp.risk_level = "HIGH"
|
|
elif comp.max_cvss >= SEVERITY_THRESHOLDS["MEDIUM"]:
|
|
comp.risk_level = "MEDIUM"
|
|
elif comp.max_cvss > 0:
|
|
comp.risk_level = "LOW"
|
|
|
|
cve_count = len(comp.cves)
|
|
vuln_count += cve_count
|
|
status = f" {cve_count} CVEs (max CVSS: {comp.max_cvss})" if cve_count else " clean"
|
|
print(status)
|
|
|
|
# Rate limiting
|
|
if idx < total - 1:
|
|
time.sleep(rate_limit)
|
|
|
|
print(f"\n[INFO] Correlation complete. Found {vuln_count} total CVEs across all components.")
|
|
return components
|
|
|
|
|
|
def build_dependency_graph(components, dependencies):
|
|
"""Build a directed dependency graph using networkx."""
|
|
if not HAS_NETWORKX:
|
|
print("[WARN] networkx not installed. Dependency graph analysis skipped.", file=sys.stderr)
|
|
return None
|
|
|
|
G = nx.DiGraph()
|
|
|
|
# Build lookup for components by purl or name@version
|
|
comp_lookup = {}
|
|
for comp in components:
|
|
ref = comp.purl or f"{comp.name}@{comp.version}"
|
|
G.add_node(ref, name=comp.name, version=comp.version,
|
|
max_cvss=comp.max_cvss, risk_level=comp.risk_level,
|
|
cve_count=len(comp.cves))
|
|
comp_lookup[ref] = comp
|
|
|
|
# Add edges from dependency relationships
|
|
for parent_ref, children in dependencies.items():
|
|
if parent_ref not in G:
|
|
G.add_node(parent_ref)
|
|
for child_ref in children:
|
|
if child_ref not in G:
|
|
G.add_node(child_ref)
|
|
G.add_edge(parent_ref, child_ref)
|
|
|
|
return G
|
|
|
|
|
|
def analyze_dependency_graph(G):
|
|
"""Analyze the dependency graph for risk metrics."""
|
|
if G is None or len(G.nodes) == 0:
|
|
return {}
|
|
|
|
analysis = {
|
|
"total_nodes": G.number_of_nodes(),
|
|
"total_edges": G.number_of_edges(),
|
|
"is_dag": nx.is_directed_acyclic_graph(G),
|
|
}
|
|
|
|
# Find most depended-on components (highest in-degree)
|
|
in_degrees = sorted(G.in_degree(), key=lambda x: x[1], reverse=True)
|
|
analysis["most_depended_on"] = [
|
|
{"ref": node, "dependents": deg, **G.nodes[node]}
|
|
for node, deg in in_degrees[:10] if deg > 0
|
|
]
|
|
|
|
# Find root nodes (no incoming edges - likely the application itself)
|
|
roots = [n for n, d in G.in_degree() if d == 0]
|
|
analysis["root_components"] = len(roots)
|
|
|
|
# Find leaf nodes (no outgoing edges - no dependencies)
|
|
leaves = [n for n, d in G.out_degree() if d == 0]
|
|
analysis["leaf_components"] = len(leaves)
|
|
|
|
# Calculate longest dependency chain
|
|
if analysis["is_dag"] and len(G.nodes) > 0:
|
|
try:
|
|
longest_path = nx.dag_longest_path(G)
|
|
analysis["deepest_chain_length"] = len(longest_path)
|
|
analysis["deepest_chain"] = longest_path
|
|
except nx.NetworkXError:
|
|
analysis["deepest_chain_length"] = 0
|
|
|
|
# Identify vulnerable components with high in-degree (blast radius)
|
|
high_risk_hubs = []
|
|
for node, deg in in_degrees:
|
|
node_data = G.nodes.get(node, {})
|
|
if node_data.get("max_cvss", 0) >= SEVERITY_THRESHOLDS["HIGH"] and deg > 0:
|
|
high_risk_hubs.append({
|
|
"ref": node,
|
|
"dependents": deg,
|
|
"max_cvss": node_data.get("max_cvss", 0),
|
|
"risk_level": node_data.get("risk_level", "UNKNOWN"),
|
|
})
|
|
analysis["high_risk_hubs"] = high_risk_hubs
|
|
|
|
# Betweenness centrality for bottleneck identification
|
|
if len(G.nodes) > 1:
|
|
centrality = nx.betweenness_centrality(G)
|
|
top_central = sorted(centrality.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
analysis["bottleneck_components"] = [
|
|
{"ref": node, "centrality": round(cent, 4)} for node, cent in top_central if cent > 0
|
|
]
|
|
|
|
return analysis
|
|
|
|
|
|
def check_license_compliance(components):
|
|
"""Check for potentially problematic licenses in SBOM components."""
|
|
copyleft_licenses = {
|
|
"GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later",
|
|
"GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later",
|
|
"AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later",
|
|
"LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later",
|
|
"LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later",
|
|
"MPL-2.0", "EUPL-1.2", "CPAL-1.0", "OSL-3.0",
|
|
}
|
|
|
|
findings = {
|
|
"copyleft_components": [],
|
|
"unknown_license_components": [],
|
|
"license_distribution": defaultdict(int),
|
|
}
|
|
|
|
for comp in components:
|
|
if not comp.licenses or comp.licenses == ["NOASSERTION"]:
|
|
findings["unknown_license_components"].append(
|
|
{"name": comp.name, "version": comp.version}
|
|
)
|
|
for lic in comp.licenses:
|
|
findings["license_distribution"][lic] += 1
|
|
if lic in copyleft_licenses:
|
|
findings["copyleft_components"].append({
|
|
"name": comp.name,
|
|
"version": comp.version,
|
|
"license": lic,
|
|
})
|
|
|
|
findings["license_distribution"] = dict(findings["license_distribution"])
|
|
return findings
|
|
|
|
|
|
def generate_report(components, dependencies, graph_analysis, license_info,
|
|
sbom_path, sbom_format, output_path=None):
|
|
"""Generate a comprehensive vulnerability analysis report."""
|
|
# Aggregate statistics
|
|
vuln_components = [c for c in components if c.cves]
|
|
total_cves = sum(len(c.cves) for c in components)
|
|
severity_counts = defaultdict(lambda: {"components": 0, "cves": 0})
|
|
|
|
for comp in components:
|
|
if comp.risk_level != "NONE":
|
|
severity_counts[comp.risk_level]["components"] += 1
|
|
severity_counts[comp.risk_level]["cves"] += len(comp.cves)
|
|
|
|
report_lines = []
|
|
report_lines.append("=" * 60)
|
|
report_lines.append("SBOM VULNERABILITY ANALYSIS REPORT")
|
|
report_lines.append("=" * 60)
|
|
report_lines.append(f"SBOM File: {sbom_path}")
|
|
report_lines.append(f"Format: {sbom_format}")
|
|
report_lines.append(f"Analysis Date: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
report_lines.append(f"Total Components: {len(components)}")
|
|
|
|
direct_deps = len(dependencies)
|
|
transitive = len(components) - direct_deps if direct_deps < len(components) else 0
|
|
report_lines.append(f"Dependencies: {len(dependencies)} direct, ~{transitive} transitive")
|
|
report_lines.append("")
|
|
|
|
report_lines.append("VULNERABILITY SUMMARY")
|
|
report_lines.append("-" * 40)
|
|
for level in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]:
|
|
counts = severity_counts.get(level, {"components": 0, "cves": 0})
|
|
report_lines.append(
|
|
f" {level:10s}: {counts['components']:3d} components / {counts['cves']:3d} CVEs"
|
|
)
|
|
report_lines.append(f" {'TOTAL':10s}: {len(vuln_components):3d} components / {total_cves:3d} CVEs")
|
|
report_lines.append("")
|
|
|
|
# Critical and high findings detail
|
|
critical_high = sorted(
|
|
[c for c in components if c.risk_level in ("CRITICAL", "HIGH")],
|
|
key=lambda c: c.max_cvss, reverse=True
|
|
)
|
|
|
|
if critical_high:
|
|
report_lines.append("CRITICAL & HIGH FINDINGS")
|
|
report_lines.append("-" * 40)
|
|
for i, comp in enumerate(critical_high[:20], 1):
|
|
report_lines.append(f"\n {i}. {comp.name}@{comp.version} [{comp.risk_level}]")
|
|
for cve in sorted(comp.cves, key=lambda c: c["cvss_score"], reverse=True)[:5]:
|
|
kev_flag = " [CISA KEV]" if cve.get("is_kev") else ""
|
|
report_lines.append(
|
|
f" {cve['cve_id']} (CVSS {cve['cvss_score']:.1f}){kev_flag}"
|
|
)
|
|
if cve["description"]:
|
|
desc_short = cve["description"][:120]
|
|
report_lines.append(f" {desc_short}...")
|
|
|
|
# Dependency graph analysis
|
|
if graph_analysis:
|
|
report_lines.append("")
|
|
report_lines.append("DEPENDENCY GRAPH ANALYSIS")
|
|
report_lines.append("-" * 40)
|
|
report_lines.append(f" Nodes: {graph_analysis.get('total_nodes', 0)}")
|
|
report_lines.append(f" Edges: {graph_analysis.get('total_edges', 0)}")
|
|
report_lines.append(f" DAG: {graph_analysis.get('is_dag', 'N/A')}")
|
|
chain_len = graph_analysis.get("deepest_chain_length", 0)
|
|
if chain_len:
|
|
report_lines.append(f" Deepest dependency chain: {chain_len} levels")
|
|
|
|
hubs = graph_analysis.get("high_risk_hubs", [])
|
|
if hubs:
|
|
report_lines.append(f"\n HIGH-RISK HUBS (vulnerable + many dependents):")
|
|
for hub in hubs[:5]:
|
|
report_lines.append(
|
|
f" {hub['ref']}: {hub['dependents']} dependents, "
|
|
f"CVSS {hub['max_cvss']:.1f} [{hub['risk_level']}]"
|
|
)
|
|
|
|
# License compliance
|
|
if license_info:
|
|
report_lines.append("")
|
|
report_lines.append("LICENSE COMPLIANCE")
|
|
report_lines.append("-" * 40)
|
|
copyleft = license_info.get("copyleft_components", [])
|
|
unknown = license_info.get("unknown_license_components", [])
|
|
report_lines.append(f" Copyleft licenses found: {len(copyleft)}")
|
|
for cl in copyleft[:10]:
|
|
report_lines.append(f" {cl['name']}@{cl['version']}: {cl['license']}")
|
|
report_lines.append(f" Unknown/missing licenses: {len(unknown)}")
|
|
|
|
report_text = "\n".join(report_lines)
|
|
print(f"\n{report_text}")
|
|
|
|
# Build JSON result
|
|
result = {
|
|
"sbom_file": sbom_path,
|
|
"sbom_format": sbom_format,
|
|
"analysis_timestamp": datetime.utcnow().isoformat(),
|
|
"summary": {
|
|
"total_components": len(components),
|
|
"vulnerable_components": len(vuln_components),
|
|
"total_cves": total_cves,
|
|
"severity_counts": dict(severity_counts),
|
|
},
|
|
"components": [c.to_dict() for c in components],
|
|
"dependency_graph": graph_analysis or {},
|
|
"license_compliance": license_info or {},
|
|
}
|
|
|
|
if output_path:
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(result, f, indent=2, default=str)
|
|
print(f"\n[OK] Full report saved to {output_path}")
|
|
|
|
return result
|
|
|
|
|
|
def analyze_sbom(sbom_path, api_key=None, output_path=None, skip_nvd=False):
|
|
"""Full SBOM analysis pipeline: parse, correlate CVEs, graph analysis, report."""
|
|
(components, dependencies), sbom_format = parse_sbom(sbom_path)
|
|
print(f" Components: {len(components)}")
|
|
print(f" Dependency entries: {len(dependencies)}")
|
|
|
|
# Correlate with NVD
|
|
components = correlate_cves(components, api_key=api_key, skip_nvd=skip_nvd)
|
|
|
|
# Build and analyze dependency graph
|
|
G = build_dependency_graph(components, dependencies)
|
|
graph_analysis = analyze_dependency_graph(G)
|
|
|
|
# License compliance check
|
|
license_info = check_license_compliance(components)
|
|
|
|
# Generate report
|
|
result = generate_report(
|
|
components, dependencies, graph_analysis, license_info,
|
|
sbom_path, sbom_format, output_path
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def compare_sboms(sbom_path_old, sbom_path_new, api_key=None):
|
|
"""Compare two SBOMs to identify added, removed, and changed components."""
|
|
(comps_old, _), _ = parse_sbom(sbom_path_old)
|
|
(comps_new, _), _ = parse_sbom(sbom_path_new)
|
|
|
|
old_set = {f"{c.name}@{c.version}" for c in comps_old}
|
|
new_set = {f"{c.name}@{c.version}" for c in comps_new}
|
|
old_names = {c.name for c in comps_old}
|
|
new_names = {c.name for c in comps_new}
|
|
|
|
added = new_set - old_set
|
|
removed = old_set - new_set
|
|
|
|
# Version changes: same name, different version
|
|
old_versions = {c.name: c.version for c in comps_old}
|
|
new_versions = {c.name: c.version for c in comps_new}
|
|
version_changes = []
|
|
for name in old_names & new_names:
|
|
if old_versions[name] != new_versions[name]:
|
|
version_changes.append({
|
|
"name": name,
|
|
"old_version": old_versions[name],
|
|
"new_version": new_versions[name],
|
|
})
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"SBOM DIFF REPORT")
|
|
print(f"{'='*60}")
|
|
print(f"Old: {sbom_path_old} ({len(comps_old)} components)")
|
|
print(f"New: {sbom_path_new} ({len(comps_new)} components)")
|
|
print(f"\nAdded: {len(added)} components")
|
|
for a in sorted(added):
|
|
print(f" + {a}")
|
|
print(f"\nRemoved: {len(removed)} components")
|
|
for r in sorted(removed):
|
|
print(f" - {r}")
|
|
print(f"\nVersion Changes: {len(version_changes)}")
|
|
for vc in version_changes:
|
|
print(f" ~ {vc['name']}: {vc['old_version']} -> {vc['new_version']}")
|
|
|
|
return {"added": sorted(added), "removed": sorted(removed),
|
|
"version_changes": version_changes}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="SBOM Supply Chain Vulnerability Analysis Agent"
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
|
|
# Analyze SBOM
|
|
analyze_parser = subparsers.add_parser("analyze", help="Analyze an SBOM for vulnerabilities")
|
|
analyze_parser.add_argument("sbom_path", help="Path to SBOM file (CycloneDX or SPDX JSON)")
|
|
analyze_parser.add_argument("--api-key", help="NVD API key for higher rate limits")
|
|
analyze_parser.add_argument("--output", "-o", help="Save full report to JSON file")
|
|
analyze_parser.add_argument("--skip-nvd", action="store_true",
|
|
help="Skip NVD API queries (offline mode)")
|
|
|
|
# Compare two SBOMs
|
|
diff_parser = subparsers.add_parser("diff", help="Compare two SBOMs for changes")
|
|
diff_parser.add_argument("old_sbom", help="Path to old/baseline SBOM")
|
|
diff_parser.add_argument("new_sbom", help="Path to new/current SBOM")
|
|
diff_parser.add_argument("--api-key", help="NVD API key")
|
|
|
|
# Parse only (no NVD queries)
|
|
parse_parser = subparsers.add_parser("parse", help="Parse SBOM and list components")
|
|
parse_parser.add_argument("sbom_path", help="Path to SBOM file")
|
|
parse_parser.add_argument("--output", "-o", help="Save component list to JSON")
|
|
|
|
# License check
|
|
license_parser = subparsers.add_parser("licenses", help="Check license compliance")
|
|
license_parser.add_argument("sbom_path", help="Path to SBOM file")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "analyze":
|
|
if not HAS_REQUESTS:
|
|
print("[ERROR] requests library required. Install: pip install requests",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
api_key = args.api_key or os.environ.get("NVD_API_KEY")
|
|
analyze_sbom(args.sbom_path, api_key=api_key, output_path=args.output,
|
|
skip_nvd=args.skip_nvd)
|
|
|
|
elif args.command == "diff":
|
|
compare_sboms(args.old_sbom, args.new_sbom, api_key=args.api_key)
|
|
|
|
elif args.command == "parse":
|
|
(components, dependencies), fmt = parse_sbom(args.sbom_path)
|
|
print(f"\n Total components: {len(components)}")
|
|
for comp in components:
|
|
print(f" {comp.name}@{comp.version} [{comp.component_type}] "
|
|
f"licenses={comp.licenses}")
|
|
if args.output:
|
|
data = {"format": fmt, "component_count": len(components),
|
|
"components": [c.to_dict() for c in components]}
|
|
with open(args.output, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
print(f"\n[OK] Component list saved to {args.output}")
|
|
|
|
elif args.command == "licenses":
|
|
(components, _), _ = parse_sbom(args.sbom_path)
|
|
info = check_license_compliance(components)
|
|
print(f"\nLicense Distribution:")
|
|
for lic, count in sorted(info["license_distribution"].items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
print(f" {lic}: {count}")
|
|
if info["copyleft_components"]:
|
|
print(f"\nCopyleft Components ({len(info['copyleft_components'])}):")
|
|
for cl in info["copyleft_components"]:
|
|
print(f" {cl['name']}@{cl['version']}: {cl['license']}")
|
|
if info["unknown_license_components"]:
|
|
print(f"\nUnknown License ({len(info['unknown_license_components'])}):")
|
|
for ul in info["unknown_license_components"]:
|
|
print(f" {ul['name']}@{ul['version']}")
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|