Dependency CVE Audit & Patcher
1. System Architecture & Prerequisites
- Python 3.10+ (stdlib only:
re,os,json,subprocess,argparse,pathlib) - Node.js 18+ with npm available on PATH (for the
npm audit --jsonbackend) - Target manifest:
package.json(JS) orrequirements.txt(Python, PEP 508 syntax) - Optional CVE list file: JSON array of
{package, versions[], cve_id, severity, patched[]}for offline (Python-only) mode
2. Input/Output Data Contracts
Input (CLI args):
{
"type": "object",
"properties": {
"manifest": { "type": "string", "description": "Path to package.json or requirements.txt" },
"wordlist-report": { "type": "string", "description": "Output CVE report JSON path", "default": "./cve_report.json" },
"fix-mode": { "type": "boolean", "description": "Generate npm audit fix + safe update commands", "default": false },
"dry-run": { "type": "boolean", "description": "Print dependency-lock diff plan without executing", "default": false }
},
"required": ["manifest"]
}
Output artifacts:
{wordlist-report}— JSON CVE report (dependency,cve,severity,range,patched,action){cve_dir}/npm_audit_fix_commands.sh— fix command list (when--fix-mode){cve_dir}/dependency_lock_diff.json— dry-run lock diff plan (when--dry-run)
3. Production Reference Implementation
#!/usr/bin/env python3
"""Dependency CVE Audit & Patcher — npm audit backend + offline CVE list fallback + patch planner."""
import re
import os
import json
import argparse
import subprocess
from pathlib import Path
SEMVER_OP = re.compile(r"^([<>=~^*]+)?\s*(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
# Offline fallback CVE list (sample — replace with a real advisory feed).
DEFAULT_CVE_LIST = [
{
"package": "lodash",
"versions": ["<4.17.21"],
"cve_id": "CVE-2021-23337",
"severity": "high",
"patched": ">=4.17.21",
"source": "offline-sample",
},
{
"package": "minimist",
"versions": ["<1.2.6"],
"cve_id": "CVE-2021-44906",
"severity": "high",
"patched": ">=1.2.6",
"source": "offline-sample",
},
{
"package": "axios",
"versions": ["<0.21.2"],
"cve_id": "CVE-2021-3749",
"severity": "high",
"patched": ">=0.21.2",
"source": "offline-sample",
},
]
def parse_package_json(path: Path) -> dict:
"""Parse package.json into {name, version, deps: [{name, range, kind}]}."""
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
deps = []
for kind, section in (("dependencies", data.get("dependencies", {})),
("devDependencies", data.get("devDependencies", {}))):
for name, version_range in section.items():
deps.append({"name": name, "range": version_range, "kind": kind})
return {"name": data.get("name", "?"), "version": data.get("version", "0.0.0"), "deps": deps}
def parse_requirements_txt(path: Path) -> dict:
"""Parse requirements.txt (PEP 508) into the same manifest shape."""
deps = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
marker = line.split(";")[0].strip()
spec = re.split(r"\s*(===|==|>=|<=|!=|~=|>|<)\s*", marker)
name = spec[0]
bounds = spec[1] + spec[2] if len(spec) >= 3 else ""
deps.append({"name": name, "range": bounds or "*", "kind": "dependencies"})
return {"name": "requirements.txt", "version": "0.0.0", "deps": deps}
def parse_manifest(path: Path) -> dict:
suffix = path.suffix.lower()
if suffix == ".json":
return parse_package_json(path)
if suffix in (".txt", ""):
return parse_requirements_txt(path)
raise ValueError(f"Unsupported manifest type {suffix!r}; use package.json or requirements.txt")
def run_npm_audit(manifest_dir: Path) -> dict:
"""Run `npm audit --json` in the manifest directory. Returns parsed advisories."""
cmd = ["npm", "audit", "--json"]
try:
proc = subprocess.run(cmd, cwd=str(manifest_dir), capture_output=True, text=True, timeout=180)
except (subprocess.SubprocessError, OSError) as exc:
return {"error": f"npm audit failed to run: {exc}"}
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
return {"error": f"npm audit returned non-JSON output (exit {proc.returncode}): {proc.stdout[:300]}"}
advisories = []
if data.get("error"):
advisories.append({"error": data["error"]["detail"]})
return {"advisories": advisories, "exit_code": proc.returncode}
vulns = data.get("vulnerabilities", {})
for pkg_name, info in vulns.items():
via = info.get("via", [])
advisories.append({
"dependency": pkg_name,
"severity": info.get("severity", "unknown"),
"range": info.get("range", "?"),
"fix_available": info.get("fixAvailable", False),
"is_direct": info.get("isDirect", False),
"via_details": [a if isinstance(a, str) else (a.get("url", "?") if isinstance(a, dict) else "?") for a in via],
"source": "npm-audit",
})
return {"advisories": advisories, "exit_code": proc.returncode, "vuln_count": len(vulns)}
def semver_compare(a: str, b: str) -> int:
"""Compare two semver strings like 1.2.3 (no prerelease support needed here)."""
ma = [int(x) for x in re.findall(r"\d+", a)][:3]
mb = [int(x) for x in re.findall(r"\d+", b)][:3]
while len(ma) < 3:
ma.append(0)
while len(mb) < 3:
mb.append(0)
return (ma > mb) - (ma < mb)
def major_of(version: str) -> int:
import re as _re
m = _re.match(r"\s*v?(\d+)", version or "")
return int(m.group(1)) if m else 0
def evaluate_patch(current_range: str, patched_range: str) -> str:
"""Classify a patch by breaking-change risk."""
cur_major = major_of(current_range.lstrip("^~>=<=>="))
pat_major = major_of(patched_range.lstrip("^~>=<=>="))
if not current_range or not patched_range:
return "review-manually"
if cur_major and pat_major and cur_major == pat_major:
return "safe-auto-fix"
if pat_major and cur_major and pat_major > cur_major:
return "major-bump-review"
return "review-manually"
def check_offline_cves(manifest: dict, cve_list_path: str) -> list:
"""Python-only fallback: compare manifest deps against a CVE list file."""
cve_list = DEFAULT_CVE_LIST
if cve_list_path:
cve_list = json.loads(Path(cve_list_path).read_text(encoding="utf-8"))
lockfile_candidates = []
package_lock = None
for name in ("package-lock.json", "yarn.lock", "pnpm-lock.yaml"):
cand = Path(name)
if cand.exists():
package_lock = cand
break
findings = []
for dep in manifest["deps"]:
for cve in cve_list:
if cve.get("package") != dep["name"]:
continue
for ver_expr in cve.get("versions", []):
patched_ok = any(semver_compare(dep["range"], p) >= 0 for p in cve.get("patched", []))
if not patched_ok:
findings.append({
"dependency": dep["name"],
"declared_range": dep["range"],
"cve": cve.get("cve_id", "UNKNOWN"),
"severity": cve.get("severity", "unknown"),
"vulnerable_version_range": ver_expr,
"patched": cve.get("patched", []),
"action": evaluate_patch(dep["range"], ",".join(cve.get("patched", []))),
"is_direct": True,
"source": f"offline-cve-list{(' + ' + str(package_lock)) if package_lock else ''}",
})
return findings
def build_report(manifest_path: Path, wordlist_report: str, fix_mode: bool, dry_run: bool, cve_list_path: str) -> dict:
manifest = parse_manifest(manifest_path)
manifest_dir = manifest_path.parent if manifest_path.suffix == ".json" else manifest_path.parent
npm_result = {}
if manifest_path.suffix == ".json":
npm_result = run_npm_audit(manifest_dir)
actions = []
if npm_result.get("advisories"):
unique_pkgs = {}
for adv in npm_result["advisories"]:
if "error" in adv:
continue
key = adv["dependency"]
unique_pkgs.setdefault(key, adv)
for adv in unique_pkgs.values():
patched = "N/A"
action = "npm audit fix" if adv.get("fix_available") else "manual-review"
actions.append({
"dependency": adv["dependency"],
"cve": "npm-advisory",
"severity": adv.get("severity"),
"range": adv.get("range"),
"patched": patched,
"action": action,
"is_direct": adv.get("is_direct"),
"source": "npm-audit",
})
else:
actions.extend(check_offline_cves(manifest, cve_list_path))
deduped = {}
for a in actions:
deduped.setdefault(a["dependency"], a)
report = {
"manifest": str(manifest_path),
"project": manifest["name"],
"version": manifest["version"],
"dependency_count": len(manifest["deps"]),
"advisories": npm_result.get("advisories", []),
"cve_findings": list(deduped.values()),
"patch_plan": {},
}
fix_commands = []
if fix_mode and manifest_path.suffix == ".json":
for a in deduped.values():
if a["action"].startswith("npm audit fix"):
fix_commands.append(f"npm audit fix --package-lock-only --dry-run # {a['dependency']}")
if fix_commands:
report["patch_plan"]["npm"] = fix_commands
else:
report["patch_plan"]["npm"] = ["npm audit fix"] # apply all directly safe fixes
if dry_run:
lock_diff = {"plan": []}
for a in deduped.values():
lock_diff["plan"].append({
"dependency": a["dependency"],
"from": a["range"],
"to": a.get("patched") if a.get("patched") != "N/A" else "review",
"risk": "low" if a["action"] == "safe-auto-fix" else "medium" if a["action"] == "major-bump-review" else "high",
"dry_run": True,
})
report["dry_run_diff"] = lock_diff
return report
def main():
parser = argparse.ArgumentParser(
description="Dependency CVE Audit & Patcher — npm audit + offline CVE list + patch-risk evaluation.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--manifest", required=True, help="Path to package.json or requirements.txt")
parser.add_argument("--wordlist-report", default="./cve_report.json", help="Output CVE report JSON path")
parser.add_argument("--fix-mode", action="store_true", help="Generate npm audit fix + safe update command list")
parser.add_argument("--dry-run", action="store_true", help="Print dependency-lock diff plan without executing")
parser.add_argument("--cve-list", default=None, help="Optional offline CVE list JSON for the Python-only fallback")
args = parser.parse_args()
manifest_path = Path(args.manifest).resolve()
if not manifest_path.is_file():
print(f"[ERROR] Manifest does not exist: {manifest_path}")
raise SystemExit(1)
try:
report = build_report(manifest_path, args.wordlist_report, args.fix_mode, args.dry_run, args.cve_list)
except ValueError as exc:
print(f"[ERROR] {exc}")
raise SystemExit(1)
report_path = Path(args.wordlist_report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
print(f"\nProject: {report['project']}@{report['version']} (deps: {report['dependency_count']})")
for f in report["cve_findings"]:
print(f" [{f['severity'].upper()}] {f['dependency']} -> {f['cve']} action: {f['action']}")
if report.get("patch_plan"):
print("\nFix commands:")
for cmd in report["patch_plan"].get("npm", []):
print(f" $ {cmd}")
if report.get("dry_run_diff"):
print("\nDry-run lock diff plan:")
for item in report["dry_run_diff"]["plan"]:
print(f" {item['dependency']}: {item['from']} -> {item['to']} (risk: {item['risk']})")
print(f"\n[INFO] Report written to {report_path}")
if __name__ == "__main__":
main()
4. Execution Protocol & Step-by-Step Workflow
- Audit a
package.json(npm backend):python dependency-cve-audit-patcher.md --manifest ./package.json --wordlist-report ./cve_report.json - Read
cve_report.json:cve_findings[]gives dependency, CVE ID, severity, affected range, action. - For Python manifests, run against
requirements.txt(offline CVE list fallback; supply--cve-list cves.jsonfor your own feed). - Generate fix commands:
Execute the printed commands (each is eitherpython dependency-cve-audit-patcher.md --manifest ./package.json --fix-modenpm audit fixscoped to a package or the whole-run fix). - Simulate the dependency-lock change first:
python dependency-cve-audit-patcher.md --manifest ./package.json --dry-run - Review the lock diff plan;
safe-auto-fixentries are same-major bumps,major-bump-reviewentries need peer/breaking-change review. - Run
npm audit fixfor safe entries, then a fullnpm auditto confirm zero vulnerabilities. - For
review-manuallyentries (no patch exists), document a risk decision or swap to the vendor-recommended alternative.
5. Edge Cases & Error Handling
npmmissing or failing →errorrecord with the underlying message; fallback CVE list still runs so the report is never empty.- Non-JSON
npm auditoutput (npm version mismatch) is captured with the exit code rather than crashing. - Transitive-only findings (
is_direct: false) still get an advisory entry, flagged for manualnpm audit fixpropagation. - Major-version bumps are never applied automatically — they are marked
major-bump-reviewto catch breaking changes (peer deps, API drift). --dry-runexecutes no commands; it readsnpm audit --json(read-only) and only writes the diff-plan JSON.- Python
requirements.txt: markers (; python_version<"3.9"), comments, and-r filelines are ignored; only name + version-spec pairs are parsed. - Lockfile candidate lookup is best-effort; offline findings fall back to the declared range in the manifest.
- The shipped
DEFAULT_CVE_LISTis a sample (lodash/minimist/axios) — replace via--cve-listwith a current advisory feed for production use.