ohdear-npm-audit 1.2.0 → 1.4.0
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.
- package/README.md +23 -4
- package/dist/bin.js +1 -1
- package/dist/generate.js +32 -15
- package/dist/handler.js +30 -2
- package/dist/next.js +44 -8
- package/dist/types.d.ts +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,13 @@ export default withOhDearHealth({
|
|
|
33
33
|
});
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The wrapper generates the manifest automatically when the config is evaluated (before the build starts). It also checks for critical vulnerabilities at build time and logs
|
|
36
|
+
The wrapper generates the manifest automatically when the config is evaluated (before the build starts). It also checks for critical vulnerabilities at build time and logs enriched results including dependency chains and vulnerable version ranges:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ohdear-npm-audit: 1 critical vulnerabilities:
|
|
40
|
+
- form-data@1.0.0 (via axios → form-data): unsafe random function
|
|
41
|
+
vulnerable: <1.0.1 — https://github.com/advisories/GHSA-xxx
|
|
42
|
+
```
|
|
37
43
|
|
|
38
44
|
```js
|
|
39
45
|
withOhDearHealth(nextConfig, {
|
|
@@ -72,8 +78,8 @@ This must match the secret configured in Oh Dear for your application health che
|
|
|
72
78
|
|
|
73
79
|
## How it works
|
|
74
80
|
|
|
75
|
-
1. **Build time** — The CLI or Next.js wrapper runs `pnpm list` / `npm ls` to extract all production dependencies (including transitive) and writes them to a JSON manifest. When using the Next.js wrapper, a build-time vulnerability check is
|
|
76
|
-
2. **Runtime** — On each GET request, the handler verifies the Oh Dear secret header, POSTs the manifest to the [npm bulk advisory API](https://docs.npmjs.com/about-audit-reports), filters for critical severity, and returns the result in the [Oh Dear health check format](https://ohdear.app/docs/features/application-health-monitoring)
|
|
81
|
+
1. **Build time** — The CLI or Next.js wrapper runs `pnpm list` / `npm ls` to extract all production dependencies (including transitive) and writes them to a JSON manifest that includes a reverse dependency map for tracing dependency chains. When using the Next.js wrapper, a build-time vulnerability check is performed and results are logged with the full dependency chain (e.g. `axios → form-data`) and vulnerable version ranges
|
|
82
|
+
2. **Runtime** — On each GET request, the handler verifies the Oh Dear secret header, POSTs the manifest to the [npm bulk advisory API](https://docs.npmjs.com/about-audit-reports), filters for critical severity, and returns the result in the [Oh Dear health check format](https://ohdear.app/docs/features/application-health-monitoring). Each vulnerability includes installed versions, vulnerable version range, and the dependency chain
|
|
77
83
|
|
|
78
84
|
### Response format
|
|
79
85
|
|
|
@@ -93,6 +99,19 @@ This must match the secret configured in Oh Dear for your application health che
|
|
|
93
99
|
}
|
|
94
100
|
```
|
|
95
101
|
|
|
102
|
+
When vulnerabilities are found, `meta.vulnerabilities` contains an array of:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"package": "form-data",
|
|
107
|
+
"installedVersions": ["1.0.0"],
|
|
108
|
+
"title": "form-data uses unsafe random function",
|
|
109
|
+
"url": "https://github.com/advisories/GHSA-xxx",
|
|
110
|
+
"vulnerableVersions": "<1.0.1",
|
|
111
|
+
"dependencyChain": ["axios", "form-data"]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
96
115
|
`status` is `"ok"` when there are no critical vulnerabilities, `"warning"` when the npm advisory API is unreachable, `"failed"` otherwise.
|
|
97
116
|
|
|
98
117
|
## API
|
|
@@ -132,7 +151,7 @@ Defaults to `deps-manifest.json` in the current directory. Detects the package m
|
|
|
132
151
|
```
|
|
133
152
|
src/
|
|
134
153
|
├── types.ts # Shared types (DepsManifest, Vulnerability, HealthCheckResponse)
|
|
135
|
-
├── generate.ts # Manifest generation
|
|
154
|
+
├── generate.ts # Manifest + reverse dep map generation (build-time, execSync)
|
|
136
155
|
├── handler.ts # createHealthHandler factory — main export "."
|
|
137
156
|
├── next.ts # withOhDearHealth wrapper — export "./next"
|
|
138
157
|
└── bin.ts # CLI entry point — bin "ohdear-deps-manifest"
|
package/dist/bin.js
CHANGED
|
@@ -12,4 +12,4 @@ if (outputIdx !== -1 && args[outputIdx + 1]) {
|
|
|
12
12
|
const cwd = process.cwd();
|
|
13
13
|
const outputPath = (0, node_path_1.resolve)(cwd, output);
|
|
14
14
|
const manifest = (0, generate_js_1.writeManifest)(outputPath, cwd);
|
|
15
|
-
console.log(
|
|
15
|
+
console.log(`${output}: ${Object.keys(manifest.packages).length} packages written`);
|
package/dist/generate.js
CHANGED
|
@@ -14,7 +14,7 @@ function detectPackageManager(cwd) {
|
|
|
14
14
|
}
|
|
15
15
|
return "npm";
|
|
16
16
|
}
|
|
17
|
-
function walkDeps(deps, acc) {
|
|
17
|
+
function walkDeps(deps, acc, reverseDeps, parent) {
|
|
18
18
|
if (!deps)
|
|
19
19
|
return;
|
|
20
20
|
for (const [name, info] of Object.entries(deps)) {
|
|
@@ -23,7 +23,12 @@ function walkDeps(deps, acc) {
|
|
|
23
23
|
if (!acc[name])
|
|
24
24
|
acc[name] = new Set();
|
|
25
25
|
acc[name].add(info.version);
|
|
26
|
-
|
|
26
|
+
if (!reverseDeps[name])
|
|
27
|
+
reverseDeps[name] = new Set();
|
|
28
|
+
if (parent !== "root") {
|
|
29
|
+
reverseDeps[name].add(parent);
|
|
30
|
+
}
|
|
31
|
+
walkDeps(info.dependencies, acc, reverseDeps, name);
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
// Pipe stdout to a temp file to avoid maxBuffer limits on large dependency
|
|
@@ -36,7 +41,11 @@ function execToFile(command, cwd) {
|
|
|
36
41
|
cwd,
|
|
37
42
|
stdio: "ignore",
|
|
38
43
|
});
|
|
39
|
-
|
|
44
|
+
const content = (0, node_fs_1.readFileSync)(tmp, "utf-8");
|
|
45
|
+
if (!content.trim()) {
|
|
46
|
+
throw new Error(`ohdear-npm-audit: "${command}" produced no output. Is the package manager installed?`);
|
|
47
|
+
}
|
|
48
|
+
return content;
|
|
40
49
|
}
|
|
41
50
|
finally {
|
|
42
51
|
try {
|
|
@@ -49,24 +58,32 @@ function execToFile(command, cwd) {
|
|
|
49
58
|
}
|
|
50
59
|
function generateManifest(cwd) {
|
|
51
60
|
const pm = detectPackageManager(cwd);
|
|
61
|
+
console.log(`ohdear-npm-audit: detected package manager → ${pm}`);
|
|
52
62
|
let raw;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
const command = pm === "pnpm"
|
|
64
|
+
? "pnpm list --json --prod --depth Infinity"
|
|
65
|
+
: "npm ls --json --omit=dev --all";
|
|
66
|
+
raw = execToFile(command, cwd);
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error(`ohdear-npm-audit: failed to parse "${command}" output (${raw.length} bytes). First 200 chars: ${raw.slice(0, 200)}`);
|
|
60
73
|
}
|
|
61
|
-
const parsed = JSON.parse(raw);
|
|
62
74
|
const tree = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
63
75
|
const acc = {};
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
const reverseAcc = {};
|
|
77
|
+
walkDeps(tree.dependencies, acc, reverseAcc, "root");
|
|
78
|
+
const packages = {};
|
|
66
79
|
for (const [name, versions] of Object.entries(acc)) {
|
|
67
|
-
|
|
80
|
+
packages[name] = [...versions];
|
|
68
81
|
}
|
|
69
|
-
|
|
82
|
+
const reverseDeps = {};
|
|
83
|
+
for (const [name, parents] of Object.entries(reverseAcc)) {
|
|
84
|
+
reverseDeps[name] = [...parents];
|
|
85
|
+
}
|
|
86
|
+
return { packages, reverseDeps };
|
|
70
87
|
}
|
|
71
88
|
function writeManifest(outputPath, cwd) {
|
|
72
89
|
const manifest = generateManifest(cwd);
|
package/dist/handler.js
CHANGED
|
@@ -3,6 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createHealthHandler = createHealthHandler;
|
|
4
4
|
const NPM_BULK_ADVISORY_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk";
|
|
5
5
|
const FETCH_TIMEOUT_MS = 8_000;
|
|
6
|
+
/** BFS from pkg up through reverseMap to find the shortest chain to a root dep. */
|
|
7
|
+
function buildChain(pkg, reverseMap) {
|
|
8
|
+
const queue = [[pkg]];
|
|
9
|
+
const visited = new Set([pkg]);
|
|
10
|
+
while (queue.length > 0) {
|
|
11
|
+
const path = queue.shift();
|
|
12
|
+
const current = path[path.length - 1];
|
|
13
|
+
const parents = reverseMap[current];
|
|
14
|
+
if (!parents || parents.length === 0)
|
|
15
|
+
return [...path].reverse();
|
|
16
|
+
for (const parent of parents) {
|
|
17
|
+
if (visited.has(parent))
|
|
18
|
+
continue;
|
|
19
|
+
visited.add(parent);
|
|
20
|
+
queue.push([...path, parent]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return [pkg];
|
|
24
|
+
}
|
|
6
25
|
function makeWarningResponse(message) {
|
|
7
26
|
return {
|
|
8
27
|
finishedAt: Math.floor(Date.now() / 1000),
|
|
@@ -36,7 +55,7 @@ function createHealthHandler(manifest, options) {
|
|
|
36
55
|
res = await fetch(NPM_BULK_ADVISORY_URL, {
|
|
37
56
|
method: "POST",
|
|
38
57
|
headers: { "Content-Type": "application/json" },
|
|
39
|
-
body: JSON.stringify(manifest),
|
|
58
|
+
body: JSON.stringify(manifest.packages),
|
|
40
59
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
41
60
|
});
|
|
42
61
|
}
|
|
@@ -60,7 +79,16 @@ function createHealthHandler(manifest, options) {
|
|
|
60
79
|
for (const [pkg, entries] of Object.entries(advisories)) {
|
|
61
80
|
for (const entry of entries) {
|
|
62
81
|
if (entry.severity === "critical") {
|
|
63
|
-
|
|
82
|
+
const versions = manifest.packages[pkg] ?? [];
|
|
83
|
+
const vuln = {
|
|
84
|
+
package: pkg,
|
|
85
|
+
installedVersions: versions,
|
|
86
|
+
title: entry.title,
|
|
87
|
+
url: entry.url,
|
|
88
|
+
vulnerableVersions: entry.vulnerable_versions ?? "",
|
|
89
|
+
dependencyChain: buildChain(pkg, manifest.reverseDeps),
|
|
90
|
+
};
|
|
91
|
+
critical.push(vuln);
|
|
64
92
|
}
|
|
65
93
|
}
|
|
66
94
|
}
|
package/dist/next.js
CHANGED
|
@@ -46,6 +46,30 @@ function hasRecentLock(output) {
|
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the dependency chain from a package back to a direct dependency
|
|
51
|
+
* using the reverse dependency map.
|
|
52
|
+
*/
|
|
53
|
+
function buildChainScript() {
|
|
54
|
+
return [
|
|
55
|
+
`function buildChain(pkg, reverseMap) {`,
|
|
56
|
+
` const queue = [[pkg]];`,
|
|
57
|
+
` const visited = new Set([pkg]);`,
|
|
58
|
+
` while (queue.length > 0) {`,
|
|
59
|
+
` const path = queue.shift();`,
|
|
60
|
+
` const current = path[path.length - 1];`,
|
|
61
|
+
` const parents = reverseMap[current];`,
|
|
62
|
+
` if (!parents || parents.length === 0) return path.slice().reverse();`,
|
|
63
|
+
` for (const parent of parents) {`,
|
|
64
|
+
` if (visited.has(parent)) continue;`,
|
|
65
|
+
` visited.add(parent);`,
|
|
66
|
+
` queue.push([...path, parent]);`,
|
|
67
|
+
` }`,
|
|
68
|
+
` }`,
|
|
69
|
+
` return [pkg];`,
|
|
70
|
+
`}`,
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
49
73
|
/**
|
|
50
74
|
* Fire-and-forget subprocess to check the manifest against the npm advisory
|
|
51
75
|
* API. Output is inherited so logs appear in the build output. Does not
|
|
@@ -55,11 +79,14 @@ function checkVulnerabilities(manifestPath) {
|
|
|
55
79
|
const safePath = JSON.stringify(manifestPath);
|
|
56
80
|
const script = [
|
|
57
81
|
`const fs = require("fs");`,
|
|
58
|
-
`const
|
|
82
|
+
`const data = JSON.parse(fs.readFileSync(${safePath}, "utf-8"));`,
|
|
83
|
+
`const packages = data.packages;`,
|
|
84
|
+
`const reverseMap = data.reverseDeps;`,
|
|
85
|
+
buildChainScript(),
|
|
59
86
|
`fetch("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {`,
|
|
60
87
|
` method: "POST",`,
|
|
61
88
|
` headers: { "Content-Type": "application/json" },`,
|
|
62
|
-
` body: JSON.stringify(
|
|
89
|
+
` body: JSON.stringify(packages),`,
|
|
63
90
|
` signal: AbortSignal.timeout(8000),`,
|
|
64
91
|
`})`,
|
|
65
92
|
`.then(r => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })`,
|
|
@@ -67,17 +94,26 @@ function checkVulnerabilities(manifestPath) {
|
|
|
67
94
|
` const crits = [];`,
|
|
68
95
|
` for (const [pkg, entries] of Object.entries(data)) {`,
|
|
69
96
|
` for (const e of entries) {`,
|
|
70
|
-
` if (e.severity
|
|
97
|
+
` if (e.severity !== "critical") continue;`,
|
|
98
|
+
` const versions = packages[pkg] || [];`,
|
|
99
|
+
` const chain = buildChain(pkg, reverseMap);`,
|
|
100
|
+
` const versionStr = versions.join(", ");`,
|
|
101
|
+
` const chainStr = chain.length > 1 ? " (via " + chain.join(" \\u2192 ") + ")" : "";`,
|
|
102
|
+
` let line = " - " + pkg + "@" + versionStr + chainStr + ": " + e.title;`,
|
|
103
|
+
` if (e.vulnerable_versions) {`,
|
|
104
|
+
` line += "\\n vulnerable: " + e.vulnerable_versions + " \\u2014 " + e.url;`,
|
|
105
|
+
` }`,
|
|
106
|
+
` crits.push(line);`,
|
|
71
107
|
` }`,
|
|
72
108
|
` }`,
|
|
73
109
|
` if (crits.length > 0) {`,
|
|
74
110
|
` console.warn("ohdear-npm-audit: " + crits.length + " critical vulnerabilities:");`,
|
|
75
|
-
` crits.forEach(c => console.warn(
|
|
111
|
+
` crits.forEach(c => console.warn(c));`,
|
|
76
112
|
` } else {`,
|
|
77
|
-
` console.log("ohdear-npm-audit: no critical vulnerabilities
|
|
113
|
+
` console.log("ohdear-npm-audit: no critical vulnerabilities \\u2713");`,
|
|
78
114
|
` }`,
|
|
79
115
|
`})`,
|
|
80
|
-
`.catch(
|
|
116
|
+
`.catch(err => console.warn("ohdear-npm-audit: build-time vulnerability check failed:", err.message || err));`,
|
|
81
117
|
].join("\n");
|
|
82
118
|
const child = (0, node_child_process_1.spawn)("node", ["-e", script], {
|
|
83
119
|
stdio: "inherit",
|
|
@@ -93,7 +129,7 @@ function withOhDearHealth(nextConfig, options) {
|
|
|
93
129
|
return nextConfig;
|
|
94
130
|
try {
|
|
95
131
|
const manifest = (0, generate_js_1.writeManifest)(output, cwd);
|
|
96
|
-
console.log(`ohdear-npm-audit: ${Object.keys(manifest).length} packages written → ${output}`);
|
|
132
|
+
console.log(`ohdear-npm-audit: ${Object.keys(manifest.packages).length} packages written → ${output}`);
|
|
97
133
|
const routePath = deriveRoutePath((0, node_path_1.relative)(cwd, output));
|
|
98
134
|
const domain = process.env.VERCEL_PROJECT_PRODUCTION_URL;
|
|
99
135
|
if (routePath && domain) {
|
|
@@ -113,7 +149,7 @@ function withOhDearHealth(nextConfig, options) {
|
|
|
113
149
|
}
|
|
114
150
|
}
|
|
115
151
|
catch (err) {
|
|
116
|
-
console.error("ohdear-npm-audit: failed to generate dependency manifest.");
|
|
152
|
+
console.error("ohdear-npm-audit: failed to generate dependency manifest.", err instanceof Error ? err.message : err);
|
|
117
153
|
throw err;
|
|
118
154
|
}
|
|
119
155
|
return nextConfig;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface DepsManifest {
|
|
2
|
+
packages: Record<string, string[]>;
|
|
3
|
+
reverseDeps: Record<string, string[]>;
|
|
4
|
+
}
|
|
2
5
|
export interface Vulnerability {
|
|
3
6
|
package: string;
|
|
7
|
+
installedVersions: string[];
|
|
4
8
|
title: string;
|
|
5
9
|
url: string;
|
|
10
|
+
vulnerableVersions: string;
|
|
11
|
+
dependencyChain?: string[];
|
|
6
12
|
}
|
|
7
13
|
export interface HealthCheckResult {
|
|
8
14
|
name: string;
|