@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Command: `baseline`
|
|
2
|
+
|
|
3
|
+
**Syntax:**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm-sentinel baseline save [flags]
|
|
7
|
+
npm-sentinel baseline diff [flags]
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## What “baseline” means here
|
|
11
|
+
|
|
12
|
+
A **saved snapshot** of your **trusted** dependency picture for **watched** packages only:
|
|
13
|
+
|
|
14
|
+
- **Watched packages** = names from root `package.json` **`dependencies`**, **`devDependencies`**, **`optionalDependencies`**, **`peerDependencies`**, plus **`watchPackagesExtra`**, or **`watchPackagesOverride`** if set (`lib/config.js`).
|
|
15
|
+
- For each watched name that **resolves** in the lockfile, the snapshot stores:
|
|
16
|
+
- **Resolved version**
|
|
17
|
+
- **Direct dependency names** of that package in the tree (children in lockfile)
|
|
18
|
+
- **Lifecycle script keys** from the registry **packument** for that name at that version (`preinstall`, `install`, `postinstall`, etc., summarized in `lib/packument.js`)
|
|
19
|
+
|
|
20
|
+
Default file: **`.npm-sentinel-baseline.json`** (override with `--baseline-file`).
|
|
21
|
+
|
|
22
|
+
## `baseline save`
|
|
23
|
+
|
|
24
|
+
### Behavior
|
|
25
|
+
|
|
26
|
+
1. Requires **`package-lock.json`** — otherwise exits **2**.
|
|
27
|
+
2. Loads `package.json`, config, lockfile; computes **`watchNames`**.
|
|
28
|
+
3. For each watched package, **`buildBaselineSnapshot`** (`lib/baseline.js`) resolves the package in the lockfile and **fetches** version metadata from the registry (needs **network**).
|
|
29
|
+
4. Writes JSON snapshot; prints path and count of watched packages (or JSON with `ok`, `path`, `snapshot` if `--json`).
|
|
30
|
+
|
|
31
|
+
### Exit codes
|
|
32
|
+
|
|
33
|
+
| Code | Meaning |
|
|
34
|
+
|------|---------|
|
|
35
|
+
| **0** | Snapshot written. |
|
|
36
|
+
| **2** | Missing lockfile or other failure. |
|
|
37
|
+
|
|
38
|
+
### How to test
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd /path/to/your-app # has package.json + package-lock.json
|
|
42
|
+
npx npm-sentinel baseline save
|
|
43
|
+
git add .npm-sentinel-baseline.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Verify the file exists and lists expected packages under **`packages`**.
|
|
47
|
+
|
|
48
|
+
Use **`--baseline-file /tmp/test-baseline.json`** in throwaway dirs.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## `baseline diff`
|
|
53
|
+
|
|
54
|
+
### Behavior
|
|
55
|
+
|
|
56
|
+
1. Loads baseline (default path). If **missing**, prints hint and exits **2**.
|
|
57
|
+
2. Requires **`package-lock.json`** — otherwise exits **2**.
|
|
58
|
+
3. Recomputes **`watchNames`** from **current** `package.json` + config.
|
|
59
|
+
4. **`diffAgainstBaseline`** (`lib/diff-signals.js`): for each watched package that existed in the saved baseline, compares saved state vs **current** lockfile + **current** registry scripts.
|
|
60
|
+
|
|
61
|
+
### Signal types
|
|
62
|
+
|
|
63
|
+
| `type` | Typical `severity` | Meaning |
|
|
64
|
+
|--------|-------------------|---------|
|
|
65
|
+
| `missing_resolution` | **error** | Watched package no longer resolves under `node_modules` in the lockfile. |
|
|
66
|
+
| `version_change` | **warn** | Resolved version changed vs baseline. |
|
|
67
|
+
| `new_dependency` | **error** | New **direct** child dependency on the watched package (possible injection). |
|
|
68
|
+
| `dependency_removed` | **warn** | A direct child was removed (account takeover / metadata oddity — can be noisy). |
|
|
69
|
+
| `new_lifecycle_script` | **error** | Registry scripts for this name@version include a lifecycle key not present in baseline. |
|
|
70
|
+
|
|
71
|
+
Messages and `detail` objects are set in **`lib/diff-signals.js`**.
|
|
72
|
+
|
|
73
|
+
### Output
|
|
74
|
+
|
|
75
|
+
- Human: one line per signal, prefixed with `[error]` or `[warn]`, or **“No baseline drift detected.”**
|
|
76
|
+
- **`--json`:** `{ "ok": <no error signals>, "signals": [...] }`
|
|
77
|
+
|
|
78
|
+
### Exit codes
|
|
79
|
+
|
|
80
|
+
| Code | Meaning |
|
|
81
|
+
|------|---------|
|
|
82
|
+
| **0** | No signals **or** only **`warn`** signals. |
|
|
83
|
+
| **1** | At least one **`error`** signal. |
|
|
84
|
+
| **2** | No baseline file, missing lockfile, etc. |
|
|
85
|
+
|
|
86
|
+
**Note:** **`warn` alone does not fail** `baseline diff`.
|
|
87
|
+
|
|
88
|
+
### How to test
|
|
89
|
+
|
|
90
|
+
1. `baseline save` on a known-good tree.
|
|
91
|
+
2. **Version bump:** `npm install lodash@<other-version>` (if lodash is a root dep), then `baseline diff` → expect **`version_change`** (warn).
|
|
92
|
+
3. **New transitive shape:** Upgrade a root dep to a release that adds a **new direct** child on that package (e.g. supply-chain style) → **`new_dependency`** (error).
|
|
93
|
+
4. **Clean run:** Immediately `diff` after `save` with no edits → **no drift**, exit **0**.
|
|
94
|
+
|
|
95
|
+
Combining with **`check`:** use **`check --baseline`** so vulnerability findings and baseline errors fail one command together ([check.md](check.md)).
|
|
96
|
+
|
|
97
|
+
## Flags
|
|
98
|
+
|
|
99
|
+
`--cwd`, `--json`, `--baseline-file` — see [../reference/flags.md](../reference/flags.md).
|
|
100
|
+
|
|
101
|
+
## Requirements
|
|
102
|
+
|
|
103
|
+
- **`package-lock.json`** (lockfile v1/v2/v3 supported by `lib/parse-npm-lockfile.js`).
|
|
104
|
+
- **Network** for `save` and for `diff` when comparing **lifecycle scripts** (packument fetch for current versions).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Command: `check`
|
|
2
|
+
|
|
3
|
+
**Syntax:** `npm-sentinel check [flags]`
|
|
4
|
+
**Default:** If you run `npm-sentinel` with no subcommand, it runs **`check`** (`bin/cli.js`).
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
Runs the **static** pipeline (no `npm install` / `npm ci` on your machine):
|
|
9
|
+
|
|
10
|
+
1. **Reads** `package.json` and **`package-lock.json`** in the project root (`--cwd`). Missing either file throws and exits **2**.
|
|
11
|
+
2. **Parses** the lockfile and collects every unique **`(package name, resolved version)`** pair.
|
|
12
|
+
3. **Findings (vulnerability / IOC):**
|
|
13
|
+
- **OSV** — unless `--offline` or `--no-osv`, POSTs batches to `https://api.osv.dev/v1/querybatch` (npm ecosystem). Each row with matching vulns becomes a finding (`source: osv`).
|
|
14
|
+
- **Offline IOCs** — **always** evaluated from `lib/offline-iocs.json`: exact name + version matches (`source: offline-ioc`, severity **critical** for filtering purposes).
|
|
15
|
+
- **`--min-severity`** filters findings (low → critical scale in `lib/merge-findings.js`).
|
|
16
|
+
4. **Baseline (optional):** With **`--baseline`**, if **`.npm-sentinel-baseline.json`** exists under **`--cwd`**, runs **`diffAgainstBaseline`** and appends **signals**. Only the default baseline filename is supported on this code path (see [baseline.md](baseline.md), [gate.md](gate.md)).
|
|
17
|
+
5. **`--npm-audit`:** Spawns **`npm audit --json`** in the project. Results are attached to the output as **`npmAuditFindings`** for display/JSON.
|
|
18
|
+
|
|
19
|
+
## Output
|
|
20
|
+
|
|
21
|
+
- **Human:** Count of scanned pairs, list of findings (name, version, source, severity, ids, optional summary), baseline signals, short npm-audit summary (first ~20 entries).
|
|
22
|
+
- **`--json`:** Single object including `ok` (**true** only when no findings **and** no baseline **error**-severity signals), `findings`, `signals`, `npmAuditFindings`, `watchNames`, `packagesScanned`, `cwd`.
|
|
23
|
+
|
|
24
|
+
## Exit codes
|
|
25
|
+
|
|
26
|
+
| Code | Meaning |
|
|
27
|
+
|------|---------|
|
|
28
|
+
| **0** | No findings after severity filter **and** no baseline signals with **`severity === "error"`**. |
|
|
29
|
+
| **1** | At least one finding **or** at least one **error** baseline signal. |
|
|
30
|
+
| **2** | Missing `package.json` / `package-lock.json`, OSV/network failure, or other thrown error. |
|
|
31
|
+
|
|
32
|
+
**Note:** **`npm audit` problems do not set exit code 1** by themselves. Only **`findings`** (OSV/IOC) and **baseline error signals** affect success for `check`.
|
|
33
|
+
|
|
34
|
+
## Flags (summary)
|
|
35
|
+
|
|
36
|
+
See [../reference/flags.md](../reference/flags.md). Common: `--cwd`, `--json`, `--min-severity`, `--no-osv`, `--offline`, `--baseline`, `--npm-audit`.
|
|
37
|
+
|
|
38
|
+
## How to test
|
|
39
|
+
|
|
40
|
+
### Smoke: help and happy path
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm-sentinel check --cwd /path/to/project
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use a project with a clean lockfile and no baseline errors — expect exit **0** (or findings if deps are vulnerable).
|
|
47
|
+
|
|
48
|
+
### IOC / offline path (no OSV)
|
|
49
|
+
|
|
50
|
+
Use a fixture lockfile that pins a version listed in **`lib/offline-iocs.json`** (e.g. `axios@1.14.1`). This repo’s test uses **`test/fixtures/lock-v3-mini.json`** copied into a temp dir:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node bin/cli.js check --cwd /tmp/test-proj --offline --no-osv
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Expect exit **1** and output mentioning the IOC package.
|
|
57
|
+
|
|
58
|
+
### OSV path
|
|
59
|
+
|
|
60
|
+
Install an old version with a known advisory, ensure **`package-lock.json`** pins it, run **`check`** without `--offline`. Expect findings if OSV has the CVE.
|
|
61
|
+
|
|
62
|
+
### Severity filter
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm-sentinel check --min-severity high
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Low/moderate-only findings are dropped; exit **0** if nothing meets the threshold.
|
|
69
|
+
|
|
70
|
+
### Baseline integration
|
|
71
|
+
|
|
72
|
+
1. `npm-sentinel baseline save`
|
|
73
|
+
2. Change a watched dependency’s resolved version in the lockfile.
|
|
74
|
+
3. `npm-sentinel check --baseline`
|
|
75
|
+
|
|
76
|
+
Expect **baseline signals** in output; exit **1** if any signal is **`error`** (e.g. `new_dependency`, `new_lifecycle_script`).
|
|
77
|
+
|
|
78
|
+
### npm audit attachment
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm-sentinel check --npm-audit --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Inspect **`npmAuditFindings`** in JSON; exit code still follows findings/baseline rules only.
|
|
85
|
+
|
|
86
|
+
## Typical CI usage
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx npm-sentinel@latest check --baseline
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pair with a committed **`.npm-sentinel-baseline.json`** after an intentional `baseline save`.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Command: `gate`
|
|
2
|
+
|
|
3
|
+
**Syntax:** `npm-sentinel gate [flags]`
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
A **CI-oriented** orchestration of **static check** and **optional sandbox**:
|
|
8
|
+
|
|
9
|
+
1. Runs **`runCheck`** with **`withBaselineDiff: true`** always. If **`.npm-sentinel-baseline.json`** exists in the project root, baseline diff runs the same way as **`check --baseline`**.
|
|
10
|
+
|
|
11
|
+
**Baseline path limitation:** `runCheck` calls **`loadBaseline(cwd)`** with no custom path (`lib/scan.js`). Only the default filename **`.npm-sentinel-baseline.json`** is loaded for **`check --baseline`** and **`gate`**. The **`--baseline-file`** flag applies to **`baseline save`** / **`baseline diff`** only. For a custom path with static check, the CLI would need a small extension.
|
|
12
|
+
|
|
13
|
+
2. **Failure (stage `check`):** If **`findings.length > 0`** **or** any baseline signal with **`severity === "error"`**, prints result (human or JSON with `gate: "failed", stage: "check"`), exits **1**.
|
|
14
|
+
|
|
15
|
+
3. **Success + `--require-sandbox`:** Calls **`cmdSandbox`** with the same `flags` **but forces `json: false`** for the sandbox step — sandbox output is always human-readable even if you passed `--json` to `gate`.
|
|
16
|
+
|
|
17
|
+
4. **Success without sandbox:** Prints **“Gate OK …”** or minimal JSON with `gate: "ok"`, exits **0**.
|
|
18
|
+
|
|
19
|
+
## Flags
|
|
20
|
+
|
|
21
|
+
Inherits **check-related** flags: `--cwd`, `--json`, `--min-severity`, `--no-osv`, `--offline`, `--npm-audit`, plus **`--require-sandbox`**.
|
|
22
|
+
|
|
23
|
+
Sandbox flags **`--no-build`**, **`--mount-ssh`**, **`--ssh-dir`** apply when **`--require-sandbox`** is set (passed through to **`cmdSandbox`**).
|
|
24
|
+
|
|
25
|
+
**Note:** There is **no** `--baseline` flag on `gate` — baseline diff is **on by default** when a baseline file is present.
|
|
26
|
+
|
|
27
|
+
## Exit codes
|
|
28
|
+
|
|
29
|
+
| Code | Meaning |
|
|
30
|
+
|------|---------|
|
|
31
|
+
| **0** | Check passed (no findings, no baseline **error** signals) and, if requested, sandbox **`ok`**. |
|
|
32
|
+
| **1** | Check failed **or** sandbox failed. |
|
|
33
|
+
| **2** | Thrown error from **`runCheck`** (e.g. missing `package.json` / lockfile). |
|
|
34
|
+
|
|
35
|
+
## Output (`--json`)
|
|
36
|
+
|
|
37
|
+
- On check failure: `{ "gate": "failed", "stage": "check", ...fullResult }`
|
|
38
|
+
- On check success without sandbox: `{ "gate": "ok", "stage": "check", "packagesScanned": N }`
|
|
39
|
+
- Sandbox stage does not add JSON when **`--json`** was used (sandbox runs non-JSON).
|
|
40
|
+
|
|
41
|
+
## How to test
|
|
42
|
+
|
|
43
|
+
### Static gate only
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx npm-sentinel gate --cwd /path/to/project
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With no baseline file: behaves like **`check`** without baseline. With baseline: includes diff signals.
|
|
50
|
+
|
|
51
|
+
### Full gate with sandbox
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx npm-sentinel gate --require-sandbox --no-build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Docker; static check must pass first.
|
|
58
|
+
|
|
59
|
+
### Simulate failure
|
|
60
|
+
|
|
61
|
+
Introduce an IOC version or baseline **error** (e.g. **`new_dependency`**) and run **`gate`** — expect exit **1** before any sandbox runs.
|
|
62
|
+
|
|
63
|
+
## When to use vs `check`
|
|
64
|
+
|
|
65
|
+
| Use **`check`** | Use **`gate`** |
|
|
66
|
+
|-----------------|----------------|
|
|
67
|
+
| Local dev, flexible flags (`--baseline` explicit, no baseline) | CI job that should always consider baseline if present |
|
|
68
|
+
| You never want sandbox | One command: **`gate --require-sandbox`** after tests |
|
|
69
|
+
|
|
70
|
+
If you need **`--baseline-file`** with **`gate`**, the current implementation may not support it on the **`runCheck`** path; use **`check --baseline --baseline-file …`** until the CLI is extended.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Command: `help`
|
|
2
|
+
|
|
3
|
+
**Syntax:**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm-sentinel help
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Note:** `npm-sentinel --help` is parsed as **flags** only; the CLI currently dispatches **`check`** before it evaluates `flags.help`, so **`--help` alone may still run `check`** in your tree. Prefer **`npm-sentinel help`** for the usage banner (or `npm-sentinel help` after we reorder dispatch if that gets fixed).
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
Prints a short reference listing **commands**, **flags**, **config** keys, and an example **`preinstall`** one-liner. **Does not** touch `package-lock.json` or network.
|
|
14
|
+
|
|
15
|
+
## Exit code
|
|
16
|
+
|
|
17
|
+
**0**
|
|
18
|
+
|
|
19
|
+
## How to test
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node bin/cli.js help
|
|
23
|
+
npx npm-sentinel help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Expect the usage banner from `bin/cli.js` (~lines 263–291).
|
|
27
|
+
|
|
28
|
+
For full documentation, use this **`docs/`** tree and the [main README](../../README.md).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Command: `sandbox`
|
|
2
|
+
|
|
3
|
+
**Syntax:** `npm-sentinel sandbox [flags]`
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Runs a **real `npm ci`** inside **Docker** so install **lifecycle scripts** execute in an isolated Linux environment (not on your host). Optionally records **DNS** (UDP port 53) and enforces an **allowlist**.
|
|
8
|
+
|
|
9
|
+
Implementation: **`runSandbox`** in `lib/sandbox/docker-runner.js` + image `docker/Dockerfile.sandbox` + `docker/entrypoint-sandbox.sh`.
|
|
10
|
+
|
|
11
|
+
### High-level flow
|
|
12
|
+
|
|
13
|
+
1. Verifies **`package-lock.json`** exists at `--cwd`.
|
|
14
|
+
2. If **`--mount-ssh`** / **`--ssh-dir`** / config SSH path: resolves mount; errors if path missing.
|
|
15
|
+
3. Runs **`docker info`** — fails if Docker is unavailable.
|
|
16
|
+
4. Unless **`--no-build`**, builds image **`npm-sentinel-sandbox:local`** from `docker/Dockerfile.sandbox`.
|
|
17
|
+
5. **Runs container** with:
|
|
18
|
+
- Project mounted **read-only** at `/src`, copied to `/work`
|
|
19
|
+
- **`tcpdump`** on `udp port 53` appended to `/out/dns.log`
|
|
20
|
+
- **`npm ci`** in `/work` (stdout/stderr → `/out/npm.log`)
|
|
21
|
+
- Optional: mount host SSH keys for **`git+ssh`**
|
|
22
|
+
6. After the container exits, the CLI reads **`dns.log`**, **`npm.log`**, **`npm-exit.code`**, runs **`extractHostsFromTcpdump`** + **`filterDisallowedHosts`** (`lib/sandbox/dns-parse.js`) using **`resolveDnsAllowlist`** (`lib/sandbox/docker-runner.js`): built-in defaults **`merge`**d with config, or config-only when **`dnsAllowlist.mode`** is **`replace`**.
|
|
23
|
+
|
|
24
|
+
### Success condition
|
|
25
|
+
|
|
26
|
+
**`ok`** is true only when:
|
|
27
|
+
|
|
28
|
+
- **`npm ci`** exit code is **0**, **and**
|
|
29
|
+
- **No** extracted DNS hostname is outside the merged allowlist.
|
|
30
|
+
|
|
31
|
+
Either failure yields CLI exit **1** (unless preflight errors — see below).
|
|
32
|
+
|
|
33
|
+
## Output (human)
|
|
34
|
+
|
|
35
|
+
- Preflight failure: **`res.error`** on stderr (lockfile, Docker, SSH path, docker build).
|
|
36
|
+
- Otherwise: **`npm ci exit:`** code, optional **DNS allowlist violations** list, optional npm log tail on install failure, **“Sandbox OK”** when `ok`.
|
|
37
|
+
|
|
38
|
+
## Output (`--json`)
|
|
39
|
+
|
|
40
|
+
Object includes at least: **`ok`**, **`npmExit`**, **`npmLogTail`**, **`dnsHostsSample`**, **`disallowedDns`**, **`sshMounted`**, and on failure **`error`** may be set instead of npm/DNS fields.
|
|
41
|
+
|
|
42
|
+
Use **`dnsHostsSample`** to debug empty violations (parser/capture issues).
|
|
43
|
+
|
|
44
|
+
## Exit codes
|
|
45
|
+
|
|
46
|
+
| Code | Meaning |
|
|
47
|
+
|------|---------|
|
|
48
|
+
| **0** | `res.ok` — install succeeded and DNS within allowlist. |
|
|
49
|
+
| **1** | DNS violations, `npm ci` failure, or any `ok: false` result. |
|
|
50
|
+
| *(preflight)* **1** | Same exit mapping: `res.ok` false includes `error` string cases. |
|
|
51
|
+
|
|
52
|
+
**Note:** The entrypoint exits with **`npm ci`’s code** inside the container; the **CLI** still treats non-zero npm or DNS violations as failure.
|
|
53
|
+
|
|
54
|
+
## Flags
|
|
55
|
+
|
|
56
|
+
`--cwd`, `--json`, `--no-build`, `--mount-ssh`, `--ssh-dir` — see [../reference/flags.md](../reference/flags.md) and [../reference/config.md](../reference/config.md).
|
|
57
|
+
|
|
58
|
+
## Limits & caveats
|
|
59
|
+
|
|
60
|
+
- **Linux image** (`node:20-bookworm-slim`): native addons built for macOS/Windows may **fail** `npm ci` here even when local install works.
|
|
61
|
+
- **DNS parsing** only recognizes certain **tcpdump** line shapes (`A?` / `AAAA?` / `CNAME?` patterns in `dns-parse.js`).
|
|
62
|
+
- **Broad default allowlist** (`lib/dns-allowlist-default.json`): malware using allowed CDNs may **not** trigger DNS violations.
|
|
63
|
+
- **`tcpdump` stderr** is discarded in the entrypoint; silent capture failures can yield **empty** host sets.
|
|
64
|
+
|
|
65
|
+
## How to test
|
|
66
|
+
|
|
67
|
+
### Happy path
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cd /path/to/small-js-project # valid package-lock.json
|
|
71
|
+
docker info # ensure daemon is up
|
|
72
|
+
npx npm-sentinel sandbox
|
|
73
|
+
# Second run:
|
|
74
|
+
npx npm-sentinel sandbox --no-build
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Expect **Sandbox OK** and exit **0**.
|
|
78
|
+
|
|
79
|
+
### Force `npm ci` failure
|
|
80
|
+
|
|
81
|
+
Make `package.json` and `package-lock.json` **out of sync** (add a dependency in `package.json` without updating the lockfile). Run **`sandbox`** → expect non-zero **`npm ci exit`** and log tail.
|
|
82
|
+
|
|
83
|
+
### Force DNS violation (controlled)
|
|
84
|
+
|
|
85
|
+
Add a root **`preinstall`** / **`postinstall`** that runs **`require('node:dns').resolve4('example.com', …)`**, run **`npm install`** to refresh the lockfile, then **`sandbox`**. If capture + parser see **`example.com`**, expect **DNS allowlist violations** (see `lib/dns-allowlist-default.json`).
|
|
86
|
+
|
|
87
|
+
Avoid **`dns.lookupSync`** on Node 25+ (removed); use **`resolve4`** or callback **`lookup`**.
|
|
88
|
+
|
|
89
|
+
### Private git dependencies
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx npm-sentinel sandbox --mount-ssh
|
|
93
|
+
# or
|
|
94
|
+
npx npm-sentinel sandbox --ssh-dir /path/to/deploy-keys
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
See main README **Docker & SSH** / macOS **UseKeychain** notes.
|
|
98
|
+
|
|
99
|
+
## Relation to `check`
|
|
100
|
+
|
|
101
|
+
**`sandbox`** does **not** run OSV or offline IOCs. Use **`check`** (or **`gate`**) for static findings; use **`sandbox`** when you need **install-time behavior** evidence.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Configuration file
|
|
2
|
+
|
|
3
|
+
Optional. First matching file in the project root (`--cwd`) wins:
|
|
4
|
+
|
|
5
|
+
1. `npm-sentinel.config.json`
|
|
6
|
+
2. `.npm-sentinelsrc.json`
|
|
7
|
+
|
|
8
|
+
Parsed as JSON in `lib/config.js`. Invalid JSON yields an empty object for that file.
|
|
9
|
+
|
|
10
|
+
## Keys
|
|
11
|
+
|
|
12
|
+
| Key | Type | Used by | Description |
|
|
13
|
+
|-----|------|---------|-------------|
|
|
14
|
+
| `watchPackagesExtra` | `string[]` | baseline, `check --baseline`, `gate` | Add package names to the **watch list** in addition to root `dependencies` / `devDependencies` / optional / peer deps. |
|
|
15
|
+
| `watchPackagesOverride` | `string[]` \| null | same | If set, **replace** the default watch list entirely (only these names are watched). |
|
|
16
|
+
| `dnsAllowlist` | object | `sandbox` | **`mode`** optional: **`merge`** (default) — defaults from `lib/dns-allowlist-default.json` **plus** your `suffixes` / `exactHosts`; **`replace`** — **only** your lists (no built-in suffixes/hosts). Invalid `mode` values behave as **`merge`**. |
|
|
17
|
+
| `sandbox.mountSsh` | boolean | `sandbox` | Same as CLI `--mount-ssh`. |
|
|
18
|
+
| `sandbox.sshDir` | string | `sandbox` | Same as `--ssh-dir`; wins over `mountSsh` when set. |
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"watchPackagesExtra": ["some-transitive-parent"],
|
|
25
|
+
"watchPackagesOverride": null,
|
|
26
|
+
"dnsAllowlist": {
|
|
27
|
+
"mode": "merge",
|
|
28
|
+
"suffixes": ["my-registry.example.com"],
|
|
29
|
+
"exactHosts": []
|
|
30
|
+
},
|
|
31
|
+
"sandbox": {
|
|
32
|
+
"mountSsh": true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Strict allowlist (`replace`)
|
|
38
|
+
|
|
39
|
+
For a minimal allowlist (e.g. private registry only), set **`"mode": "replace"`** and list every **suffix** and **exact host** your `npm ci` legitimately resolves (often start by copying defaults from `lib/dns-allowlist-default.json`, then trim). Empty **`suffixes`** / **`exactHosts`** under **`replace`** allow **no** names by those rules until you add them.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# CLI flags reference
|
|
2
|
+
|
|
3
|
+
Parsed in `bin/cli.js` (`parseArgs`). Boolean flags can appear as `--flag` or `--flag=true`-style where noted.
|
|
4
|
+
|
|
5
|
+
## All commands
|
|
6
|
+
|
|
7
|
+
| Flag | Type | Applies to | Description |
|
|
8
|
+
|------|------|------------|-------------|
|
|
9
|
+
| `--cwd <dir>` | path | all | Project root; must contain `package.json` (and usually `package-lock.json`). |
|
|
10
|
+
| `--json` | boolean | `check`, `baseline`, `sandbox`, `gate` | Machine-readable JSON on stdout (shape varies by command). |
|
|
11
|
+
| `--help` / `help` | — | `npm-sentinel help` | Prints usage and exits 0. |
|
|
12
|
+
|
|
13
|
+
## `check` and `gate`
|
|
14
|
+
|
|
15
|
+
| Flag | Default | Description |
|
|
16
|
+
|------|---------|-------------|
|
|
17
|
+
| `--min-severity` | `low` | Filter findings: `low`, `moderate`, `high`, `critical`. |
|
|
18
|
+
| `--no-osv` | off | Do not call the OSV batch API. |
|
|
19
|
+
| `--offline` | off | Skip OSV entirely (offline IOCs in `lib/offline-iocs.json` still apply). |
|
|
20
|
+
| `--baseline` | off | **`check` only:** if **`.npm-sentinel-baseline.json`** exists, run baseline diff and merge signals into output. Custom paths are **not** wired into `runCheck` / **`gate`**. |
|
|
21
|
+
| `--npm-audit` | off | Run `npm audit --json` in the project and attach summary to results (**does not** change exit code by itself; see [check.md](../commands/check.md)). |
|
|
22
|
+
|
|
23
|
+
## `gate` only
|
|
24
|
+
|
|
25
|
+
| Flag | Description |
|
|
26
|
+
|------|-------------|
|
|
27
|
+
| `--require-sandbox` | After a successful check, run **`sandbox`** (Docker). **`gate` forces `json: false` for the sandbox stage** even if `--json` was passed. |
|
|
28
|
+
|
|
29
|
+
## `sandbox` only
|
|
30
|
+
|
|
31
|
+
| Flag | Description |
|
|
32
|
+
|------|-------------|
|
|
33
|
+
| `--no-build` | Skip `docker build`; reuse existing `npm-sentinel-sandbox:local` image. |
|
|
34
|
+
| `--mount-ssh` | Mount host `~/.ssh` read-only at `/root/.ssh` in the container. |
|
|
35
|
+
| `--ssh-dir <path>` | Mount this directory instead of `~/.ssh` (for `git+ssh` deps). |
|
|
36
|
+
|
|
37
|
+
Config can also set `sandbox.mountSsh`, `sandbox.sshDir`, and `dnsAllowlist` (see [config.md](config.md)).
|
|
38
|
+
|
|
39
|
+
## `baseline` subcommands
|
|
40
|
+
|
|
41
|
+
| Flag | Description |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `--baseline-file <path>` | Read/write baseline JSON at this path instead of **`.npm-sentinel-baseline.json`**. |
|
|
44
|
+
|
|
45
|
+
`baseline save` and `baseline diff` also accept `--cwd` and `--json`. (**Not** passed through from `check` / `gate` today.)
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Testing npm-sentinel
|
|
2
|
+
|
|
3
|
+
End-to-end ideas are split by command so they stay next to behavior and exit codes:
|
|
4
|
+
|
|
5
|
+
| Goal | Doc |
|
|
6
|
+
|------|-----|
|
|
7
|
+
| Static scan, OSV, IOCs, severity, `npm audit` | [commands/check.md — How to test](commands/check.md#how-to-test) |
|
|
8
|
+
| Baseline save/diff, signal types | [commands/baseline.md — How to test](commands/baseline.md#how-to-test) |
|
|
9
|
+
| Docker `npm ci`, DNS allowlist, SSH | [commands/sandbox.md — How to test](commands/sandbox.md#how-to-test) |
|
|
10
|
+
| CI gate + optional sandbox | [commands/gate.md — How to test](commands/gate.md#how-to-test) |
|
|
11
|
+
|
|
12
|
+
**This repo’s automated tests:**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm ci
|
|
16
|
+
npm test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Fixtures live under **`test/fixtures/`** (e.g. lockfile mini IOC case in **`cli-smoke.test.js`**).
|
package/lib/baseline.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
parseNpmLockfile,
|
|
5
|
+
getRootDependencyResolution,
|
|
6
|
+
} from "./parse-npm-lockfile.js";
|
|
7
|
+
import { fetchVersionScripts, summarizeLifecycleScripts } from "./packument.js";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_BASELINE_PATH = ".npm-sentinel-baseline.json";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} cwd
|
|
13
|
+
* @param {string} [relPath]
|
|
14
|
+
*/
|
|
15
|
+
export function baselinePath(cwd, relPath = DEFAULT_BASELINE_PATH) {
|
|
16
|
+
return join(cwd, relPath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} pkg
|
|
21
|
+
* @param {ReturnType<import('./parse-npm-lockfile.js').parseNpmLockfile>} parsed
|
|
22
|
+
* @param {string[]} watchNames
|
|
23
|
+
* @param {typeof fetch} [fetchImpl]
|
|
24
|
+
*/
|
|
25
|
+
export async function buildBaselineSnapshot(
|
|
26
|
+
pkg,
|
|
27
|
+
parsed,
|
|
28
|
+
watchNames,
|
|
29
|
+
fetchImpl = fetch
|
|
30
|
+
) {
|
|
31
|
+
/** @type {Record<string, object>} */
|
|
32
|
+
const packages = {};
|
|
33
|
+
|
|
34
|
+
for (const name of watchNames) {
|
|
35
|
+
const res = getRootDependencyResolution(parsed, name);
|
|
36
|
+
if (!res) {
|
|
37
|
+
packages[name] = {
|
|
38
|
+
resolvedVersion: null,
|
|
39
|
+
directDependencyNames: [],
|
|
40
|
+
lifecycleScriptKeys: [],
|
|
41
|
+
scriptHashes: {},
|
|
42
|
+
missingInLockfile: true,
|
|
43
|
+
};
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scripts = await fetchVersionScripts(name, res.version, fetchImpl);
|
|
48
|
+
const sum =
|
|
49
|
+
scripts === null
|
|
50
|
+
? { keys: [], hashes: {} }
|
|
51
|
+
: summarizeLifecycleScripts(scripts);
|
|
52
|
+
|
|
53
|
+
packages[name] = {
|
|
54
|
+
resolvedVersion: res.version,
|
|
55
|
+
directDependencyNames: [...(res.directDependencyNames || [])].sort(),
|
|
56
|
+
lifecycleScriptKeys: sum.keys.sort(),
|
|
57
|
+
scriptHashes: sum.hashes,
|
|
58
|
+
missingInLockfile: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
version: 1,
|
|
64
|
+
savedAt: new Date().toISOString(),
|
|
65
|
+
packages,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function loadBaseline(cwd, relPath = DEFAULT_BASELINE_PATH) {
|
|
70
|
+
const p = baselinePath(cwd, relPath);
|
|
71
|
+
if (!existsSync(p)) return null;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveBaseline(snapshot, cwd, relPath = DEFAULT_BASELINE_PATH) {
|
|
80
|
+
const p = baselinePath(cwd, relPath);
|
|
81
|
+
writeFileSync(p, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
82
|
+
return p;
|
|
83
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const CONFIG_NAMES = [
|
|
5
|
+
"npm-sentinel.config.json",
|
|
6
|
+
".npm-sentinelsrc.json",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} cwd
|
|
11
|
+
* @returns {object}
|
|
12
|
+
*/
|
|
13
|
+
export function loadConfig(cwd) {
|
|
14
|
+
for (const name of CONFIG_NAMES) {
|
|
15
|
+
const p = join(cwd, name);
|
|
16
|
+
if (existsSync(p)) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} pkg - package.json parsed
|
|
29
|
+
* @param {object} config
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
export function getWatchPackageNames(pkg, config) {
|
|
33
|
+
if (Array.isArray(config.watchPackagesOverride)) {
|
|
34
|
+
return [...config.watchPackagesOverride].sort();
|
|
35
|
+
}
|
|
36
|
+
const extra = config.watchPackagesExtra || [];
|
|
37
|
+
const root = new Set(
|
|
38
|
+
[
|
|
39
|
+
...Object.keys(pkg.dependencies || {}),
|
|
40
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
41
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
42
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
43
|
+
].filter(Boolean)
|
|
44
|
+
);
|
|
45
|
+
if (Array.isArray(extra)) {
|
|
46
|
+
for (const e of extra) root.add(e);
|
|
47
|
+
}
|
|
48
|
+
return [...root].sort();
|
|
49
|
+
}
|