# Dependency CVE Audit & Patcher

> Audits third-party open-source packages for known vulnerabilities (CVEs), evaluating breaking change risks and generating automated patch updates.

- Skill: `rmazrim/dependency-cve-audit-patcher` (Agent Skill)
- Install (CLI): `npx skillmds@latest add rmazrim/dependency-cve-audit-patcher`
- Raw SKILL.md: https://api.skillmd.com/api/skills/rmazrim/dependency-cve-audit-patcher/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: RMAzrim (https://skillmd.com/u/rmazrim)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/rmazrim/dependency-cve-audit-patcher

---


# 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 --json` backend)
- Target manifest: `package.json` (JS) or `requirements.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):**
```json
{
  "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

```python
#!/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
1. Audit a `package.json` (npm backend):
   ```bash
   python dependency-cve-audit-patcher.md --manifest ./package.json --wordlist-report ./cve_report.json
   ```
2. Read `cve_report.json`: `cve_findings[]` gives dependency, CVE ID, severity, affected range, action.
3. For Python manifests, run against `requirements.txt` (offline CVE list fallback; supply `--cve-list cves.json` for your own feed).
4. Generate fix commands:
   ```bash
   python dependency-cve-audit-patcher.md --manifest ./package.json --fix-mode
   ```
   Execute the printed commands (each is either `npm audit fix` scoped to a package or the whole-run fix).
5. Simulate the dependency-lock change first:
   ```bash
   python dependency-cve-audit-patcher.md --manifest ./package.json --dry-run
   ```
6. Review the lock diff plan; `safe-auto-fix` entries are same-major bumps, `major-bump-review` entries need peer/breaking-change review.
7. Run `npm audit fix` for safe entries, then a full `npm audit` to confirm zero vulnerabilities.
8. For `review-manually` entries (no patch exists), document a risk decision or swap to the vendor-recommended alternative.

## 5. Edge Cases & Error Handling
- `npm` missing or failing → `error` record with the underlying message; fallback CVE list still runs so the report is never empty.
- Non-JSON `npm audit` output (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 manual `npm audit fix` propagation.
- Major-version bumps are never applied automatically — they are marked `major-bump-review` to catch breaking changes (peer deps, API drift).
- `--dry-run` executes no commands; it reads `npm audit --json` (read-only) and only writes the diff-plan JSON.
- Python `requirements.txt`: markers (`; python_version<"3.9"`), comments, and `-r file` lines 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_LIST` is a sample (lodash/minimist/axios) — replace via `--cve-list` with a current advisory feed for production use.

