dependency-check
Audit project dependencies for known-vulnerable versions and risky pinning. Parses requirements.txt and package.json, matches a bundled offline advisory DB, optionally queries OSV.dev live, and warns about unpinned versions. Use when the user asks to "check dependencies for vulne
Install
npx skills add https://github.com/NovaCode37/claude-security-skills/tree/main/skills/dependency-check
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install novacode37-claude-security-skills@llmmart
git clone https://github.com/NovaCode37/claude-security-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole novacode37/claude-security-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Dependency Check
Scans Python (requirements.txt) and npm (package.json) manifests for
known-vulnerable versions and supply-chain risks. Offline by default — it
ships a bundled advisory database so it runs in air-gapped CI — with an optional
live OSV.dev lookup. Pure standard library.
When to use this skill
- "Are any of my dependencies vulnerable?"
- "Audit requirements.txt / package.json."
- "Check for vulnerable / outdated packages before release."
What it reports
- Known vulnerabilities — version matches against the bundled advisory DB
(or OSV.dev with
--online), with CVE/ID, severity and summary. - Unpinned dependencies — ranges (
^,~,>=) or missing pins that make builds non-reproducible and widen supply-chain exposure.
How to run it
# Offline scan (bundled advisory DB)
python skills/dependency-check/checker.py requirements.txt
python skills/dependency-check/checker.py package.json
# Scan a directory (auto-discovers both manifest types)
python skills/dependency-check/checker.py .
# Live advisory lookup via OSV.dev
python skills/dependency-check/checker.py requirements.txt --online
# JSON output
python skills/dependency-check/checker.py . --json
# Only report MEDIUM or higher findings (unpinned warnings are LOW)
python skills/dependency-check/checker.py . --min-severity medium
Exit codes: 0 clean · 1 findings reported · 2 no manifest / usage
error. Unpinned dependencies are reported, so they fail the build too; suppress
them with --no-unpinned, or raise --min-severity to filter advisory
findings out of both the report and the exit code.
Recommended workflow for Claude
- Run offline first for a fast baseline, then
--onlinefor full coverage if the user has network access. - For each vulnerable package, recommend the minimum fixed version and note breaking-change risk.
- Encourage exact pins (
==/ lockfiles) for reproducible, auditable builds.
Note
The bundled DB is intentionally small (well-known historical CVEs) so the tool
is self-contained and testable. For comprehensive coverage use --online
(OSV.dev) or integrate a dedicated scanner; treat the offline DB as a fast
first pass.
Files (claude-security-skills)
-
tests
-
test_checker.py 5.8 KB
import json import os import sys import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import checker requires_tomllib = pytest.mark.skipif( sys.version_info < (3, 11), reason="tomllib is stdlib only on Python 3.11+", ) def test_cmp(): assert checker._cmp("1.2.3", "1.2.4") < 0 assert checker._cmp("2.0.0", "1.9.9") > 0 assert checker._cmp("1.0", "1.0.0") == 0 def test_version_matches(): assert checker.version_matches("0.12.2", "<0.12.3") assert not checker.version_matches("0.12.3", "<0.12.3") assert checker.version_matches("1.0.0", "==1.0.0") assert checker.version_matches("2.0.0", ">=1.0.0") def test_parse_requirements_pinned(): deps = checker.parse_requirements("flask==0.12.2\nrequests>=2.0\n# comment\n") flask = next(d for d in deps if d.name == "flask") assert flask.pinned and flask.version == "0.12.2" req = next(d for d in deps if d.name == "requests") assert not req.pinned def test_parse_requirements_skips_blank_and_flags(): deps = checker.parse_requirements("\n-r other.txt\n--index-url x\nflask==1.0\n") assert {d.name for d in deps} == {"flask"} @requires_tomllib def test_parse_pyproject_toml_dependencies(): toml = """ [project] dependencies = [ "flask==2.0.0", "requests>=2.25", "pandas; python_version < '3.11'", "# ignored comment", ] """ deps = checker.parse_pyproject_toml(toml) assert {d.name for d in deps} == {"flask", "requests", "pandas"} flask = next(d for d in deps if d.name == "flask") assert flask.pinned and flask.version == "2.0.0" requests = next(d for d in deps if d.name == "requests") assert not requests.pinned and requests.version == "2.25" pandas = next(d for d in deps if d.name == "pandas") assert not pandas.pinned and pandas.version is None def test_parse_pyproject_toml_invalid_returns_empty(): assert checker.parse_pyproject_toml("[not a valid toml") == [] def test_parse_package_json(): pkg = json.dumps({ "dependencies": {"lodash": "4.17.20", "axios": "^0.21.0"}, "devDependencies": {"jest": "27.0.0"}, }) deps = checker.parse_package_json(pkg) names = {d.name for d in deps} assert {"lodash", "axios", "jest"} <= names lodash = next(d for d in deps if d.name == "lodash") assert lodash.pinned and lodash.version == "4.17.20" axios = next(d for d in deps if d.name == "axios") assert not axios.pinned def test_parse_package_json_invalid(): assert checker.parse_package_json("{not json") == [] def test_vulnerable_flask_detected(): deps = checker.parse_requirements("flask==0.12.2\n") findings = checker.check_offline(deps) assert any(f.id == "CVE-2018-1000656" for f in findings) def test_patched_flask_not_detected(): deps = checker.parse_requirements("flask==1.0.0\n") assert checker.check_offline(deps) == [] def test_vulnerable_lodash_detected(): deps = checker.parse_package_json( json.dumps({"dependencies": {"lodash": "4.17.20"}})) findings = checker.check_offline(deps) assert any(f.id == "CVE-2021-23337" for f in findings) def test_findings_sorted_by_severity(): deps = checker.parse_requirements("django==2.0.0\njinja2==2.10.0\n") findings = checker.check_offline(deps) ranks = [checker.SEV_RANK[f.severity] for f in findings] assert ranks == sorted(ranks) def test_unpinned_warnings(): deps = checker.parse_requirements("requests>=2.0\nflask==1.0\n") warns = checker.unpinned_warnings(deps) assert any(w.package == "requests" for w in warns) assert not any(w.package == "flask" for w in warns) def test_run_directory(tmp_path): (tmp_path / "requirements.txt").write_text("flask==0.12.2\n") result = checker.run(str(tmp_path)) assert result["dependency_count"] == 1 assert result["vulnerabilities"] @requires_tomllib def test_run_directory_with_pyproject_toml(tmp_path): (tmp_path / "pyproject.toml").write_text( """ [project] dependencies = ["flask==0.12.2"] """ ) result = checker.run(str(tmp_path)) assert result["dependency_count"] == 1 assert len(result["vulnerabilities"]) == 1 def test_cli_exit_code_vuln(tmp_path): f = tmp_path / "requirements.txt" f.write_text("flask==0.12.2\n") assert checker.main([str(f)]) == 1 def test_cli_exit_code_clean(tmp_path): f = tmp_path / "requirements.txt" f.write_text("flask==2.0.0\n") assert checker.main([str(f)]) == 0 def test_cli_no_manifest(tmp_path): assert checker.main([str(tmp_path)]) == 2 def test_cli_json(tmp_path, capsys): f = tmp_path / "requirements.txt" f.write_text("flask==0.12.2\n") checker.main([str(f), "--json"]) data = json.loads(capsys.readouterr().out) assert data["vulnerabilities"] def test_cli_unpinned_only_exits_one(tmp_path): """Unpinned deps are reported, so they must fail the build (issue #46).""" f = tmp_path / "requirements.txt" f.write_text("httpx\nrich>=13.0\n") assert checker.main([str(f)]) == 1 def test_cli_no_unpinned_suppresses_report_and_exit(tmp_path): f = tmp_path / "requirements.txt" f.write_text("httpx\nrich>=13.0\n") assert checker.main([str(f), "--no-unpinned"]) == 0 def test_cli_min_severity_filters_report_and_exit(tmp_path, capsys): f = tmp_path / "requirements.txt" f.write_text("httpx\nrich>=13.0\n") rc = checker.main([str(f), "--min-severity", "medium", "--json"]) data = json.loads(capsys.readouterr().out) assert data["unpinned"] == [] and data["vulnerabilities"] == [] assert rc == 0 def test_run_min_severity_filters_vulnerabilities(tmp_path): f = tmp_path / "requirements.txt" f.write_text("jinja2==2.11.0\n") # medium-severity advisory assert checker.run(str(f))["vulnerabilities"] assert checker.run(str(f), min_severity="high")["vulnerabilities"] == []
-
-
checker.py 11.1 KB
from __future__ import annotations import argparse import json import os import re import sys from dataclasses import dataclass, asdict SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} ADVISORIES = { "pypi": { "flask": [("<0.12.3", "CVE-2018-1000656", "high", "Flask < 0.12.3 DoS via crafted JSON.")], "django": [("<2.2.28", "CVE-2022-28346", "critical", "Django SQL injection via QuerySet.annotate (<2.2.28).")], "requests": [("<2.20.0", "CVE-2018-18074", "high", "requests < 2.20.0 leaks Authorization on redirect.")], "pyyaml": [("<5.4", "CVE-2020-14343", "critical", "PyYAML < 5.4 arbitrary code execution via full_load.")], "jinja2": [("<2.11.3", "CVE-2020-28493", "medium", "Jinja2 < 2.11.3 ReDoS in urlize filter.")], "urllib3": [("<1.26.5", "CVE-2021-33503", "high", "urllib3 < 1.26.5 ReDoS via crafted URL authority.")], "cryptography": [("<3.3.2", "CVE-2020-36242", "high", "cryptography < 3.3.2 buffer overflow in Fernet.")], }, "npm": { "lodash": [("<4.17.21", "CVE-2021-23337", "high", "lodash < 4.17.21 command injection via template.")], "minimist": [("<1.2.6", "CVE-2021-44906", "critical", "minimist < 1.2.6 prototype pollution.")], "axios": [("<0.21.2", "CVE-2021-3749", "high", "axios < 0.21.2 ReDoS via trim regex.")], "node-fetch": [("<2.6.7", "CVE-2022-0235", "medium", "node-fetch < 2.6.7 leaks cookies/Authorization on redirect.")], }, } @dataclass class Finding: ecosystem: str package: str version: str id: str severity: str summary: str def to_dict(self) -> dict: return asdict(self) def _norm(v: str) -> tuple: nums = re.findall(r"\d+", v) return tuple(int(n) for n in nums) if nums else (0,) def _cmp(a: str, b: str) -> int: ta, tb = _norm(a), _norm(b) length = max(len(ta), len(tb)) ta += (0,) * (length - len(ta)) tb += (0,) * (length - len(tb)) return (ta > tb) - (ta < tb) def version_matches(version: str, spec: str) -> bool: m = re.match(r"\s*(<=|>=|==|<|>)\s*(.+)\s*$", spec) if not m: return False op, target = m.group(1), m.group(2).strip() c = _cmp(version, target) return { "<": c < 0, "<=": c <= 0, "==": c == 0, ">": c > 0, ">=": c >= 0, }[op] @dataclass class Dep: ecosystem: str name: str version: str | None raw: str pinned: bool def _parse_dependency_token(text: str) -> tuple[str | None, str | None, bool] | None: token = text.split(";", 1)[0].strip() token = token.split("#", 1)[0].strip() if not token: return None m = re.match( r"^([A-Za-z0-9_.\-]+(?:\[[^\]]+\])?)\s*(==|>=|<=|~=|>|<|!=)?\s*([^\s;,]+)?", token) if not m: return None name = m.group(1).split("[", 1)[0].lower() op, ver = m.group(2), m.group(3) pinned = op == "==" and bool(ver) version = ver if pinned or op else (ver if op and ver else None) return name, version, pinned def parse_requirements(text: str) -> list[Dep]: deps: list[Dep] = [] for line in text.splitlines(): line = line.split("#", 1)[0].strip() if not line or line.startswith("-"): continue parsed = _parse_dependency_token(line) if not parsed: continue name, version, pinned = parsed deps.append(Dep("pypi", name, version, line, pinned)) return deps def parse_pyproject_toml(text: str) -> list[Dep]: try: import tomllib except Exception: return [] try: data = tomllib.loads(text) except Exception: return [] deps: list[Dep] = [] project = data.get("project") or {} project_deps = project.get("dependencies") or [] if not isinstance(project_deps, list): return [] for dep in project_deps: if not isinstance(dep, str): continue parsed = _parse_dependency_token(dep) if not parsed: continue name, version, pinned = parsed deps.append(Dep("pypi", name, version, dep, pinned)) return deps def parse_package_json(text: str) -> list[Dep]: try: data = json.loads(text) except json.JSONDecodeError: return [] deps: list[Dep] = [] for section in ("dependencies", "devDependencies", "optionalDependencies"): for name, spec in (data.get(section) or {}).items(): spec = str(spec) pinned = bool(re.match(r"^\d+\.\d+\.\d+", spec)) and not any( c in spec for c in "^~*x ><||") ver = re.sub(r"^[\^~>=<\s]+", "", spec) ver = ver if re.match(r"^\d", ver) else None deps.append(Dep("npm", name.lower(), ver, f"{name}: {spec}", pinned)) return deps def check_offline(deps: list[Dep]) -> list[Finding]: findings: list[Finding] = [] for dep in deps: if not dep.version: continue eco_db = ADVISORIES.get(dep.ecosystem, {}) for spec, cve, sev, summary in eco_db.get(dep.name, []): if version_matches(dep.version, spec): findings.append(Finding(dep.ecosystem, dep.name, dep.version, cve, sev, summary)) findings.sort(key=lambda f: SEV_RANK.get(f.severity, 9)) return findings def check_online_osv(deps: list[Dep], timeout: float = 10.0) -> list[Finding]: import urllib.request eco_map = {"pypi": "PyPI", "npm": "npm"} findings: list[Finding] = [] for dep in deps: if not dep.version: continue body = json.dumps({ "version": dep.version, "package": {"name": dep.name, "ecosystem": eco_map[dep.ecosystem]}, }).encode() req = urllib.request.Request( "https://api.osv.dev/v1/query", data=body, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read()) except Exception: continue for vuln in data.get("vulns", []): sev = _osv_severity(vuln) findings.append(Finding(dep.ecosystem, dep.name, dep.version, vuln.get("id", "OSV"), sev, (vuln.get("summary") or "")[:200])) findings.sort(key=lambda f: SEV_RANK.get(f.severity, 9)) return findings def _osv_severity(vuln: dict) -> str: db = (vuln.get("database_specific") or {}) sev = str(db.get("severity", "")).lower() if sev in SEV_RANK: return sev return "medium" def unpinned_warnings(deps: list[Dep]) -> list[Finding]: out: list[Finding] = [] for dep in deps: if not dep.pinned: out.append(Finding(dep.ecosystem, dep.name, dep.version or "*", "unpinned", "low", "Dependency is not pinned to an exact version " "— non-reproducible builds and supply-chain risk.")) return out def filter_by_severity(findings: list[Finding], min_severity: str = "info") -> list[Finding]: threshold = SEV_RANK.get(min_severity, SEV_RANK["info"]) return [f for f in findings if SEV_RANK.get(f.severity, 3) <= threshold] def parse_file(path: str) -> list[Dep]: with open(path, "r", encoding="utf-8", errors="ignore") as fh: text = fh.read() base = os.path.basename(path).lower() if base == "package.json": return parse_package_json(text) if base == "pyproject.toml": return parse_pyproject_toml(text) if base.endswith(".txt") or "requirements" in base: return parse_requirements(text) return parse_requirements(text) def discover(path: str) -> list[str]: if os.path.isfile(path): return [path] found = [] for name in ("requirements.txt", "package.json", "pyproject.toml"): candidate = os.path.join(path, name) if os.path.isfile(candidate): found.append(candidate) return found def run(path: str, online: bool = False, show_unpinned: bool = True, min_severity: str = "info") -> dict: files = discover(path) deps: list[Dep] = [] for f in files: deps.extend(parse_file(f)) findings = check_offline(deps) if online: findings.extend(check_online_osv(deps)) findings = filter_by_severity(findings, min_severity) warnings = (filter_by_severity(unpinned_warnings(deps), min_severity) if show_unpinned else []) return { "files": files, "dependency_count": len(deps), "vulnerabilities": [f.to_dict() for f in findings], "unpinned": [w.to_dict() for w in warnings], } def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="dependency-check", description="Check dependencies for known vulns and risky pinning.") p.add_argument("path", help="requirements.txt, package.json, or a directory") p.add_argument("--online", action="store_true", help="also query the OSV.dev advisory API (network)") p.add_argument("--no-unpinned", action="store_true", help="suppress unpinned-dependency warnings") p.add_argument("--json", action="store_true", help="emit JSON") p.add_argument("--min-severity", default="info", choices=list(SEV_RANK), help="report findings at or above this severity " "(default: info, i.e. report everything)") args = p.parse_args(argv) if not os.path.exists(args.path): print(f"error: path not found: {args.path}", file=sys.stderr) return 2 result = run(args.path, online=args.online, show_unpinned=not args.no_unpinned, min_severity=args.min_severity) if not result["files"]: print("error: no requirements.txt or package.json found.", file=sys.stderr) return 2 if args.json: print(json.dumps(result, indent=2)) else: vulns = result["vulnerabilities"] print(f"[dependency-check] scanned {result['dependency_count']} " f"dependencies from {len(result['files'])} file(s)\n") if vulns: print(f"Known vulnerabilities ({len(vulns)}):") for v in vulns: print(f" [{v['severity'].upper():<8}] {v['package']} " f"{v['version']} {v['id']}: {v['summary']}") else: print("No known vulnerabilities in the offline DB. [OK]") if result["unpinned"]: print(f"\nUnpinned dependencies ({len(result['unpinned'])}):") for w in result["unpinned"]: print(f" - {w['package']} ({w['version']})") return 1 if result["vulnerabilities"] or result["unpinned"] else 0 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass raise SystemExit(main()) -
SKILL.md 2.7 KB
--- name: dependency-check description: >- Audit project dependencies for known-vulnerable versions and risky pinning. Parses requirements.txt and package.json, matches a bundled offline advisory DB, optionally queries OSV.dev live, and warns about unpinned versions. Use when the user asks to "check dependencies for vulnerabilities", "audit my requirements.txt / package.json", "scan for vulnerable packages", or "is my dependency tree secure". license: MIT --- # Dependency Check Scans Python (`requirements.txt`) and npm (`package.json`) manifests for known-vulnerable versions and supply-chain risks. **Offline by default** — it ships a bundled advisory database so it runs in air-gapped CI — with an optional live OSV.dev lookup. Pure standard library. ## When to use this skill - "Are any of my dependencies vulnerable?" - "Audit requirements.txt / package.json." - "Check for vulnerable / outdated packages before release." ## What it reports - **Known vulnerabilities** — version matches against the bundled advisory DB (or OSV.dev with `--online`), with CVE/ID, severity and summary. - **Unpinned dependencies** — ranges (`^`, `~`, `>=`) or missing pins that make builds non-reproducible and widen supply-chain exposure. ## How to run it ```bash # Offline scan (bundled advisory DB) python skills/dependency-check/checker.py requirements.txt python skills/dependency-check/checker.py package.json # Scan a directory (auto-discovers both manifest types) python skills/dependency-check/checker.py . # Live advisory lookup via OSV.dev python skills/dependency-check/checker.py requirements.txt --online # JSON output python skills/dependency-check/checker.py . --json # Only report MEDIUM or higher findings (unpinned warnings are LOW) python skills/dependency-check/checker.py . --min-severity medium ``` **Exit codes:** `0` clean · `1` findings reported · `2` no manifest / usage error. Unpinned dependencies are reported, so they fail the build too; suppress them with `--no-unpinned`, or raise `--min-severity` to filter advisory findings out of both the report and the exit code. ## Recommended workflow for Claude 1. Run offline first for a fast baseline, then `--online` for full coverage if the user has network access. 2. For each vulnerable package, recommend the **minimum fixed version** and note breaking-change risk. 3. Encourage exact pins (`==` / lockfiles) for reproducible, auditable builds. ## Note The bundled DB is intentionally small (well-known historical CVEs) so the tool is self-contained and testable. For comprehensive coverage use `--online` (OSV.dev) or integrate a dedicated scanner; treat the offline DB as a fast first pass.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.