@kushankurdas/npm-sentinel 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.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for helping improve npm-sentinel.
4
+
5
+ ## Development
6
+
7
+ 1. Clone the repo and install dependencies (this package has no runtime deps; tests use Node built-ins).
8
+ 2. Run tests: `npm test`
9
+ 3. Follow existing style: ES modules, no unnecessary dependencies.
10
+
11
+ ## Pull requests
12
+
13
+ - Keep changes focused on one concern.
14
+ - Add or update tests when behavior changes.
15
+ - Update `README.md` if user-facing commands or flags change.
16
+
17
+ ## Security
18
+
19
+ See **[SECURITY.md](SECURITY.md)** for how to report vulnerabilities in this project.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 npm-sentinel contributors
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,243 @@
1
+ # npm-sentinel
2
+
3
+ **npm-sentinel** helps you catch risky dependency changes before or during install:
4
+
5
+ - **Fast checks** — scan your `package-lock.json` against vulnerability databases and known-bad versions.
6
+ - **Baseline drift** — detect new dependencies or install scripts on packages you trust (similar to how the [Axios supply-chain incident](https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all) introduced a hidden dependency).
7
+ - **Optional Docker sandbox** — run `npm ci` **inside a container** with DNS monitoring, so malicious `postinstall` scripts do not run on your host during that check.
8
+
9
+ ---
10
+
11
+ ## Simple guide: two jobs, five commands
12
+
13
+ Think of it as **two layers**:
14
+
15
+ | Layer | What it does | Needs Docker? |
16
+ |--------|----------------|---------------|
17
+ | **A — Static** | Reads the lockfile, calls [OSV](https://osv.dev/), optional baseline compare | **No** |
18
+ | **B — Sandbox** | Runs a real `npm ci` in Linux + watches DNS | **Yes** |
19
+
20
+ ### Cheat sheet (what to run)
21
+
22
+ | I want… | Command |
23
+ |--------|---------|
24
+ | **Quick “is my lockfile bad?”** (daily / CI) | `npx npm-sentinel check` |
25
+ | **Same + compare to a saved “good” snapshot** | `npx npm-sentinel check --baseline` |
26
+ | **Save a “good” snapshot** (commit the file afterward) | `npx npm-sentinel baseline save` |
27
+ | **See what changed vs that snapshot** | `npx npm-sentinel baseline diff` |
28
+ | **Heavy: install in Docker + DNS allowlist** | `npx npm-sentinel sandbox` |
29
+ | **CI: static check, then optionally sandbox** | `npx npm-sentinel gate` or `npx npm-sentinel gate --require-sandbox` |
30
+
31
+ **Most teams:** use **`check`** (and **`check --baseline`** once baselines exist) on every PR; add **`sandbox`** where you need install-time behavior proof.
32
+
33
+ ### Commands in plain English
34
+
35
+ - **`check`** — “Does this lockfile list any known vulnerable or known-malicious versions?” Optionally: “Did my trusted direct deps drift vs my baseline?”
36
+ - **`baseline save`** — “Remember today’s dependency + script picture for my root `package.json` deps as **trusted**.” Writes `.npm-sentinel-baseline.json`.
37
+ - **`baseline diff`** — “What changed since `baseline save`?” (new child deps, new install scripts, version bumps, etc.)
38
+ - **`sandbox`** — “Copy the project into a container, run **`npm ci` with scripts on**, record DNS. Fail if DNS hits unknown domains or `npm ci` fails.” Use **`--mount-ssh`** if you have private **`git+ssh`** dependencies (see below).
39
+ - **`gate`** — “Run **`check`** with baseline diff enabled **if** a baseline file exists. With **`--require-sandbox`**, run **`sandbox`** after **`check`** passes.”
40
+
41
+ ---
42
+
43
+ ## Quick start
44
+
45
+ ### 1. In any repo that has `package-lock.json`
46
+
47
+ ```bash
48
+ cd your-project
49
+ npx npm-sentinel check
50
+ ```
51
+
52
+ Exit code **0** = no findings at your severity threshold; **1** = findings or baseline errors.
53
+
54
+ ### 2. Optional: block install on bad lockfile (host only runs static check)
55
+
56
+ Use **npx** or a **global** install so `preinstall` works on a fresh clone:
57
+
58
+ ```json
59
+ {
60
+ "scripts": {
61
+ "preinstall": "npx --yes npm-sentinel@latest check --baseline"
62
+ }
63
+ }
64
+ ```
65
+
66
+ Static `preinstall` does **not** run dependency `postinstall` on the host if npm aborts first. For **install-time** behavior, use **`sandbox`** separately or in CI.
67
+
68
+ ### 3. Optional: baseline workflow
69
+
70
+ ```bash
71
+ npx npm-sentinel baseline save
72
+ git add .npm-sentinel-baseline.json
73
+ git commit -m "chore: npm-sentinel baseline"
74
+ ```
75
+
76
+ Later:
77
+
78
+ ```bash
79
+ npx npm-sentinel check --baseline
80
+ ```
81
+
82
+ ### 4. Optional: Docker sandbox (`git+ssh` private deps)
83
+
84
+ ```bash
85
+ npx npm-sentinel sandbox --mount-ssh
86
+ ```
87
+
88
+ Or a folder with only deploy keys:
89
+
90
+ ```bash
91
+ npx npm-sentinel sandbox --ssh-dir /path/to/keys
92
+ ```
93
+
94
+ See **Docker & SSH** below for macOS vs Linux caveats.
95
+
96
+ ---
97
+
98
+ ## Install
99
+
100
+ ```bash
101
+ npm install -D npm-sentinel
102
+ ```
103
+
104
+ ```bash
105
+ npm install -g npm-sentinel
106
+ ```
107
+
108
+ From a **local clone** of this repo:
109
+
110
+ ```bash
111
+ cd npm-sentinel
112
+ npm link
113
+ cd /path/to/your-app
114
+ npm link npm-sentinel
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Requirements
120
+
121
+ - **Node.js ≥ 18**
122
+ - **Docker** — only for `sandbox` and `gate --require-sandbox`
123
+ - **`package-lock.json`** at the project root (required for `check`, `baseline`, `sandbox`)
124
+
125
+ ---
126
+
127
+ ## Command reference (detailed)
128
+
129
+ | Command | Description |
130
+ |--------|-------------|
131
+ | `npm-sentinel check` | Lockfile → OSV + offline IOCs; add `--baseline` if `.npm-sentinel-baseline.json` exists |
132
+ | `npm-sentinel baseline save` | Write baseline from lockfile + registry metadata for watched packages |
133
+ | `npm-sentinel baseline diff` | Print drift vs baseline |
134
+ | `npm-sentinel sandbox` | Docker: copy project, `npm ci`, tcpdump DNS vs allowlist |
135
+ | `npm-sentinel gate` | `check` with baseline diff; `--require-sandbox` runs `sandbox` after |
136
+ | `npm-sentinel help` | Print help |
137
+
138
+ ### Flags
139
+
140
+ | Flag | Purpose |
141
+ |------|---------|
142
+ | `--cwd <dir>` | Project root (default: current directory) |
143
+ | `--json` | JSON output |
144
+ | `--min-severity` | `low` \| `moderate` \| `high` \| `critical` (default: `low`) |
145
+ | `--no-osv` | Skip OSV API |
146
+ | `--offline` | Skip OSV (offline IOCs still apply) |
147
+ | `--baseline` | With `check`, run baseline diff when baseline file exists |
148
+ | `--npm-audit` | Also run `npm audit --json` |
149
+ | `--require-sandbox` | With `gate`, run `sandbox` after check |
150
+ | `--no-build` | `sandbox`: reuse existing Docker image |
151
+ | `--mount-ssh` | `sandbox`: mount host `~/.ssh` read-only at `/root/.ssh` |
152
+ | `--ssh-dir <path>` | `sandbox`: mount this directory instead of `~/.ssh` |
153
+ | `--baseline-file <path>` | Alternate baseline file path |
154
+
155
+ ---
156
+
157
+ ## Docker sandbox and your machine
158
+
159
+ - The sandbox runs **Linux** in Docker. It does **not** always give you a **macOS/Windows–usable** `node_modules` when packages ship **native** binaries built for your host OS.
160
+ - **Recommendation:** run **`sandbox` in CI (Linux)**; locally use **`check`**, or develop inside a **Dev Container** if you want Linux `node_modules` daily.
161
+
162
+ ### Private `git+ssh` dependencies
163
+
164
+ The image includes **`git`** and **`openssh-client`**.
165
+
166
+ | Approach | Command / config |
167
+ |----------|-------------------|
168
+ | Mount host keys | `npm-sentinel sandbox --mount-ssh` |
169
+ | Mount a key folder only | `npm-sentinel sandbox --ssh-dir /path/to/keys` |
170
+ | Config file | `npm-sentinel.config.json`: `"sandbox": { "mountSsh": true }` or `"sshDir": "/path"` |
171
+
172
+ **macOS:** Your `~/.ssh/config` may use **`UseKeychain`**, which **Linux OpenSSH does not support**. The tool sets **`GIT_SSH_COMMAND`** with **`-F /dev/null`** so the **mounted config is ignored**; default key files in the mount still apply (`id_ed25519`, `id_rsa`, …). Keys that exist **only** in the Keychain and not on disk may still fail — use a **deploy key file** or **`git+https`** with a token in CI.
173
+
174
+ **Security:** Mounting `~/.ssh` gives the container read access to whatever keys are in that directory. Prefer **deploy keys** or **HTTPS + token** for automation.
175
+
176
+ ---
177
+
178
+ ## Configuration
179
+
180
+ Create **`npm-sentinel.config.json`** or **`.npm-sentinelsrc.json`** in the project root:
181
+
182
+ ```json
183
+ {
184
+ "watchPackagesExtra": ["some-transitive-parent"],
185
+ "watchPackagesOverride": null,
186
+ "dnsAllowlist": {
187
+ "mode": "merge",
188
+ "suffixes": ["my-registry.example.com"],
189
+ "exactHosts": []
190
+ },
191
+ "sandbox": {
192
+ "mountSsh": true
193
+ }
194
+ }
195
+ ```
196
+
197
+ | Key | Meaning |
198
+ |-----|---------|
199
+ | **Watched packages** | Defaults to root `dependencies` + `devDependencies` (+ optional peers / optionals). `watchPackagesExtra` adds more parent names. |
200
+ | **`watchPackagesOverride`** | If set (array), **only** these names are watched (replaces default list + extras). |
201
+ | **`dnsAllowlist`** | DNS allowlist for `sandbox`: **`mode`** `merge` (default) adds `suffixes` / `exactHosts` to the [built-in list](lib/dns-allowlist-default.json); **`replace`** uses **only** your lists (strict; you must include every host your install needs). |
202
+ | **`sandbox.mountSsh`** | Same as `--mount-ssh`. |
203
+ | **`sandbox.sshDir`** | Same as `--ssh-dir` (wins over `mountSsh` when set). |
204
+
205
+ ---
206
+
207
+ ## Baseline signals
208
+
209
+ When you compare to a saved baseline, npm-sentinel can report:
210
+
211
+ - **New direct dependencies** on a watched package (dependency injection)
212
+ - **New lifecycle scripts** on a resolved version (`preinstall` / `install` / `postinstall`)
213
+ - **Version changes** on watched packages
214
+ - **Removed dependencies** (warning; can be noisy; possible account-takeover signal)
215
+
216
+ ---
217
+
218
+ ## Development (this repo)
219
+
220
+ Command details and testing: **[`docs/`](docs/README.md)** (per-command guides under **`docs/commands/`**). Security reports: **[`SECURITY.md`](SECURITY.md)**.
221
+
222
+ ```bash
223
+ git clone https://github.com/kushankurdas/npm-sentinel.git
224
+ cd npm-sentinel
225
+ npm ci
226
+ npm test
227
+ ```
228
+
229
+ If your GitHub username or repo name differs, update the **`repository`**, **`bugs`**, and **`homepage`** fields in [`package.json`](package.json) to match.
230
+
231
+ ---
232
+
233
+ ## Related reading
234
+
235
+ - [pakrat](https://github.com/HorseyofCoursey/pakrat) — behavioral npm monitoring (inspiration)
236
+ - [Elastic — Axios supply chain](https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all)
237
+ - [Microsoft — Axios mitigation](https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/)
238
+
239
+ ---
240
+
241
+ ## License
242
+
243
+ [MIT](LICENSE)
package/SECURITY.md ADDED
@@ -0,0 +1,17 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are applied to the **latest** `0.x` release line on the default branch. Use the newest published version when possible.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Report security issues in **this repository or the npm-sentinel CLI** through **[GitHub Security Advisories](https://github.com/kushankurdas/npm-sentinel/security/advisories/new)** (private report, preferred) instead of a public issue.
10
+
11
+ **Out of scope** for this channel: general discussion of npm ecosystem incidents, vulnerabilities in third-party packages you depend on, or abuse of registries unless they directly involve a flaw in npm-sentinel’s own code.
12
+
13
+ We will acknowledge receipt as we can and coordinate on fixes and disclosure.
14
+
15
+ ## npm package
16
+
17
+ After you publish, confirm the **`npm-sentinel`** package on the npm registry lists **`repository`** in `package.json` as this GitHub repo before trusting forks or typosquats.
package/bin/cli.js ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { runCheck, readPackageJson } from "../lib/scan.js";
5
+ import {
6
+ buildBaselineSnapshot,
7
+ saveBaseline,
8
+ loadBaseline,
9
+ DEFAULT_BASELINE_PATH,
10
+ } from "../lib/baseline.js";
11
+ import {
12
+ parseNpmLockfile,
13
+ } from "../lib/parse-npm-lockfile.js";
14
+ import { loadConfig, getWatchPackageNames } from "../lib/config.js";
15
+ import { runSandbox } from "../lib/sandbox/docker-runner.js";
16
+ import { diffAgainstBaseline } from "../lib/diff-signals.js";
17
+ function parseArgs(argv) {
18
+ const args = { _: [], flags: {} };
19
+ for (let i = 2; i < argv.length; i++) {
20
+ const a = argv[i];
21
+ if (a.startsWith("--")) {
22
+ const [k, v] = a.includes("=") ? a.split("=", 2) : [a, null];
23
+ const key = k.slice(2);
24
+ if (v !== null) args.flags[key] = v;
25
+ else if (argv[i + 1] && !argv[i + 1].startsWith("-")) {
26
+ args.flags[key] = argv[++i];
27
+ } else {
28
+ args.flags[key] = true;
29
+ }
30
+ } else {
31
+ args._.push(a);
32
+ }
33
+ }
34
+ return args;
35
+ }
36
+
37
+ function printFindingsJson(payload) {
38
+ console.log(JSON.stringify(payload, null, 2));
39
+ }
40
+
41
+ function printFindingsHuman(result) {
42
+ console.log(`Scanned ${result.packagesScanned} unique package versions (lockfile).`);
43
+ if (result.findings.length === 0) {
44
+ console.log("No OSV/offline-IOC findings at or above min severity.");
45
+ } else {
46
+ console.log("\nFindings:");
47
+ for (const f of result.findings) {
48
+ console.log(
49
+ ` - ${f.name}@${f.version} [${f.source}] severity=${f.severity} ids=${(f.ids || []).join(",")}`
50
+ );
51
+ if (f.summary) console.log(` ${f.summary}`);
52
+ }
53
+ }
54
+ if (result.signals?.length) {
55
+ console.log("\nBaseline signals:");
56
+ for (const s of result.signals) {
57
+ console.log(` [${s.severity}] ${s.type}: ${s.message}`);
58
+ }
59
+ }
60
+ if (result.npmAuditFindings?.length) {
61
+ console.log("\nnpm audit (summary):");
62
+ for (const a of result.npmAuditFindings.slice(0, 20)) {
63
+ if (a.error) console.log(` - ${a.error}`);
64
+ else console.log(` - ${a.name} [${a.severity}]`);
65
+ }
66
+ }
67
+ }
68
+
69
+ async function cmdCheck(flags) {
70
+ const cwd = flags.cwd || process.cwd();
71
+ const json = !!flags.json;
72
+ const minSeverity = flags["min-severity"] || "low";
73
+ const result = await runCheck({
74
+ cwd,
75
+ minSeverity,
76
+ noOsv: !!flags["no-osv"],
77
+ offline: !!flags.offline,
78
+ withBaselineDiff: !!flags.baseline,
79
+ npmAudit: !!flags["npm-audit"],
80
+ });
81
+
82
+ const signalErrors = (result.signals || []).filter((s) => s.severity === "error");
83
+ const failSignals = signalErrors.length > 0;
84
+
85
+ if (json) {
86
+ printFindingsJson({
87
+ ok: result.findings.length === 0 && !failSignals,
88
+ ...result,
89
+ });
90
+ } else {
91
+ printFindingsHuman(result);
92
+ }
93
+
94
+ const failFindings = result.findings.length > 0;
95
+ process.exitCode = failFindings || failSignals ? 1 : 0;
96
+ }
97
+
98
+ async function cmdBaseline(flags, sub) {
99
+ const cwd = flags.cwd || process.cwd();
100
+ const json = !!flags.json;
101
+ if (sub === "save") {
102
+ const lockPath = join(cwd, "package-lock.json");
103
+ if (!existsSync(lockPath)) {
104
+ console.error("package-lock.json required for baseline save.");
105
+ process.exitCode = 2;
106
+ return;
107
+ }
108
+ const pkg = readPackageJson(cwd);
109
+ const config = loadConfig(cwd);
110
+ const watchNames = getWatchPackageNames(pkg, config);
111
+ const lockRaw = JSON.parse(readFileSync(lockPath, "utf8"));
112
+ const parsed = parseNpmLockfile(lockRaw);
113
+ const snapshot = await buildBaselineSnapshot(pkg, parsed, watchNames);
114
+ const p = saveBaseline(snapshot, cwd, flags["baseline-file"] || DEFAULT_BASELINE_PATH);
115
+ if (json) {
116
+ printFindingsJson({ ok: true, path: p, snapshot });
117
+ } else {
118
+ console.log(`Baseline written to ${p} (${watchNames.length} watched packages).`);
119
+ }
120
+ process.exitCode = 0;
121
+ return;
122
+ }
123
+ if (sub === "diff") {
124
+ const baseline = loadBaseline(cwd, flags["baseline-file"] || DEFAULT_BASELINE_PATH);
125
+ if (!baseline) {
126
+ console.error("No baseline file. Run: npm-sentinel baseline save");
127
+ process.exitCode = 2;
128
+ return;
129
+ }
130
+ const lockPath = join(cwd, "package-lock.json");
131
+ if (!existsSync(lockPath)) {
132
+ console.error("package-lock.json required.");
133
+ process.exitCode = 2;
134
+ return;
135
+ }
136
+ const pkg = readPackageJson(cwd);
137
+ const config = loadConfig(cwd);
138
+ const watchNames = getWatchPackageNames(pkg, config);
139
+ const parsed = parseNpmLockfile(JSON.parse(readFileSync(lockPath, "utf8")));
140
+ const signals = await diffAgainstBaseline(baseline, parsed, watchNames);
141
+ const errors = signals.filter((s) => s.severity === "error");
142
+ if (json) {
143
+ printFindingsJson({ ok: errors.length === 0, signals });
144
+ } else {
145
+ if (signals.length === 0) console.log("No baseline drift detected.");
146
+ else {
147
+ for (const s of signals) {
148
+ console.log(`[${s.severity}] ${s.type}: ${s.message}`);
149
+ }
150
+ }
151
+ }
152
+ process.exitCode = errors.length ? 1 : 0;
153
+ return;
154
+ }
155
+ console.error("Usage: npm-sentinel baseline save|diff");
156
+ process.exitCode = 2;
157
+ }
158
+
159
+ function sandboxSshOpts(flags, config) {
160
+ const explicitDir = flags["ssh-dir"] || config.sandbox?.sshDir;
161
+ if (explicitDir) {
162
+ return { mountSsh: false, sshMountPath: explicitDir };
163
+ }
164
+ const useDefaultDotSsh =
165
+ !!flags["mount-ssh"] || !!config.sandbox?.mountSsh;
166
+ if (useDefaultDotSsh) {
167
+ return { mountSsh: true, sshMountPath: null };
168
+ }
169
+ return { mountSsh: false, sshMountPath: null };
170
+ }
171
+
172
+ function cmdSandbox(flags) {
173
+ const cwd = flags.cwd || process.cwd();
174
+ const json = !!flags.json;
175
+ let userAllowlist;
176
+ const config = loadConfig(cwd);
177
+ if (config.dnsAllowlist) userAllowlist = config.dnsAllowlist;
178
+
179
+ const ssh = sandboxSshOpts(flags, config);
180
+ const res = runSandbox({
181
+ cwd,
182
+ skipBuild: !!flags["no-build"],
183
+ userAllowlist,
184
+ mountSsh: ssh.mountSsh,
185
+ sshMountPath: ssh.sshMountPath,
186
+ });
187
+
188
+ if (json) {
189
+ printFindingsJson({ ok: res.ok, ...res });
190
+ } else {
191
+ if (!res.ok && res.error) {
192
+ console.error(res.error);
193
+ } else {
194
+ console.log(`npm ci exit: ${res.npmExit}`);
195
+ if (res.disallowedDns?.length) {
196
+ console.error("DNS allowlist violations:");
197
+ for (const h of res.disallowedDns) console.error(` - ${h}`);
198
+ }
199
+ if (res.npmExit !== 0) {
200
+ console.error("npm ci failed inside sandbox. Last log lines:");
201
+ console.error(res.npmLogTail || "(no log)");
202
+ }
203
+ if (res.ok) {
204
+ console.log("Sandbox OK: npm ci succeeded and DNS within allowlist.");
205
+ if (res.sshMounted) console.log("(SSH keys mounted from host for git+ssh dependencies.)");
206
+ }
207
+ }
208
+ }
209
+ process.exitCode = res.ok ? 0 : 1;
210
+ }
211
+
212
+ async function cmdGate(flags) {
213
+ const cwd = flags.cwd || process.cwd();
214
+ const json = !!flags.json;
215
+ const minSeverity = flags["min-severity"] || "low";
216
+
217
+ let result;
218
+ try {
219
+ result = await runCheck({
220
+ cwd,
221
+ minSeverity,
222
+ noOsv: !!flags["no-osv"],
223
+ offline: !!flags.offline,
224
+ withBaselineDiff: true,
225
+ npmAudit: !!flags["npm-audit"],
226
+ });
227
+ } catch (e) {
228
+ console.error(e.message || e);
229
+ process.exitCode = 2;
230
+ return;
231
+ }
232
+
233
+ const signalErrors = (result.signals || []).filter((s) => s.severity === "error");
234
+ const checkFail = result.findings.length > 0 || signalErrors.length > 0;
235
+
236
+ if (checkFail) {
237
+ if (json) printFindingsJson({ gate: "failed", stage: "check", ...result });
238
+ else printFindingsHuman(result);
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+
243
+ if (flags["require-sandbox"]) {
244
+ cmdSandbox({ ...flags, cwd, json: false });
245
+ return;
246
+ }
247
+
248
+ if (json) printFindingsJson({ gate: "ok", stage: "check", packagesScanned: result.packagesScanned });
249
+ else console.log("Gate OK (check + baseline diff if baseline present).");
250
+ process.exitCode = 0;
251
+ }
252
+
253
+ async function main() {
254
+ const { _, flags } = parseArgs(process.argv);
255
+ const cmd = _[0] || "check";
256
+
257
+ try {
258
+ if (cmd === "check") await cmdCheck(flags);
259
+ else if (cmd === "baseline") await cmdBaseline(flags, _[1]);
260
+ else if (cmd === "sandbox") cmdSandbox(flags);
261
+ else if (cmd === "gate") await cmdGate(flags);
262
+ else if (cmd === "help" || cmd === "--help" || flags.help) {
263
+ console.log(`
264
+ npm-sentinel — static gate + Docker sandbox for npm supply-chain risk
265
+
266
+ Commands:
267
+ check Lockfile + OSV + offline IOCs; optional baseline diff (--baseline)
268
+ baseline save Write .npm-sentinel-baseline.json from current lockfile + registry metadata
269
+ baseline diff Compare current tree vs baseline (signals only)
270
+ sandbox Run npm ci inside Docker with DNS capture + allowlist
271
+ gate Run check (with baseline diff) and optionally --require-sandbox
272
+
273
+ Flags:
274
+ --cwd <dir> Project root (default: cwd)
275
+ --json JSON output
276
+ --min-severity low|moderate|high|critical (default: low)
277
+ --no-osv Skip OSV API
278
+ --offline Skip OSV (offline IOCs still apply)
279
+ --baseline With check: run baseline diff if baseline file exists
280
+ --npm-audit Also run npm audit --json
281
+ --require-sandbox With gate: run Docker sandbox after check
282
+ --no-build sandbox: skip docker build (reuse image)
283
+ --mount-ssh sandbox: mount host ~/.ssh read-only at /root/.ssh (git+ssh)
284
+ --ssh-dir <path> sandbox: mount this directory instead of ~/.ssh (implies SSH mount)
285
+ --baseline-file Alternate baseline path
286
+
287
+ Config (npm-sentinel.config.json): sandbox.mountSsh, sandbox.sshDir, dnsAllowlist.mode (merge|replace)
288
+
289
+ preinstall (host, static only):
290
+ "preinstall": "npx --yes npm-sentinel@latest check --baseline"
291
+ `);
292
+ process.exitCode = 0;
293
+ } else {
294
+ console.error(`Unknown command: ${cmd}`);
295
+ process.exitCode = 2;
296
+ }
297
+ } catch (e) {
298
+ console.error(e.message || e);
299
+ process.exitCode = 2;
300
+ }
301
+ }
302
+
303
+ main();
@@ -0,0 +1,15 @@
1
+ FROM node:20-bookworm-slim
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends \
5
+ tcpdump \
6
+ ca-certificates \
7
+ git \
8
+ openssh-client \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY entrypoint-sandbox.sh /entrypoint-sandbox.sh
12
+ RUN chmod +x /entrypoint-sandbox.sh
13
+
14
+ WORKDIR /work
15
+ ENTRYPOINT ["/entrypoint-sandbox.sh"]
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # /src: read-only project mount, /out: writable output (dns log, npm log)
5
+ rm -rf /work/*
6
+ cp -a /src/. /work/
7
+ cd /work
8
+
9
+ : "${NPM_CI_FLAGS:=}"
10
+
11
+ DNS_LOG="${DNS_LOG:-/out/dns.log}"
12
+ NPM_LOG="${NPM_LOG:-/out/npm.log}"
13
+
14
+ touch "$DNS_LOG" "$NPM_LOG" 2>/dev/null || true
15
+
16
+ tcpdump -i any -n -l -tttt 'udp port 53' >>"$DNS_LOG" 2>/dev/null &
17
+ TPID=$!
18
+
19
+ cleanup() {
20
+ kill "$TPID" 2>/dev/null || true
21
+ wait "$TPID" 2>/dev/null || true
22
+ }
23
+ trap cleanup EXIT
24
+
25
+ set +e
26
+ npm ci $NPM_CI_FLAGS >"$NPM_LOG" 2>&1
27
+ NPM_EXIT=$?
28
+ set -e
29
+
30
+ cleanup
31
+ trap - EXIT
32
+
33
+ echo "$NPM_EXIT" >/out/npm-exit.code
34
+ exit "$NPM_EXIT"
package/docs/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # npm-sentinel documentation
2
+
3
+ Command references below match the CLI in `bin/cli.js`. The **[main README](../README.md)** is the quick-start and cheat sheet.
4
+
5
+ ## Command reference
6
+
7
+ | Command | Guide |
8
+ |--------|--------|
9
+ | **`check`** | [commands/check.md](commands/check.md) — lockfile scan (OSV, offline IOCs), optional baseline diff and npm audit |
10
+ | **`baseline`** | [commands/baseline.md](commands/baseline.md) — `save` and `diff` for trusted-tree snapshots |
11
+ | **`sandbox`** | [commands/sandbox.md](commands/sandbox.md) — Docker `npm ci` with DNS capture and allowlist |
12
+ | **`gate`** | [commands/gate.md](commands/gate.md) — CI-friendly check + optional sandbox |
13
+ | **`help`** | [commands/help.md](commands/help.md) — built-in help text |
14
+
15
+ ## Shared configuration
16
+
17
+ - **Project root:** `--cwd <dir>` (default: current directory).
18
+ - **Config files:** `npm-sentinel.config.json` or `.npm-sentinelsrc.json` — see [reference/config.md](reference/config.md).
19
+ - **Global flags:** [reference/flags.md](reference/flags.md) summarizes all CLI flags by command.
20
+
21
+ ## Testing
22
+
23
+ See **[testing.md](testing.md)** for links to each command’s testing section and how to run **`npm test`** in this repo.
24
+
25
+ ## Related topics
26
+
27
+ - Supply-chain background (e.g. lifecycle-based attacks): see the main README’s “Related reading” and [SECURITY.md](../SECURITY.md).