@intentsolutionsio/penetration-tester 2.0.0 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/README.md +8 -0
  3. package/commands/pentest.md +5 -0
  4. package/package.json +8 -3
  5. package/skills/analyzing-tls-config/SKILL.md +221 -0
  6. package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
  7. package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
  8. package/skills/analyzing-tls-config/references/THEORY.md +128 -0
  9. package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
  10. package/skills/auditing-cors-policy/SKILL.md +186 -0
  11. package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
  12. package/skills/auditing-cors-policy/references/THEORY.md +142 -0
  13. package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
  14. package/skills/auditing-npm-dependencies/SKILL.md +254 -0
  15. package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
  16. package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
  17. package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
  18. package/skills/auditing-python-dependencies/SKILL.md +251 -0
  19. package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
  20. package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
  21. package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
  22. package/skills/checking-http-security-headers/SKILL.md +176 -0
  23. package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
  24. package/skills/checking-http-security-headers/references/THEORY.md +137 -0
  25. package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
  26. package/skills/checking-license-compliance/SKILL.md +225 -0
  27. package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
  28. package/skills/checking-license-compliance/references/THEORY.md +152 -0
  29. package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
  30. package/skills/composing-vulnerability-report/SKILL.md +212 -0
  31. package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
  32. package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
  33. package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
  34. package/skills/confirming-pentest-authorization/SKILL.md +247 -0
  35. package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
  36. package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
  37. package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
  38. package/skills/defining-pentest-scope/SKILL.md +227 -0
  39. package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
  40. package/skills/defining-pentest-scope/references/THEORY.md +170 -0
  41. package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
  42. package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
  43. package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
  44. package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
  45. package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
  46. package/skills/detecting-debug-endpoints/SKILL.md +207 -0
  47. package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
  48. package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
  49. package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
  50. package/skills/detecting-directory-listing/SKILL.md +206 -0
  51. package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
  52. package/skills/detecting-directory-listing/references/THEORY.md +203 -0
  53. package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
  54. package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
  55. package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
  56. package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
  57. package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
  58. package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
  59. package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
  60. package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
  61. package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
  62. package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
  63. package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
  64. package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
  65. package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
  66. package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
  67. package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
  68. package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
  69. package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
  70. package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
  71. package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
  72. package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
  73. package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
  74. package/skills/detecting-weak-cryptography/SKILL.md +147 -0
  75. package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
  76. package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
  77. package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
  78. package/skills/fingerprinting-server-software/SKILL.md +191 -0
  79. package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
  80. package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
  81. package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
  82. package/skills/generating-executive-summary/SKILL.md +261 -0
  83. package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
  84. package/skills/generating-executive-summary/references/THEORY.md +195 -0
  85. package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
  86. package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
  87. package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
  88. package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
  89. package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
  90. package/skills/performing-penetration-testing/SKILL.md +282 -190
  91. package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
  92. package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
  93. package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
  94. package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
  95. package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
  96. package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
  97. package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
  98. package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
  99. package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
  100. package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
  101. package/skills/recording-pentest-engagement/SKILL.md +253 -0
  102. package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
  103. package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
  104. package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
  105. package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
  106. package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
  107. package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
  108. package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
  109. package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
  110. package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
  111. package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
  112. package/skills/tracing-transitive-vulnerabilities/scripts/trace_vulns.py +484 -0
