postinstall-guardian 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SNR
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # postinstall-guardian
2
+
3
+ Scan `node_modules` for `preinstall`/`install`/`postinstall` scripts — the
4
+ most common npm supply-chain attack vector (a compromised or typosquatted
5
+ package runs arbitrary code the moment someone runs `npm install`) — and gate
6
+ CI on any that aren't explicitly approved.
7
+
8
+ ## Why not just `npm audit`?
9
+
10
+ `npm audit` only flags packages with a *reported* CVE. A malicious install
11
+ script in a brand-new or typosquatted package has no CVE yet — that's exactly
12
+ how the recent high-profile npm supply-chain compromises worked. This tool
13
+ doesn't judge whether a script is malicious (it can't); it makes sure a human
14
+ looks at every install script before it's allowed to run unreviewed in CI.
15
+
16
+ ## How it works
17
+
18
+ 1. `postinstall-guardian scan` walks `node_modules` (including nested/hoisted
19
+ copies) and lists every package with an install-type script.
20
+ 2. Compare that list against a baseline file (`.postinstall-guardian.json`,
21
+ committed to your repo) of package versions you've already reviewed and
22
+ approved.
23
+ 3. `postinstall-guardian ci` exits non-zero if anything isn't in the
24
+ baseline — so a new or bumped dependency with a new install script fails
25
+ the build until someone reviews and approves it.
26
+
27
+ A version bump automatically drops out of the baseline and gets re-flagged,
28
+ since a new version can ship a different script than the one you approved.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install --save-dev postinstall-guardian
34
+ ```
35
+
36
+ ## CLI
37
+
38
+ ### `postinstall-guardian scan`
39
+
40
+ ```bash
41
+ postinstall-guardian scan --dir . --report report.md
42
+ ```
43
+
44
+ Lists every install-type script found and marks which ones aren't yet
45
+ approved. Exit code is always 0 — use `ci` for a gating exit code.
46
+
47
+ ### `postinstall-guardian approve`
48
+
49
+ ```bash
50
+ postinstall-guardian approve
51
+ ```
52
+
53
+ Adds every currently-found install script to `.postinstall-guardian.json`.
54
+ **Review the `scan` output before running this** — approving is how you tell
55
+ the tool "I looked at this command and it's fine."
56
+
57
+ ### `postinstall-guardian ci`
58
+
59
+ ```bash
60
+ postinstall-guardian ci --report postinstall-guardian-report.md
61
+ ```
62
+
63
+ Scans, writes a report, and exits 1 if any install script isn't in the
64
+ baseline. This is the command to run in CI.
65
+
66
+ ### `postinstall-guardian init-workflow`
67
+
68
+ ```bash
69
+ postinstall-guardian init-workflow
70
+ # writes .github/workflows/postinstall-guardian.yml
71
+ ```
72
+
73
+ Writes a workflow that runs on every push/PR to `main` (new install scripts
74
+ arrive with dependency changes, not on a calendar) plus a daily cron as a
75
+ backstop for unpinned version ranges resolving to a new version between
76
+ deploys. It installs with `npm ci --ignore-scripts` so CI never executes the
77
+ scripts it's auditing.
78
+
79
+ ## Library API
80
+
81
+ ```ts
82
+ import { scan, mergeIntoBaseline, writeBaseline, readBaseline } from 'postinstall-guardian';
83
+
84
+ const result = await scan('./my-project');
85
+ if (result.unapproved.length > 0) {
86
+ console.log(`${result.unapproved.length} unreviewed install script(s)`);
87
+ }
88
+ ```
89
+
90
+ ## Baseline file format
91
+
92
+ ```json
93
+ {
94
+ "approved": ["esbuild@0.21.5", "sharp@0.33.4"]
95
+ }
96
+ ```
97
+
98
+ Commit `.postinstall-guardian.json` to version control — reviewing and
99
+ approving a new script is meant to happen in a PR, same as any other code
100
+ change.
101
+
102
+ ## Limitations
103
+
104
+ - Only npm's `node_modules` layout is supported (pnpm's symlink layout is
105
+ deduped via realpath, but scoped/aliased pnpm structures aren't specially
106
+ handled).
107
+ - This tool surfaces scripts for human review — it does not analyze whether
108
+ a script is actually malicious.
@@ -0,0 +1,6 @@
1
+ import type { Baseline, ScriptEntry } from './types';
2
+ export declare const DEFAULT_BASELINE_FILE = ".postinstall-guardian.json";
3
+ export declare function readBaseline(root: string, filename?: string): Promise<Baseline>;
4
+ export declare function writeBaseline(root: string, baseline: Baseline, filename?: string): Promise<void>;
5
+ export declare function diffAgainstBaseline(entries: ScriptEntry[], baseline: Baseline): ScriptEntry[];
6
+ export declare function mergeIntoBaseline(baseline: Baseline, entries: ScriptEntry[]): Baseline;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_BASELINE_FILE = void 0;
4
+ exports.readBaseline = readBaseline;
5
+ exports.writeBaseline = writeBaseline;
6
+ exports.diffAgainstBaseline = diffAgainstBaseline;
7
+ exports.mergeIntoBaseline = mergeIntoBaseline;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const types_1 = require("./types");
11
+ exports.DEFAULT_BASELINE_FILE = '.postinstall-guardian.json';
12
+ async function readBaseline(root, filename = exports.DEFAULT_BASELINE_FILE) {
13
+ try {
14
+ const raw = await node_fs_1.promises.readFile((0, node_path_1.join)(root, filename), 'utf8');
15
+ const parsed = JSON.parse(raw);
16
+ if (!Array.isArray(parsed.approved))
17
+ throw new Error('malformed baseline');
18
+ return { approved: parsed.approved };
19
+ }
20
+ catch (err) {
21
+ if (err.code === 'ENOENT')
22
+ return { approved: [] };
23
+ throw new Error(`Could not read ${filename}: ${err instanceof Error ? err.message : err}`);
24
+ }
25
+ }
26
+ async function writeBaseline(root, baseline, filename = exports.DEFAULT_BASELINE_FILE) {
27
+ const sorted = { approved: [...new Set(baseline.approved)].sort() };
28
+ await node_fs_1.promises.writeFile((0, node_path_1.join)(root, filename), JSON.stringify(sorted, null, 2) + '\n', 'utf8');
29
+ }
30
+ function diffAgainstBaseline(entries, baseline) {
31
+ const approved = new Set(baseline.approved);
32
+ return entries.filter((entry) => !approved.has((0, types_1.baselineKey)(entry)));
33
+ }
34
+ function mergeIntoBaseline(baseline, entries) {
35
+ const approved = new Set(baseline.approved);
36
+ for (const entry of entries)
37
+ approved.add((0, types_1.baselineKey)(entry));
38
+ return { approved: [...approved] };
39
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const scan_1 = require("./scan");
8
+ const report_1 = require("./report");
9
+ const baseline_1 = require("./baseline");
10
+ const workflowTemplate_1 = require("./workflowTemplate");
11
+ const program = new commander_1.Command();
12
+ program
13
+ .name('postinstall-guardian')
14
+ .description('Scan node_modules for preinstall/install/postinstall scripts and gate CI on unapproved ones.')
15
+ .version('0.1.0');
16
+ function writeReport(path, contents) {
17
+ if (!path)
18
+ return;
19
+ const full = (0, node_path_1.resolve)(process.cwd(), path);
20
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
21
+ (0, node_fs_1.writeFileSync)(full, contents, 'utf8');
22
+ }
23
+ program
24
+ .command('scan')
25
+ .description('List install-type scripts in node_modules and flag any not in the baseline.')
26
+ .option('-d, --dir <path>', 'project directory', '.')
27
+ .option('-r, --report <path>', 'write a Markdown report to this path')
28
+ .action(async (opts) => {
29
+ const cwd = (0, node_path_1.resolve)(process.cwd(), opts.dir);
30
+ const result = await (0, scan_1.scan)(cwd);
31
+ const md = (0, report_1.scanToMarkdown)(result);
32
+ console.log(md);
33
+ writeReport(opts.report, md);
34
+ });
35
+ program
36
+ .command('approve')
37
+ .description('Add all currently-found install scripts to the baseline (review them first!).')
38
+ .option('-d, --dir <path>', 'project directory', '.')
39
+ .action(async (opts) => {
40
+ const cwd = (0, node_path_1.resolve)(process.cwd(), opts.dir);
41
+ const result = await (0, scan_1.scan)(cwd);
42
+ const baseline = await (0, baseline_1.readBaseline)(cwd);
43
+ const updated = (0, baseline_1.mergeIntoBaseline)(baseline, result.entries);
44
+ await (0, baseline_1.writeBaseline)(cwd, updated);
45
+ console.log(`Approved ${result.unapproved.length} new script(s). ${baseline_1.DEFAULT_BASELINE_FILE} now tracks ${updated.approved.length} package version(s).`);
46
+ });
47
+ program
48
+ .command('ci')
49
+ .description('Scan and exit non-zero if any install script is not in the baseline.')
50
+ .option('-d, --dir <path>', 'project directory', '.')
51
+ .option('-r, --report <path>', 'write a Markdown report to this path', 'postinstall-guardian-report.md')
52
+ .action(async (opts) => {
53
+ const cwd = (0, node_path_1.resolve)(process.cwd(), opts.dir);
54
+ const result = await (0, scan_1.scan)(cwd);
55
+ const md = (0, report_1.scanToMarkdown)(result);
56
+ console.log(md);
57
+ writeReport(opts.report, md);
58
+ if (result.unapproved.length > 0) {
59
+ process.exitCode = 1;
60
+ }
61
+ });
62
+ program
63
+ .command('init-workflow')
64
+ .description('Write a GitHub Actions workflow that runs postinstall-guardian on every push/PR (plus a daily backstop).')
65
+ .option('-o, --out <path>', 'workflow file path', '.github/workflows/postinstall-guardian.yml')
66
+ .option('--cron <expr>', 'backstop cron schedule (UTC)', '0 6 * * *')
67
+ .option('--node-version <version>', 'Node.js version to run under', '20')
68
+ .action((opts) => {
69
+ const yaml = (0, workflowTemplate_1.workflowYaml)({ cron: opts.cron, nodeVersion: opts.nodeVersion });
70
+ const full = (0, node_path_1.resolve)(process.cwd(), opts.out);
71
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
72
+ (0, node_fs_1.writeFileSync)(full, yaml, 'utf8');
73
+ console.log(`Wrote ${opts.out}`);
74
+ });
75
+ program.parseAsync(process.argv).catch((err) => {
76
+ console.error(err instanceof Error ? err.message : err);
77
+ process.exitCode = 1;
78
+ });
@@ -0,0 +1,6 @@
1
+ export * from './types';
2
+ export { scan } from './scan';
3
+ export { scanNodeModules } from './scanner';
4
+ export { readBaseline, writeBaseline, diffAgainstBaseline, mergeIntoBaseline, DEFAULT_BASELINE_FILE } from './baseline';
5
+ export { scanToMarkdown } from './report';
6
+ export { workflowYaml } from './workflowTemplate';
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.workflowYaml = exports.scanToMarkdown = exports.DEFAULT_BASELINE_FILE = exports.mergeIntoBaseline = exports.diffAgainstBaseline = exports.writeBaseline = exports.readBaseline = exports.scanNodeModules = exports.scan = void 0;
18
+ __exportStar(require("./types"), exports);
19
+ var scan_1 = require("./scan");
20
+ Object.defineProperty(exports, "scan", { enumerable: true, get: function () { return scan_1.scan; } });
21
+ var scanner_1 = require("./scanner");
22
+ Object.defineProperty(exports, "scanNodeModules", { enumerable: true, get: function () { return scanner_1.scanNodeModules; } });
23
+ var baseline_1 = require("./baseline");
24
+ Object.defineProperty(exports, "readBaseline", { enumerable: true, get: function () { return baseline_1.readBaseline; } });
25
+ Object.defineProperty(exports, "writeBaseline", { enumerable: true, get: function () { return baseline_1.writeBaseline; } });
26
+ Object.defineProperty(exports, "diffAgainstBaseline", { enumerable: true, get: function () { return baseline_1.diffAgainstBaseline; } });
27
+ Object.defineProperty(exports, "mergeIntoBaseline", { enumerable: true, get: function () { return baseline_1.mergeIntoBaseline; } });
28
+ Object.defineProperty(exports, "DEFAULT_BASELINE_FILE", { enumerable: true, get: function () { return baseline_1.DEFAULT_BASELINE_FILE; } });
29
+ var report_1 = require("./report");
30
+ Object.defineProperty(exports, "scanToMarkdown", { enumerable: true, get: function () { return report_1.scanToMarkdown; } });
31
+ var workflowTemplate_1 = require("./workflowTemplate");
32
+ Object.defineProperty(exports, "workflowYaml", { enumerable: true, get: function () { return workflowTemplate_1.workflowYaml; } });
@@ -0,0 +1,2 @@
1
+ import type { ScanResult } from './types';
2
+ export declare function scanToMarkdown(result: ScanResult): string;
package/dist/report.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanToMarkdown = scanToMarkdown;
4
+ function line(entry) {
5
+ const cmd = entry.command.length > 100 ? entry.command.slice(0, 97) + '...' : entry.command;
6
+ return `- **${entry.name}@${entry.version}** \`${entry.hook}\`: \`${cmd}\``;
7
+ }
8
+ function scanToMarkdown(result) {
9
+ const lines = [
10
+ '# postinstall-guardian scan report',
11
+ '',
12
+ `Generated: ${new Date().toISOString()}`,
13
+ '',
14
+ `Total install-type scripts found: ${result.entries.length}`,
15
+ `Unapproved: ${result.unapproved.length}`,
16
+ '',
17
+ ];
18
+ if (result.unapproved.length === 0) {
19
+ lines.push('All install-type scripts are approved in the baseline.');
20
+ return lines.join('\n');
21
+ }
22
+ lines.push('## Unapproved scripts', '', 'These packages run a script during `npm install` and are not yet in your baseline.', 'Review each command below. If it is expected (native module builds, binary', 'downloads, etc.), run `postinstall-guardian approve` to add it to the baseline.', '', ...result.unapproved.map(line));
23
+ return lines.join('\n');
24
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { ScanResult } from './types';
2
+ export declare function scan(root: string, baselineFile?: string): Promise<ScanResult>;
package/dist/scan.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scan = scan;
4
+ const baseline_1 = require("./baseline");
5
+ const scanner_1 = require("./scanner");
6
+ async function scan(root, baselineFile = baseline_1.DEFAULT_BASELINE_FILE) {
7
+ const [entries, baseline] = await Promise.all([(0, scanner_1.scanNodeModules)(root), (0, baseline_1.readBaseline)(root, baselineFile)]);
8
+ return { entries, unapproved: (0, baseline_1.diffAgainstBaseline)(entries, baseline) };
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { ScriptEntry } from './types';
2
+ /**
3
+ * Walks every node_modules directory under `root` (including nested ones from
4
+ * hoisting/version conflicts) and collects packages that declare a
5
+ * preinstall/install/postinstall script. Dedupes on realpath to avoid
6
+ * re-scanning symlinked packages (pnpm) or looping on symlink cycles.
7
+ */
8
+ export declare function scanNodeModules(root: string): Promise<ScriptEntry[]>;
9
+ export declare function toRelative(root: string, entry: ScriptEntry): string;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanNodeModules = scanNodeModules;
4
+ exports.toRelative = toRelative;
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const HOOKS = ['preinstall', 'install', 'postinstall'];
8
+ /**
9
+ * Walks every node_modules directory under `root` (including nested ones from
10
+ * hoisting/version conflicts) and collects packages that declare a
11
+ * preinstall/install/postinstall script. Dedupes on realpath to avoid
12
+ * re-scanning symlinked packages (pnpm) or looping on symlink cycles.
13
+ */
14
+ async function scanNodeModules(root) {
15
+ const entries = [];
16
+ const visited = new Set();
17
+ await walk((0, node_path_1.join)(root, 'node_modules'), entries, visited);
18
+ return entries;
19
+ }
20
+ async function walk(dir, entries, visited) {
21
+ let real;
22
+ try {
23
+ real = await node_fs_1.promises.realpath(dir);
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ if (visited.has(real))
29
+ return;
30
+ visited.add(real);
31
+ let children;
32
+ try {
33
+ children = await node_fs_1.promises.readdir(dir);
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const child of children) {
39
+ if (child === '.bin')
40
+ continue;
41
+ const childPath = (0, node_path_1.join)(dir, child);
42
+ if (child.startsWith('@')) {
43
+ // Scoped packages are a directory of packages, not a package itself.
44
+ let scopedChildren;
45
+ try {
46
+ scopedChildren = await node_fs_1.promises.readdir(childPath);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ for (const scopedChild of scopedChildren) {
52
+ await readPackage((0, node_path_1.join)(childPath, scopedChild), entries);
53
+ await descendNested((0, node_path_1.join)(childPath, scopedChild), entries, visited);
54
+ }
55
+ continue;
56
+ }
57
+ await readPackage(childPath, entries);
58
+ await descendNested(childPath, entries, visited);
59
+ }
60
+ }
61
+ async function descendNested(packageDir, entries, visited) {
62
+ const nested = (0, node_path_1.join)(packageDir, 'node_modules');
63
+ const stat = await safeStat(nested);
64
+ if (stat?.isDirectory()) {
65
+ await walk(nested, entries, visited);
66
+ }
67
+ }
68
+ async function readPackage(packageDir, entries) {
69
+ const pkgJsonPath = (0, node_path_1.join)(packageDir, 'package.json');
70
+ const stat = await safeStat(pkgJsonPath);
71
+ if (!stat?.isFile())
72
+ return;
73
+ let pkg;
74
+ try {
75
+ pkg = JSON.parse(await node_fs_1.promises.readFile(pkgJsonPath, 'utf8'));
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ if (!pkg.name || !pkg.version || !pkg.scripts)
81
+ return;
82
+ for (const hook of HOOKS) {
83
+ const command = pkg.scripts[hook];
84
+ if (command) {
85
+ entries.push({ name: pkg.name, version: pkg.version, hook, command, path: packageDir });
86
+ }
87
+ }
88
+ }
89
+ async function safeStat(path) {
90
+ try {
91
+ return await node_fs_1.promises.stat(path);
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ function toRelative(root, entry) {
98
+ return (0, node_path_1.relative)(root, entry.path) || '.';
99
+ }
@@ -0,0 +1,21 @@
1
+ export type InstallHook = 'preinstall' | 'install' | 'postinstall';
2
+ export interface ScriptEntry {
3
+ name: string;
4
+ version: string;
5
+ hook: InstallHook;
6
+ command: string;
7
+ /** Path to the package directory, relative to the scan root. */
8
+ path: string;
9
+ }
10
+ export interface ScanResult {
11
+ /** Every install-type script found in node_modules. */
12
+ entries: ScriptEntry[];
13
+ /** Entries not present in the baseline (i.e. never approved). */
14
+ unapproved: ScriptEntry[];
15
+ }
16
+ /** Baseline key uniquely identifying an approved package version's script set. */
17
+ export declare function baselineKey(entry: Pick<ScriptEntry, 'name' | 'version'>): string;
18
+ export interface Baseline {
19
+ /** Set of "name@version" strings whose install scripts have been reviewed and approved. */
20
+ approved: string[];
21
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.baselineKey = baselineKey;
4
+ /** Baseline key uniquely identifying an approved package version's script set. */
5
+ function baselineKey(entry) {
6
+ return `${entry.name}@${entry.version}`;
7
+ }
@@ -0,0 +1,12 @@
1
+ export interface WorkflowOptions {
2
+ /** Daily cron in UTC, as a backstop for drift outside of PRs (e.g. floating ranges resolving differently). */
3
+ cron?: string;
4
+ nodeVersion?: string;
5
+ }
6
+ /**
7
+ * Unlike vuln-guardian's daily-only schedule, new install scripts arrive
8
+ * whenever dependencies change — so this runs on every push/PR (the moment
9
+ * that matters) plus a daily cron as a backstop for drift from unpinned
10
+ * version ranges resolving to a new version between deploys.
11
+ */
12
+ export declare function workflowYaml(options?: WorkflowOptions): string;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.workflowYaml = workflowYaml;
4
+ /**
5
+ * Unlike vuln-guardian's daily-only schedule, new install scripts arrive
6
+ * whenever dependencies change — so this runs on every push/PR (the moment
7
+ * that matters) plus a daily cron as a backstop for drift from unpinned
8
+ * version ranges resolving to a new version between deploys.
9
+ */
10
+ function workflowYaml(options = {}) {
11
+ const cron = options.cron ?? '0 6 * * *';
12
+ const nodeVersion = options.nodeVersion ?? '20';
13
+ return `name: postinstall-guardian
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+ pull_request:
19
+ schedule:
20
+ - cron: '${cron}'
21
+ workflow_dispatch: {}
22
+
23
+ jobs:
24
+ scan:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: actions/setup-node@v4
30
+ with:
31
+ node-version: '${nodeVersion}'
32
+
33
+ # --ignore-scripts: don't execute the very scripts we're about to audit.
34
+ - run: npm ci --ignore-scripts
35
+
36
+ - name: Scan for unapproved install scripts
37
+ run: npx postinstall-guardian ci
38
+ `;
39
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "postinstall-guardian",
3
+ "version": "0.1.0",
4
+ "description": "Scan node_modules for preinstall/install/postinstall scripts (the #1 npm supply-chain attack vector) and gate CI on any that aren't explicitly approved.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "postinstall-guardian": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "tsc -p tsconfig.test.json && node --test .test-build/*.test.js",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "npm",
25
+ "security",
26
+ "supply-chain",
27
+ "postinstall",
28
+ "preinstall",
29
+ "install-scripts",
30
+ "cli",
31
+ "ci"
32
+ ],
33
+ "dependencies": {
34
+ "commander": "^12.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.14.0",
38
+ "typescript": "^5.6.0"
39
+ }
40
+ }