@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.
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/SECURITY.md +17 -0
- package/bin/cli.js +303 -0
- package/docker/Dockerfile.sandbox +15 -0
- package/docker/entrypoint-sandbox.sh +34 -0
- package/docs/README.md +27 -0
- package/docs/commands/baseline.md +104 -0
- package/docs/commands/check.md +92 -0
- package/docs/commands/gate.md +70 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/sandbox.md +101 -0
- package/docs/reference/config.md +39 -0
- package/docs/reference/flags.md +45 -0
- package/docs/testing.md +19 -0
- package/lib/baseline.js +83 -0
- package/lib/config.js +49 -0
- package/lib/diff-signals.js +99 -0
- package/lib/dns-allowlist-default.json +27 -0
- package/lib/merge-findings.js +66 -0
- package/lib/offline-iocs.json +17 -0
- package/lib/osv-client.js +97 -0
- package/lib/packument.js +59 -0
- package/lib/parse-npm-lockfile.js +144 -0
- package/lib/sandbox/dns-parse.js +54 -0
- package/lib/sandbox/docker-runner.js +211 -0
- package/lib/scan.js +156 -0
- package/package.json +45 -0
package/CONTRIBUTING.md
ADDED
|
@@ -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).
|