ohdear-npm-audit 0.1.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 ADDED
@@ -0,0 +1,158 @@
1
+ # ohdear-npm-audit
2
+
3
+ [Oh Dear Application Health](https://ohdear.app/docs/features/application-health-monitoring) check for critical npm vulnerabilities.
4
+
5
+ Designed for serverless platforms (Vercel, Netlify...) where you can't run `npm audit` at runtime. Instead, a dependency manifest is generated at build time and checked against the npm advisory API on each health check request.
6
+
7
+ > **Disclaimer:** This package is not affiliated with or endorsed by [Oh Dear](https://ohdear.app/). It is a community-built integration that implements the [Oh Dear application health check protocol](https://ohdear.app/docs/features/application-health-monitoring).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install ohdear-npm-audit
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ ### 1. Generate the dependency manifest at build time
18
+
19
+ **Option A — CLI (works with any framework)**
20
+
21
+ ```json
22
+ "build": "ohdear-deps-manifest --output src/app/api/health/deps-manifest.json && next build"
23
+ ```
24
+
25
+ **Option B — Next.js config wrapper**
26
+
27
+ ```js
28
+ // next.config.mjs
29
+ import { withOhDearHealth } from "ohdear-npm-audit/next";
30
+
31
+ export default withOhDearHealth({
32
+ // your Next.js config
33
+ });
34
+ ```
35
+
36
+ The wrapper generates the manifest automatically when the config is evaluated (before the build starts). You can customize the output path:
37
+
38
+ ```js
39
+ withOhDearHealth(nextConfig, {
40
+ output: "src/app/api/health/deps-manifest.json", // default
41
+ });
42
+ ```
43
+
44
+ ### 2. Create the health check route
45
+
46
+ ```ts
47
+ // src/app/api/health/route.ts (Next.js App Router)
48
+ import { createHealthHandler } from "ohdear-npm-audit";
49
+ import manifest from "./deps-manifest.json" with { type: "json" };
50
+
51
+ export const GET = createHealthHandler(manifest);
52
+ ```
53
+
54
+ ### 3. Set the environment variable
55
+
56
+ ```
57
+ OHDEAR_HEALTH_SECRET=your-secret-here
58
+ ```
59
+
60
+ This must match the secret configured in Oh Dear for your application health check.
61
+
62
+ ### 4. Add to .gitignore
63
+
64
+ ```gitignore
65
+ **/deps-manifest.json
66
+ ```
67
+
68
+ ## How it works
69
+
70
+ 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
71
+ 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)
72
+
73
+ ### Response format
74
+
75
+ ```json
76
+ {
77
+ "finishedAt": 1708300000,
78
+ "checkResults": [
79
+ {
80
+ "name": "npm_vulnerabilities",
81
+ "label": "NPM Critical Vulnerabilities",
82
+ "status": "ok",
83
+ "notificationMessage": "No critical npm vulnerabilities found.",
84
+ "shortSummary": "0 critical",
85
+ "meta": {}
86
+ }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ `status` is `"ok"` when there are no critical vulnerabilities, `"warning"` when the npm advisory API is unreachable, `"failed"` otherwise.
92
+
93
+ ## API
94
+
95
+ ### `createHealthHandler(manifest, options?)`
96
+
97
+ Returns a `(request: Request) => Promise<Response>` handler compatible with any framework that uses the Web Request/Response API (Next.js, Hono, SvelteKit...).
98
+
99
+ | Option | Default | Description |
100
+ |--------|---------|-------------|
101
+ | `secretEnvVar` | `"OHDEAR_HEALTH_SECRET"` | Environment variable name for the secret |
102
+ | `secretHeader` | `"oh-dear-health-check-secret"` | Request header name for the secret |
103
+
104
+ ### `withOhDearHealth(nextConfig, options?)`
105
+
106
+ Next.js config wrapper that generates the manifest before the build.
107
+
108
+ | Option | Default | Description |
109
+ |--------|---------|-------------|
110
+ | `output` | `"src/app/api/health/deps-manifest.json"` | Output path for the manifest |
111
+
112
+ ### CLI `ohdear-deps-manifest`
113
+
114
+ ```bash
115
+ ohdear-deps-manifest --output path/to/deps-manifest.json
116
+ ```
117
+
118
+ Defaults to `deps-manifest.json` in the current directory. Detects the package manager (pnpm or npm) from the lockfile.
119
+
120
+ > **Note:** Yarn is not supported. Use pnpm or npm.
121
+
122
+ ## Contributing
123
+
124
+ ### Architecture
125
+
126
+ ```
127
+ src/
128
+ ├── types.ts # Shared types (DepsManifest, Vulnerability, HealthCheckResponse)
129
+ ├── generate.ts # Manifest generation logic (build-time, execSync)
130
+ ├── handler.ts # createHealthHandler factory — main export "."
131
+ ├── next.ts # withOhDearHealth wrapper — export "./next"
132
+ └── bin.ts # CLI entry point — bin "ohdear-deps-manifest"
133
+ ```
134
+
135
+ ### Package manager detection
136
+
137
+ The manifest generator detects the package manager from the lockfile in the project root:
138
+
139
+ - `pnpm-lock.yaml` → `pnpm list --json --prod --depth Infinity`
140
+ - Otherwise → `npm ls --json --omit=dev --all`
141
+ - `yarn.lock` → error (not supported)
142
+
143
+ ### Building
144
+
145
+ ```bash
146
+ pnpm install
147
+ pnpm build
148
+ ```
149
+
150
+ ESM only. TypeScript compiled with `tsc`, output in `dist/`.
151
+
152
+ ## Credits
153
+
154
+ Made by [Breaking Web](https://www.breakingweb.com?utm_source=github&utm_medium=readme&utm_campaign=ohdear-npm-audit).
155
+
156
+ ## License
157
+
158
+ MIT
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { writeManifest } from "./generate.js";
4
+ const args = process.argv.slice(2);
5
+ let output = "deps-manifest.json";
6
+ const outputIdx = args.indexOf("--output");
7
+ if (outputIdx !== -1 && args[outputIdx + 1]) {
8
+ output = args[outputIdx + 1];
9
+ }
10
+ const cwd = process.cwd();
11
+ const outputPath = resolve(cwd, output);
12
+ const manifest = writeManifest(outputPath, cwd);
13
+ console.log(`deps-manifest.json: ${Object.keys(manifest).length} packages written`);
@@ -0,0 +1,3 @@
1
+ import type { DepsManifest } from "./types.js";
2
+ export declare function generateManifest(cwd: string): DepsManifest;
3
+ export declare function writeManifest(outputPath: string, cwd: string): DepsManifest;
@@ -0,0 +1,63 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ function detectPackageManager(cwd) {
5
+ if (existsSync(resolve(cwd, "pnpm-lock.yaml")))
6
+ return "pnpm";
7
+ if (existsSync(resolve(cwd, "yarn.lock"))) {
8
+ throw new Error("ohdear-npm-audit: yarn is not supported. Use pnpm or npm.");
9
+ }
10
+ return "npm";
11
+ }
12
+ function walkDeps(deps, acc) {
13
+ if (!deps)
14
+ return;
15
+ for (const [name, info] of Object.entries(deps)) {
16
+ if (!info.version)
17
+ continue;
18
+ if (!acc[name])
19
+ acc[name] = new Set();
20
+ acc[name].add(info.version);
21
+ walkDeps(info.dependencies, acc);
22
+ }
23
+ }
24
+ // execSync throws on non-zero exit codes, but npm ls exits with code 1
25
+ // on missing/extraneous packages while still writing valid JSON to stdout.
26
+ function execCommand(command, cwd) {
27
+ try {
28
+ return execSync(command, { cwd, encoding: "utf-8" });
29
+ }
30
+ catch (err) {
31
+ const stdout = err.stdout;
32
+ if (stdout)
33
+ return stdout;
34
+ throw err;
35
+ }
36
+ }
37
+ export function generateManifest(cwd) {
38
+ const pm = detectPackageManager(cwd);
39
+ let raw;
40
+ switch (pm) {
41
+ case "pnpm":
42
+ raw = execCommand("pnpm list --json --prod --depth Infinity", cwd);
43
+ break;
44
+ case "npm":
45
+ raw = execCommand("npm ls --json --omit=dev --all", cwd);
46
+ break;
47
+ }
48
+ const parsed = JSON.parse(raw);
49
+ const tree = Array.isArray(parsed) ? parsed[0] : parsed;
50
+ const acc = {};
51
+ walkDeps(tree.dependencies, acc);
52
+ const manifest = {};
53
+ for (const [name, versions] of Object.entries(acc)) {
54
+ manifest[name] = [...versions];
55
+ }
56
+ return manifest;
57
+ }
58
+ export function writeManifest(outputPath, cwd) {
59
+ const manifest = generateManifest(cwd);
60
+ mkdirSync(dirname(outputPath), { recursive: true });
61
+ writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + "\n");
62
+ return manifest;
63
+ }
@@ -0,0 +1,9 @@
1
+ import type { DepsManifest } from "./types.js";
2
+ export type { DepsManifest, HealthCheckResponse, Vulnerability, } from "./types.js";
3
+ export interface CreateHealthHandlerOptions {
4
+ /** Environment variable name for the secret. Default: "OHDEAR_HEALTH_SECRET" */
5
+ secretEnvVar?: string;
6
+ /** Header name for the secret. Default: "oh-dear-health-check-secret" */
7
+ secretHeader?: string;
8
+ }
9
+ export declare function createHealthHandler(manifest: DepsManifest, options?: CreateHealthHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,82 @@
1
+ const NPM_BULK_ADVISORY_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk";
2
+ const FETCH_TIMEOUT_MS = 8_000;
3
+ function makeWarningResponse(message) {
4
+ return {
5
+ finishedAt: Math.floor(Date.now() / 1000),
6
+ checkResults: [
7
+ {
8
+ name: "npm_vulnerabilities",
9
+ label: "NPM Critical Vulnerabilities",
10
+ status: "warning",
11
+ notificationMessage: message,
12
+ shortSummary: "check error",
13
+ meta: {},
14
+ },
15
+ ],
16
+ };
17
+ }
18
+ export function createHealthHandler(manifest, options) {
19
+ const envVar = options?.secretEnvVar ?? "OHDEAR_HEALTH_SECRET";
20
+ const headerName = options?.secretHeader ?? "oh-dear-health-check-secret";
21
+ if (!process.env[envVar]) {
22
+ console.warn(`ohdear-npm-audit: env var ${envVar} is not set — all health check requests will be rejected with 401.`);
23
+ }
24
+ return async (request) => {
25
+ const secret = request.headers.get(headerName);
26
+ if (!secret || secret !== process.env[envVar]) {
27
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
28
+ }
29
+ let res;
30
+ try {
31
+ res = await fetch(NPM_BULK_ADVISORY_URL, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(manifest),
35
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
36
+ });
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof DOMException && err.name === "TimeoutError"
40
+ ? "npm advisory API timed out"
41
+ : "npm advisory API request failed";
42
+ return Response.json(makeWarningResponse(message));
43
+ }
44
+ if (!res.ok) {
45
+ return Response.json(makeWarningResponse(`npm advisory API returned HTTP ${res.status}`));
46
+ }
47
+ let advisories;
48
+ try {
49
+ advisories = await res.json();
50
+ }
51
+ catch {
52
+ return Response.json(makeWarningResponse("Failed to parse npm advisory response"));
53
+ }
54
+ const critical = [];
55
+ for (const [pkg, entries] of Object.entries(advisories)) {
56
+ for (const entry of entries) {
57
+ if (entry.severity === "critical") {
58
+ critical.push({ package: pkg, title: entry.title, url: entry.url });
59
+ }
60
+ }
61
+ }
62
+ const status = critical.length === 0 ? "ok" : "failed";
63
+ const shortSummary = `${critical.length} critical`;
64
+ const notificationMessage = critical.length === 0
65
+ ? "No critical npm vulnerabilities found."
66
+ : `Critical vulnerabilities in: ${critical.map((v) => v.package).join(", ")}`;
67
+ const body = {
68
+ finishedAt: Math.floor(Date.now() / 1000),
69
+ checkResults: [
70
+ {
71
+ name: "npm_vulnerabilities",
72
+ label: "NPM Critical Vulnerabilities",
73
+ status,
74
+ notificationMessage,
75
+ shortSummary,
76
+ meta: critical.length > 0 ? { vulnerabilities: critical } : {},
77
+ },
78
+ ],
79
+ };
80
+ return Response.json(body);
81
+ };
82
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export interface WithOhDearHealthOptions {
2
+ /** Output path for the manifest, relative to project root.
3
+ * Default: "src/app/api/health/deps-manifest.json" */
4
+ output?: string;
5
+ }
6
+ export declare function withOhDearHealth<T extends Record<string, unknown>>(nextConfig: T, options?: WithOhDearHealthOptions): T;
package/dist/next.js ADDED
@@ -0,0 +1,15 @@
1
+ import { resolve } from "node:path";
2
+ import { writeManifest } from "./generate.js";
3
+ export function withOhDearHealth(nextConfig, options) {
4
+ const cwd = process.cwd();
5
+ const output = resolve(cwd, options?.output ?? "src/app/api/health/deps-manifest.json");
6
+ try {
7
+ const manifest = writeManifest(output, cwd);
8
+ console.log(`ohdear-npm-audit: ${Object.keys(manifest).length} packages written → ${output}`);
9
+ }
10
+ catch (err) {
11
+ console.error("ohdear-npm-audit: failed to generate dependency manifest.");
12
+ throw err;
13
+ }
14
+ return nextConfig;
15
+ }
@@ -0,0 +1,18 @@
1
+ export type DepsManifest = Record<string, string[]>;
2
+ export interface Vulnerability {
3
+ package: string;
4
+ title: string;
5
+ url: string;
6
+ }
7
+ export interface HealthCheckResult {
8
+ name: string;
9
+ label: string;
10
+ status: "ok" | "warning" | "failed" | "crashed";
11
+ notificationMessage: string;
12
+ shortSummary: string;
13
+ meta: Record<string, unknown>;
14
+ }
15
+ export interface HealthCheckResponse {
16
+ finishedAt: number;
17
+ checkResults: HealthCheckResult[];
18
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "ohdear-npm-audit",
3
+ "version": "0.1.0",
4
+ "description": "Oh Dear Application Health check for npm audit critical vulnerabilities",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/handler.d.ts",
9
+ "import": "./dist/handler.js"
10
+ },
11
+ "./next": {
12
+ "types": "./dist/next.d.ts",
13
+ "import": "./dist/next.js"
14
+ }
15
+ },
16
+ "bin": {
17
+ "ohdear-deps-manifest": "./dist/bin.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "prepublishOnly": "tsc"
25
+ },
26
+ "keywords": [
27
+ "ohdear",
28
+ "health-check",
29
+ "npm",
30
+ "vulnerabilities",
31
+ "security",
32
+ "nextjs"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "peerDependencies": {
39
+ "next": ">=14"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "next": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^20",
48
+ "typescript": "^5"
49
+ }
50
+ }