@@ -0,0 +1,175 @@
1
+ # PLAYBOOK — Remediating npm Findings
2
+
3
+ ## Decision flow
4
+
5
+ ```
6
+ npm audit finding
7
+ |
8
+ v
9
+ +-------------------+
10
+ | DIRECT or |
11
+ | TRANSITIVE? |
12
+ +---+-----------+---+
13
+ | |
14
+ DIRECT | | TRANSITIVE
15
+ v v
16
+ +----------+ +-----------------+
17
+ | semver- | | parent has new |
18
+ | minor | | version with |
19
+ | fix? | | floor above fix?|
20
+ +-+----+---+ +--+----------+---+
21
+ | | | |
22
+ YES NO YES NO
23
+ | | | |
24
+ v v v v
25
+ audit manual bump overrides
26
+ fix review parent block
27
+ ```
28
+
29
+ ## Per-runtime remediation patterns
30
+
31
+ ### Frontend bundler (webpack / vite / next.js)
32
+
33
+ CVEs in build-time-only deps (loaders, transformers, dev plugins)
34
+ are lower priority than CVEs in runtime deps that ship in the
35
+ bundle.
36
+
37
+ - Build-time only: file ticket, fix in next sprint.
38
+ - Runtime in bundle: block release.
39
+ - Use `npm audit --omit=dev` to focus on runtime; this skill's
40
+ `--include-dev` flag toggles the inverse.
41
+
42
+ ### Node server (Express, Fastify, Hapi)
43
+
44
+ Every advisory affects a long-running process. Treat all of them
45
+ as runtime, including those marked dev-only — `eslint` etc. usually
46
+ DON'T ship to production, but if they're in your Docker image at
47
+ runtime, they do.
48
+
49
+ Inspect your `Dockerfile`. If it copies `node_modules` whole, every
50
+ dep ships. If it uses `npm ci --omit=dev`, devDeps are pruned.
51
+
52
+ ### Electron desktop app
53
+
54
+ Electron apps bundle Chromium + Node into a binary distributed to
55
+ end-users. A vulnerable dep ships to every user. Patches require a
56
+ full app release; you cannot ship a runtime hotpatch. CRITICAL
57
+ findings require an emergency release.
58
+
59
+ ### AWS Lambda / serverless function
60
+
61
+ Each function invocation loads the dependency tree. The blast radius
62
+ of a compromised dep is whatever the Lambda's IAM role grants. Audit
63
+ the IAM role alongside the dep audit — a compromised `request` package
64
+ in a Lambda with `s3:*` permissions is an exfiltration vector to
65
+ every object in your S3 buckets.
66
+
67
+ ### Monorepo (Nx / Turborepo / pnpm workspaces)
68
+
69
+ Audit each workspace separately. `npm audit --workspaces` exists in
70
+ npm 7+ but produces output keyed by workspace and is awkward to
71
+ parse. This skill currently scans one package.json at a time; for
72
+ monorepos, iterate over the workspace globs in your top-level
73
+ `package.json` and run the scanner per package.
74
+
75
+ ## Override-block templates
76
+
77
+ ### Single-package override
78
+
79
+ ```json
80
+ {
81
+ "overrides": {
82
+ "minimist": "^1.2.6"
83
+ }
84
+ }
85
+ ```
86
+
87
+ Use when one transitive package needs a floor and the rest of the
88
+ parent's deps are fine.
89
+
90
+ ### Nested override (specific parent only)
91
+
92
+ ```json
93
+ {
94
+ "overrides": {
95
+ "express": {
96
+ "qs": "^6.10.3"
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ Force `qs@^6.10.3` only when pulled in through `express` — leave
103
+ other consumers of `qs` untouched. Use when an override breaks an
104
+ unrelated package and you need surgical control.
105
+
106
+ ### Wildcard override (rare)
107
+
108
+ ```json
109
+ {
110
+ "overrides": {
111
+ "lodash": "$lodash"
112
+ }
113
+ }
114
+ ```
115
+
116
+ `$lodash` resolves to the version declared in your `dependencies`.
117
+ Use to enforce that every transitive use of `lodash` matches your
118
+ declared version. Reserve for cases where transitive duplication is
119
+ causing real bundle-size or behavior problems.
120
+
121
+ ## Provider rotation procedures
122
+
123
+ Vulnerabilities sometimes overlap credential leaks: a CVE in a
124
+ package that handles auth tokens may incidentally expose tokens.
125
+ After fixing the dep, check whether the vulnerable version logged
126
+ or transmitted secrets in a way that requires rotation.
127
+
128
+ | Provider | Rotation surface |
129
+ |---|---|
130
+ | GitHub | Settings → Developer settings → Personal access tokens → Regenerate |
131
+ | npm | `npm token revoke <id>` + `npm token create` |
132
+ | AWS | IAM → Access keys → Deactivate → Delete → New access key |
133
+ | Stripe | Dashboard → Developers → API keys → Roll key |
134
+ | Sentry | Settings → Auth Tokens → Revoke + Create |
135
+
136
+ ## GitHub Dependabot integration
137
+
138
+ Dependabot opens automatic PRs when GitHub's vulnerability database
139
+ flags a finding. This skill is complementary, not redundant:
140
+
141
+ - Dependabot is the long-running watcher.
142
+ - This skill is the PR-time gate that ensures Dependabot's findings
143
+ haven't accumulated unreviewed.
144
+
145
+ In CI:
146
+
147
+ ```yaml
148
+ - name: npm audit
149
+ run: |
150
+ python3 plugins/security/penetration-tester/skills/auditing-npm-dependencies/scripts/audit_npm.py . \
151
+ --min-severity high --format markdown --output npm-audit.md
152
+ - name: Comment on PR
153
+ if: github.event_name == 'pull_request'
154
+ uses: marocchino/sticky-pull-request-comment@v2
155
+ with:
156
+ path: npm-audit.md
157
+ ```
158
+
159
+ ## SOC2 evidence retention
160
+
161
+ For Trust Service Category CC7 (System Operations) and CC8 (Change
162
+ Management), retain npm audit output as evidence that dependency
163
+ vulnerabilities are tracked:
164
+
165
+ ```bash
166
+ mkdir -p evidence/CC7/
167
+ python3 ./scripts/audit_npm.py . --include-dev --no-cache \
168
+ --format json \
169
+ --output evidence/CC7/npm-audit-$(date +%Y%m%d).json
170
+ ```
171
+
172
+ Keep at least one audit per quarter. Auditors will ask for evidence
173
+ that you ran the audit, that findings were triaged, and that
174
+ remediation timing matched your published vulnerability-response
175
+ policy.
@@ -0,0 +1,122 @@
1
+ # THEORY — Why npm Dependency Audits Matter
2
+
3
+ ## The shape of the problem
4
+
5
+ A modern Node application is mostly other people's code. A typical
6
+ React + Next.js app installs ~30 direct dependencies and ends up
7
+ with ~1,500 packages in `node_modules` after npm resolves the
8
+ transitive closure. Every one of those packages can:
9
+
10
+ 1. Ship a known CVE that gets disclosed after you installed.
11
+ 2. Be hijacked through maintainer-account takeover (the npm registry
12
+ has had several published cases since 2017).
13
+ 3. Be replaced by a typosquatted near-name package that someone
14
+ slipped into a lockfile during a careless `npm install`.
15
+
16
+ Because npm packages execute arbitrary JavaScript on install
17
+ (`postinstall` scripts), a single compromised package in your
18
+ transitive tree can read environment variables, exfiltrate secrets,
19
+ and pivot to other systems with no user interaction beyond the
20
+ install.
21
+
22
+ ## Historical npm supply-chain compromises
23
+
24
+ These are the cases that drove the industry to treat dependency
25
+ auditing as a CI gate, not optional polish.
26
+
27
+ | Year | Event | Mechanism |
28
+ |---|---|---|
29
+ | 2018 | `event-stream` | Maintainer transferred to a new account that injected wallet-stealing code into a transitive dep (`flatmap-stream`). Affected Bitcoin wallet users. |
30
+ | 2021 | `ua-parser-js` | Maintainer account compromise. Crypto miner shipped to ~8M weekly downloads. |
31
+ | 2022 | `node-ipc` | Maintainer-installed protestware that targeted users in specific geographies. |
32
+ | 2022 | `colors.js` / `faker.js` | Maintainer self-sabotage; broke production builds globally for the affected versions. |
33
+ | 2024 | `lottie-player` | Cloudflare CDN compromise distributed wallet-drainer code. |
34
+
35
+ Each case ended with the same remediation: roll back to a known-good
36
+ version range, audit logs for exploitation evidence, publish an
37
+ incident postmortem. The presence of an audit gate would not have
38
+ prevented the original install (the malicious code was new), but
39
+ would have surfaced the compromise within hours of disclosure.
40
+
41
+ ## Direct vs transitive: the remediation diff
42
+
43
+ A CVE in a direct dependency you require in `package.json` is
44
+ fixable by upgrading the version range. A CVE in a transitive
45
+ dependency requires either (a) a parent bump that pulls in the fix
46
+ or (b) an `overrides` block in your root `package.json`.
47
+
48
+ The `overrides` mechanism was introduced in npm 8.3 (December 2021).
49
+ Before that, only Yarn's `resolutions` field offered the equivalent
50
+ escape hatch. `overrides` lets you force the resolved version of a
51
+ package regardless of what the dependency graph requested:
52
+
53
+ ```json
54
+ {
55
+ "overrides": {
56
+ "minimist": "^1.2.6"
57
+ }
58
+ }
59
+ ```
60
+
61
+ This is safe when the override pins to a SemVer-compatible version
62
+ of the package the parent expected. It is RISKY when the override
63
+ crosses a major-version boundary — the parent may rely on removed
64
+ APIs and break at runtime. The audit script tags transitive findings
65
+ with a `relationship: transitive` evidence field to flag them for
66
+ the operator's manual review.
67
+
68
+ ## npm audit output schema diff (v1 vs v2)
69
+
70
+ npm 6 emitted findings under an `advisories` key, one per advisory.
71
+ npm 7+ rewrote the output to a `vulnerabilities` key, keyed by
72
+ package name, with the per-package record summarizing all advisories
73
+ affecting that package.
74
+
75
+ | Aspect | v1 (npm 6) | v2 (npm 7+) |
76
+ |---|---|---|
77
+ | Top-level key | `advisories` | `vulnerabilities` |
78
+ | Keyed by | Advisory ID | Package name |
79
+ | Direct vs transitive | implicit | explicit via `via` field |
80
+ | Fix metadata | `patched_versions` string | `fixAvailable` object or boolean |
81
+ | CVE field | `cves` array | per-advisory `cve` field in `via[]` |
82
+
83
+ This skill's parser handles both shapes. Practically, most modern
84
+ projects run npm 8+; the v1 parser is kept for legacy CI runners
85
+ and engineering laptops that haven't upgraded.
86
+
87
+ ## Why severity normalization matters
88
+
89
+ Different tools use different severity vocabularies. npm uses
90
+ `info/low/moderate/high/critical`. CVSS uses numeric scores.
91
+ GitHub uses 4 levels. PyPA uses something else again. The
92
+ penetration-tester `Severity` enum is the canonical mapping target
93
+ so downstream consumers (SOC2 evidence packages, security
94
+ dashboards, executive summary reports) see one vocabulary across
95
+ every tool. The `Severity.from_npm_audit` classmethod in
96
+ `lib/finding.py` does the npm-specific mapping.
97
+
98
+ ## When `npm audit fix` is dangerous
99
+
100
+ `npm audit fix` rewrites `package-lock.json` to the closest
101
+ non-breaking versions that resolve advisories. Two failure modes:
102
+
103
+ 1. **Semver-major fix required.** npm refuses to auto-apply and
104
+ warns "requires manual review." This is correct behavior; do not
105
+ pass `--force` casually. A major version bump can break the
106
+ parent's API contract.
107
+ 2. **Lockfile churn on shared CI runners.** If the lockfile rewrite
108
+ happens in a CI step that doesn't commit back, the next CI run
109
+ sees the same vulnerabilities and re-applies the fix, producing
110
+ a stable but undocumented divergence between developer workstations
111
+ and CI. Either commit the fix in CI (with appropriate guards) or
112
+ require the fix to land via human PR.
113
+
114
+ ## When to use the CISA KEV list
115
+
116
+ NIST's NVD scoring (CVSS) reflects intrinsic severity. CISA's Known
117
+ Exploited Vulnerabilities (KEV) catalog reflects observed exploitation
118
+ in the wild. A high-CVSS finding with no KEV listing is concerning;
119
+ a medium-CVSS finding WITH KEV listing is more concerning because
120
+ attackers are actively using it. The skill currently emits CVSS-derived
121
+ severity only; KEV enrichment is a planned addition via the
122
+ `mcp__pen-tester-cve` MCP server when wired.
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python3
2
+ """auditing-npm-dependencies — wrap `npm audit --json` into canonical Findings.
3
+
4
+ Walks a Node.js project, runs `npm audit --json` in the target directory, parses
5
+ both the v1 (npm 6) and v2 (npm 7+) audit output shapes, and emits Findings via
6
+ lib/finding.py. Output formats (json/jsonl/markdown) and exit-code semantics are
7
+ shared with the rest of the penetration-tester v3 pack via lib/report.py.
8
+
9
+ The scanner deduplicates per-CVE across direct + transitive dependency paths,
10
+ classifies each finding as direct vs transitive (impacts remediation strategy),
11
+ and maps npm's severity vocabulary (info/low/moderate/high/critical) onto the
12
+ shared Severity enum.
13
+
14
+ Usage:
15
+ python3 audit_npm.py PATH [--output FILE] [--format json|jsonl|markdown]
16
+ [--min-severity sev] [--include-dev] [--no-cache]
17
+ [--json-only]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ # --- Make lib/ importable regardless of CWD ----------------------------------
31
+ _LIB_ROOT = Path(__file__).resolve().parents[3]
32
+ sys.path.insert(0, str(_LIB_ROOT))
33
+
34
+ from lib.finding import Finding, Severity # noqa: E402
35
+ from lib import report # noqa: E402
36
+
37
+
38
+ SKILL_ID = "auditing-npm-dependencies"
39
+ CATEGORY = "dependency-vulnerability"
40
+ CWE_DEFAULT = "CWE-1104" # Use of unmaintained / vulnerable third-party component
41
+
42
+
43
+ # --- npm invocation ----------------------------------------------------------
44
+
45
+
46
+ def _npm_present() -> bool:
47
+ """Probe for an npm binary on PATH."""
48
+ return shutil.which("npm") is not None
49
+
50
+
51
+ def _project_is_node(directory: Path) -> bool:
52
+ """A directory is a node project if it has package.json at the root."""
53
+ return (directory / "package.json").exists()
54
+
55
+
56
+ def _run_npm_audit(directory: Path, include_dev: bool, no_cache: bool) -> tuple[dict[str, Any] | None, str]:
57
+ """Run `npm audit --json` in directory, return (parsed_json_or_None, raw_stdout).
58
+
59
+ Returns parsed dict on success, None when audit failed or output was not JSON.
60
+ The raw stdout is always returned so the caller can attach it to an INFO
61
+ Finding for debugging.
62
+ """
63
+ cmd: list[str] = ["npm", "audit", "--json"]
64
+ if not include_dev:
65
+ cmd.append("--omit=dev")
66
+ if no_cache:
67
+ # npm 8+ honors --no-audit; older npm ignored. Combined with cache flag.
68
+ cmd.append("--no-fund")
69
+ try:
70
+ proc = subprocess.run( # noqa: S603 — npm is the audit tool
71
+ cmd,
72
+ cwd=str(directory),
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=180,
76
+ check=False,
77
+ )
78
+ except subprocess.TimeoutExpired:
79
+ return None, "npm audit timed out after 180s"
80
+ except FileNotFoundError:
81
+ return None, "npm binary not found"
82
+
83
+ stdout = proc.stdout or ""
84
+ # npm audit exits non-zero when vulns are found — that's expected; we
85
+ # parse the JSON regardless.
86
+ try:
87
+ data = json.loads(stdout)
88
+ except json.JSONDecodeError:
89
+ return None, stdout
90
+ return data, stdout
91
+
92
+
93
+ # --- npm v2 schema parser (npm 7+) -------------------------------------------
94
+
95
+
96
+ def _parse_v2(data: dict[str, Any], target_label: str) -> list[Finding]:
97
+ """Parse npm 7+ audit output. Schema is `vulnerabilities.<pkg> → record`.
98
+
99
+ Direct vs transitive distinction comes from `record.via`: if `via` contains
100
+ string entries (other package names), this is a transitive vulnerability
101
+ surfaced through those parents. If `via` contains dict entries, the package
102
+ itself is the source (direct vuln in the named package).
103
+ """
104
+ findings: list[Finding] = []
105
+ vulns: dict[str, Any] = data.get("vulnerabilities", {}) or {}
106
+
107
+ for pkg_name, record in vulns.items():
108
+ severity_str = str(record.get("severity", "info"))
109
+ severity = Severity.from_npm_audit(severity_str)
110
+
111
+ via = record.get("via", [])
112
+ is_direct = any(isinstance(item, dict) for item in via)
113
+ source_label = "direct" if is_direct else "transitive"
114
+
115
+ # Extract CVE / GHSA from any dict entries in `via`.
116
+ cve_id: str | None = None
117
+ ghsa_id: str | None = None
118
+ title_summary: str | None = None
119
+ urls: list[str] = []
120
+ for item in via:
121
+ if not isinstance(item, dict):
122
+ continue
123
+ raw_source = item.get("source")
124
+ # npm audit v2 sometimes emits `source` as a list, sometimes as a
125
+ # scalar (string advisory ID or integer advisory ID). Normalize.
126
+ source_list: list[Any] = []
127
+ if isinstance(raw_source, list):
128
+ source_list = raw_source
129
+ elif raw_source is not None:
130
+ source_list = [raw_source]
131
+ for src in source_list:
132
+ if isinstance(src, str) and src.upper().startswith("CVE-"):
133
+ cve_id = cve_id or src
134
+ if isinstance(src, str) and src.upper().startswith("GHSA-"):
135
+ ghsa_id = ghsa_id or src
136
+ cve_id = cve_id or item.get("cve") or None
137
+ # When source is itself a GHSA string, surface it as ghsa_id.
138
+ source_str = raw_source if isinstance(raw_source, str) else None
139
+ ghsa_id = ghsa_id or item.get("ghsa") or source_str or None
140
+ title_summary = title_summary or item.get("title")
141
+ url = item.get("url")
142
+ if url:
143
+ urls.append(url)
144
+
145
+ affected_range = str(record.get("range", "unknown"))
146
+ fix_available = record.get("fixAvailable", False)
147
+ if isinstance(fix_available, dict):
148
+ fix_version_str = f"{fix_available.get('name', pkg_name)}@{fix_available.get('version', '?')}"
149
+ elif fix_available is True:
150
+ fix_version_str = "non-breaking upgrade available (npm audit fix)"
151
+ else:
152
+ fix_version_str = "no fix available"
153
+
154
+ title = title_summary or f"npm vulnerability in {pkg_name} ({severity_str})"
155
+
156
+ detail_lines = [
157
+ f"Affected package: {pkg_name}",
158
+ f"Affected range: {affected_range}",
159
+ f"Dependency relationship: {source_label}",
160
+ f"npm severity: {severity_str}",
161
+ ]
162
+ if cve_id:
163
+ detail_lines.append(f"CVE: {cve_id}")
164
+ if ghsa_id:
165
+ detail_lines.append(f"GHSA: {ghsa_id}")
166
+
167
+ if is_direct:
168
+ remediation = (
169
+ f"1. Run `npm audit fix` in {target_label}.\n"
170
+ f"2. If fix requires a semver-major bump, evaluate breaking changes "
171
+ f"and decide whether to upgrade.\n"
172
+ f"3. Commit the updated package-lock.json."
173
+ )
174
+ else:
175
+ remediation = (
176
+ f"1. Run `npm ls {pkg_name}` to identify which parent(s) pull in "
177
+ f"the vulnerable version.\n"
178
+ f"2. Check whether upgrading the parent picks up the fix.\n"
179
+ f"3. If not, add a root-level `overrides` block for {pkg_name} "
180
+ f"pinning to the fix version (requires npm 8.3+)."
181
+ )
182
+
183
+ if fix_version_str.startswith("no fix"):
184
+ remediation += (
185
+ "\n\nNO FIX AVAILABLE — subscribe to GHSA notifications and "
186
+ "consider vendoring + patching or replacing the package."
187
+ )
188
+
189
+ evidence_items: list[tuple[str, Any]] = [
190
+ ("package", pkg_name),
191
+ ("range", affected_range),
192
+ ("relationship", source_label),
193
+ ("fix", fix_version_str),
194
+ ]
195
+ if cve_id:
196
+ evidence_items.append(("cve", cve_id))
197
+ if ghsa_id:
198
+ evidence_items.append(("ghsa", ghsa_id))
199
+
200
+ # Bump severity to HIGH if no fix and originally moderate+ — operator
201
+ # has limited remediation surface.
202
+ if fix_version_str.startswith("no fix") and severity.numeric >= 3:
203
+ severity = max(severity, Severity.HIGH, key=lambda s: s.numeric)
204
+
205
+ findings.append(
206
+ Finding(
207
+ skill_id=SKILL_ID,
208
+ title=title,
209
+ severity=severity,
210
+ target=f"{target_label}::{pkg_name}",
211
+ detail="\n".join(detail_lines),
212
+ remediation=remediation,
213
+ cve_id=cve_id,
214
+ cwe_id=CWE_DEFAULT,
215
+ references=tuple(urls or []),
216
+ evidence=tuple(evidence_items),
217
+ )
218
+ )
219
+
220
+ return findings
221
+
222
+
223
+ # --- npm v1 schema parser (npm 6) --------------------------------------------
224
+
225
+
226
+ def _parse_v1(data: dict[str, Any], target_label: str) -> list[Finding]:
227
+ """Parse npm 6 audit output. Schema is `advisories.<id> → record`."""
228
+ findings: list[Finding] = []
229
+ advisories: dict[str, Any] = data.get("advisories", {}) or {}
230
+
231
+ for adv_id, record in advisories.items():
232
+ severity_str = str(record.get("severity", "info"))
233
+ severity = Severity.from_npm_audit(severity_str)
234
+ pkg_name = record.get("module_name", "<unknown>")
235
+ affected_range = record.get("vulnerable_versions", "unknown")
236
+ patched_versions = record.get("patched_versions", "")
237
+ cves = record.get("cves") or []
238
+ cve_id = cves[0] if cves else None
239
+ title = record.get("title") or f"npm advisory {adv_id} in {pkg_name}"
240
+ url = record.get("url", "")
241
+
242
+ detail_lines = [
243
+ f"Affected package: {pkg_name}",
244
+ f"Affected versions: {affected_range}",
245
+ f"npm severity: {severity_str}",
246
+ f"Advisory ID: {adv_id}",
247
+ ]
248
+ if cve_id:
249
+ detail_lines.append(f"CVE: {cve_id}")
250
+
251
+ remediation = (
252
+ f"1. Upgrade {pkg_name} to a version matching `{patched_versions}`.\n"
253
+ "2. Run `npm install` to refresh package-lock.json.\n"
254
+ "3. Re-run audit to confirm the finding is resolved."
255
+ )
256
+
257
+ findings.append(
258
+ Finding(
259
+ skill_id=SKILL_ID,
260
+ title=title,
261
+ severity=severity,
262
+ target=f"{target_label}::{pkg_name}",
263
+ detail="\n".join(detail_lines),
264
+ remediation=remediation,
265
+ cve_id=cve_id,
266
+ cwe_id=CWE_DEFAULT,
267
+ references=(url,) if url else (),
268
+ evidence=(
269
+ ("package", pkg_name),
270
+ ("affected", affected_range),
271
+ ("patched", patched_versions),
272
+ ("advisory_id", adv_id),
273
+ ),
274
+ )
275
+ )
276
+
277
+ return findings
278
+
279
+
280
+ # --- Operational helpers -----------------------------------------------------
281
+
282
+
283
+ def _info_finding(title: str, detail: str, target: str) -> Finding:
284
+ return Finding(
285
+ skill_id=SKILL_ID,
286
+ title=title,
287
+ severity=Severity.INFO,
288
+ target=target,
289
+ detail=detail,
290
+ remediation="Operational issue; no security action required.",
291
+ references=(),
292
+ evidence=(),
293
+ )
294
+
295
+
296
+ def audit_directory(directory: Path, include_dev: bool, no_cache: bool, json_only: bool) -> tuple[list[Finding], str]:
297
+ """Run an audit, returning (findings, raw_stdout)."""
298
+ if not _npm_present():
299
+ return [
300
+ _info_finding(
301
+ "npm not installed",
302
+ "The npm binary was not found on PATH; cannot run audit.",
303
+ str(directory),
304
+ )
305
+ ], ""
306
+ if not _project_is_node(directory):
307
+ return [
308
+ _info_finding(
309
+ "target is not a Node project",
310
+ f"No package.json at {directory}; skipping npm audit.",
311
+ str(directory),
312
+ )
313
+ ], ""
314
+
315
+ data, raw = _run_npm_audit(directory, include_dev, no_cache)
316
+ if json_only:
317
+ sys.stdout.write(raw)
318
+ return [], raw
319
+ if data is None:
320
+ return [
321
+ _info_finding(
322
+ "npm audit returned non-JSON output",
323
+ f"Raw stdout (first 500 chars): {raw[:500]}",
324
+ str(directory),
325
+ )
326
+ ], raw
327
+
328
+ target_label = directory.name or str(directory)
329
+ if "vulnerabilities" in data:
330
+ findings = _parse_v2(data, target_label)
331
+ elif "advisories" in data:
332
+ findings = _parse_v1(data, target_label)
333
+ else:
334
+ findings = []
335
+
336
+ if not findings:
337
+ findings = [
338
+ _info_finding(
339
+ "no npm vulnerabilities found",
340
+ "npm audit reported a clean dependency tree.",
341
+ str(directory),
342
+ )
343
+ ]
344
+ return findings, raw
345
+
346
+
347
+ # --- CLI ---------------------------------------------------------------------
348
+
349
+
350
+ def _build_arg_parser() -> argparse.ArgumentParser:
351
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
352
+ p.add_argument("path", help="Path to Node project root (contains package.json)")
353
+ p.add_argument("--output", default=None, help="Write findings to FILE (default: stdout)")
354
+ p.add_argument(
355
+ "--format",
356
+ default="markdown",
357
+ choices=["json", "jsonl", "markdown"],
358
+ help="Output format (default: markdown)",
359
+ )
360
+ p.add_argument(
361
+ "--min-severity",
362
+ default="info",
363
+ choices=["info", "low", "medium", "high", "critical"],
364
+ help="Filter out findings below this severity (default: info — emit all)",
365
+ )
366
+ p.add_argument(
367
+ "--include-dev",
368
+ action="store_true",
369
+ help="Audit devDependencies too (default: prod only)",
370
+ )
371
+ p.add_argument(
372
+ "--no-cache",
373
+ action="store_true",
374
+ help="Disable npm fund cache hint (slower; fresher data)",
375
+ )
376
+ p.add_argument(
377
+ "--json-only",
378
+ action="store_true",
379
+ help="Print raw npm audit --json and exit (debug)",
380
+ )
381
+ return p
382
+
383
+
384
+ def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
385
+ floor = Severity(min_sev).numeric
386
+ return [f for f in findings if f.severity.numeric >= floor]
387
+
388
+
389
+ def main(argv: list[str] | None = None) -> int:
390
+ args = _build_arg_parser().parse_args(argv)
391
+ directory = Path(args.path).resolve()
392
+
393
+ findings, _raw = audit_directory(
394
+ directory,
395
+ include_dev=args.include_dev,
396
+ no_cache=args.no_cache,
397
+ json_only=args.json_only,
398
+ )
399
+ if args.json_only:
400
+ return 0
401
+
402
+ findings = _filter_min_severity(findings, args.min_severity)
403
+ report.emit(findings, args.output, args.format, scan_target=str(directory))
404
+ return report.exit_code(findings)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ sys.exit(main())