@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,211 @@
1
+ import {
2
+ readFileSync,
3
+ mkdtempSync,
4
+ rmSync,
5
+ existsSync,
6
+ realpathSync,
7
+ } from "node:fs";
8
+ import { tmpdir, homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { spawnSync } from "node:child_process";
12
+ import {
13
+ extractHostsFromTcpdump,
14
+ filterDisallowedHosts,
15
+ } from "./dns-parse.js";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const PACKAGE_ROOT = join(__dirname, "..", "..");
19
+ const DOCKER_DIR = join(PACKAGE_ROOT, "docker");
20
+ const IMAGE_TAG = "npm-sentinel-sandbox:local";
21
+
22
+ /**
23
+ * Skip mounted ~/.ssh/config: macOS often sets UseKeychain, which Linux OpenSSH rejects.
24
+ * With -F /dev/null, ssh still tries default identity files under /root/.ssh (id_ed25519, id_rsa, …).
25
+ */
26
+ const GIT_SSH_COMMAND =
27
+ "ssh -F /dev/null -o BatchMode=yes -o IdentityAgent=none -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/npm-sentinel-known_hosts";
28
+
29
+ function loadDefaultAllowlist() {
30
+ const p = join(PACKAGE_ROOT, "lib", "dns-allowlist-default.json");
31
+ return JSON.parse(readFileSync(p, "utf8"));
32
+ }
33
+
34
+ /**
35
+ * DNS allowlist for sandbox: built-in defaults plus optional project config.
36
+ *
37
+ * - **merge** (default): `lib/dns-allowlist-default.json` + `dnsAllowlist.suffixes` / `exactHosts`.
38
+ * - **replace**: only `suffixes` / `exactHosts` from config (full list is your responsibility).
39
+ *
40
+ * @param {object | null | undefined} userAllow - from `dnsAllowlist` in npm-sentinel.config.json
41
+ * @returns {{ suffixes: string[], exactHosts: string[] }}
42
+ */
43
+ export function resolveDnsAllowlist(userAllow) {
44
+ const modeRaw =
45
+ userAllow && typeof userAllow.mode === "string"
46
+ ? String(userAllow.mode).toLowerCase()
47
+ : "merge";
48
+ const mode = modeRaw === "replace" ? "replace" : "merge";
49
+
50
+ const userSuffixes = (userAllow && userAllow.suffixes) || [];
51
+ const userExact = (userAllow && userAllow.exactHosts) || [];
52
+
53
+ if (mode === "replace") {
54
+ return {
55
+ suffixes: [...userSuffixes],
56
+ exactHosts: [...userExact],
57
+ };
58
+ }
59
+
60
+ const base = loadDefaultAllowlist();
61
+ return {
62
+ suffixes: [...(base.suffixes || []), ...userSuffixes],
63
+ exactHosts: [...(base.exactHosts || []), ...userExact],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Resolve directory to mount at /root/.ssh in the container.
69
+ * @param {{ sshMountPath?: string | null, mountSsh?: boolean }} opts
70
+ * @returns {{ path: string } | { error: string }}
71
+ */
72
+ export function resolveSshMountPath(opts) {
73
+ let dir = opts.sshMountPath;
74
+ if (!dir && opts.mountSsh) {
75
+ dir = join(homedir(), ".ssh");
76
+ }
77
+ if (!dir) return { path: "" };
78
+ if (!existsSync(dir)) {
79
+ return {
80
+ error: `SSH mount path does not exist: ${dir}`,
81
+ };
82
+ }
83
+ try {
84
+ return { path: realpathSync(dir) };
85
+ } catch {
86
+ return { error: `Could not resolve SSH mount path: ${dir}` };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {{
92
+ * cwd: string,
93
+ * skipBuild?: boolean,
94
+ * userAllowlist?: object,
95
+ * dockerPath?: string,
96
+ * mountSsh?: boolean,
97
+ * sshMountPath?: string | null,
98
+ * }} opts
99
+ */
100
+ export function runSandbox(opts) {
101
+ const cwd = opts.cwd || process.cwd();
102
+ if (!existsSync(join(cwd, "package-lock.json"))) {
103
+ return {
104
+ ok: false,
105
+ error: "package-lock.json required for sandbox (npm ci)",
106
+ };
107
+ }
108
+
109
+ const sshResolved = resolveSshMountPath({
110
+ sshMountPath: opts.sshMountPath || null,
111
+ mountSsh: !!opts.mountSsh,
112
+ });
113
+ if (sshResolved.error) {
114
+ return { ok: false, error: sshResolved.error };
115
+ }
116
+ const sshHostPath = sshResolved.path || null;
117
+
118
+ const dockerBin = opts.dockerPath || "docker";
119
+ const checkDocker = spawnSync(dockerBin, ["info"], { encoding: "utf8" });
120
+ if (checkDocker.status !== 0) {
121
+ return {
122
+ ok: false,
123
+ error: `Docker not available: ${checkDocker.stderr || checkDocker.stdout || "docker info failed"}`,
124
+ };
125
+ }
126
+
127
+ if (!opts.skipBuild) {
128
+ const build = spawnSync(
129
+ dockerBin,
130
+ [
131
+ "build",
132
+ "-t",
133
+ IMAGE_TAG,
134
+ "-f",
135
+ join(DOCKER_DIR, "Dockerfile.sandbox"),
136
+ DOCKER_DIR,
137
+ ],
138
+ { encoding: "utf8" }
139
+ );
140
+ if (build.status !== 0) {
141
+ return {
142
+ ok: false,
143
+ error: `docker build failed:\n${build.stderr || build.stdout}`,
144
+ };
145
+ }
146
+ }
147
+
148
+ const outDir = mkdtempSync(join(tmpdir(), "npm-sentinel-"));
149
+ try {
150
+ const dockerArgs = [
151
+ "run",
152
+ "--rm",
153
+ "--cap-add=NET_RAW",
154
+ "--cap-add=NET_ADMIN",
155
+ ];
156
+
157
+ if (sshHostPath) {
158
+ dockerArgs.push("-e", `GIT_SSH_COMMAND=${GIT_SSH_COMMAND}`);
159
+ dockerArgs.push("-v", `${sshHostPath}:/root/.ssh:ro`);
160
+ }
161
+
162
+ dockerArgs.push("-v", `${cwd}:/src:ro`, "-v", `${outDir}:/out`, IMAGE_TAG);
163
+
164
+ const run = spawnSync(dockerBin, dockerArgs, {
165
+ encoding: "utf8",
166
+ maxBuffer: 50 * 1024 * 1024,
167
+ });
168
+
169
+ const dnsLogPath = join(outDir, "dns.log");
170
+ const npmLogPath = join(outDir, "npm.log");
171
+ const exitPath = join(outDir, "npm-exit.code");
172
+
173
+ let dnsText = "";
174
+ if (existsSync(dnsLogPath)) {
175
+ dnsText = readFileSync(dnsLogPath, "utf8");
176
+ }
177
+ let npmLog = "";
178
+ if (existsSync(npmLogPath)) {
179
+ npmLog = readFileSync(npmLogPath, "utf8");
180
+ }
181
+ let npmExit = 1;
182
+ if (existsSync(exitPath)) {
183
+ npmExit = parseInt(readFileSync(exitPath, "utf8").trim(), 10);
184
+ if (Number.isNaN(npmExit)) npmExit = 1;
185
+ }
186
+
187
+ const hosts = extractHostsFromTcpdump(dnsText);
188
+ const allow = resolveDnsAllowlist(opts.userAllowlist);
189
+ const disallowed = filterDisallowedHosts(hosts, allow);
190
+
191
+ const dnsViolation = disallowed.length > 0;
192
+ const npmFailed = npmExit !== 0;
193
+
194
+ return {
195
+ ok: !dnsViolation && !npmFailed,
196
+ npmExit,
197
+ npmLogTail: npmLog.slice(-8000),
198
+ dnsHostsSample: [...hosts].slice(0, 50),
199
+ disallowedDns: disallowed,
200
+ dockerStderr: run.stderr,
201
+ dockerStdout: run.stdout,
202
+ sshMounted: !!sshHostPath,
203
+ };
204
+ } finally {
205
+ try {
206
+ rmSync(outDir, { recursive: true, force: true });
207
+ } catch {
208
+ /* ignore */
209
+ }
210
+ }
211
+ }
package/lib/scan.js ADDED
@@ -0,0 +1,156 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import {
6
+ parseNpmLockfile,
7
+ getAllNameVersionPairs,
8
+ } from "./parse-npm-lockfile.js";
9
+ import { queryOsvBatch } from "./osv-client.js";
10
+ import {
11
+ flattenOsvFindings,
12
+ matchOfflineIocs,
13
+ filterByMinSeverity,
14
+ } from "./merge-findings.js";
15
+ import { loadBaseline, buildBaselineSnapshot } from "./baseline.js";
16
+ import { diffAgainstBaseline } from "./diff-signals.js";
17
+ import { loadConfig, getWatchPackageNames } from "./config.js";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ /**
22
+ * @param {string} cwd
23
+ */
24
+ function readPackageJson(cwd) {
25
+ const p = join(cwd, "package.json");
26
+ if (!existsSync(p)) throw new Error(`Missing package.json in ${cwd}`);
27
+ return JSON.parse(readFileSync(p, "utf8"));
28
+ }
29
+
30
+ /**
31
+ * @param {string} cwd
32
+ */
33
+ function readLockfile(cwd) {
34
+ const p = join(cwd, "package-lock.json");
35
+ if (!existsSync(p)) throw new Error(`Missing package-lock.json in ${cwd}`);
36
+ return JSON.parse(readFileSync(p, "utf8"));
37
+ }
38
+
39
+ /**
40
+ * @param {object} opts
41
+ * @param {string} opts.cwd
42
+ * @param {string} [opts.minSeverity]
43
+ * @param {boolean} [opts.noOsv]
44
+ * @param {boolean} [opts.offline] - skip OSV, IOCs only
45
+ * @param {boolean} [opts.withBaselineDiff]
46
+ * @param {boolean} [opts.npmAudit]
47
+ * @param {typeof fetch} [opts.fetchImpl]
48
+ */
49
+ export async function runCheck(opts) {
50
+ const cwd = opts.cwd || process.cwd();
51
+ const minSeverity = opts.minSeverity || "low";
52
+ const pkg = readPackageJson(cwd);
53
+ const lockRaw = readLockfile(cwd);
54
+ const parsed = parseNpmLockfile(lockRaw);
55
+ const pairs = getAllNameVersionPairs(parsed);
56
+ const config = loadConfig(cwd);
57
+ const watchNames = getWatchPackageNames(pkg, config);
58
+
59
+ /** @type {Array<{name: string, version: string, source: string, severity: string, ids: string[], summary?: string}>} */
60
+ let findings = [];
61
+
62
+ if (!opts.offline) {
63
+ if (!opts.noOsv) {
64
+ const osv = await queryOsvBatch(pairs, opts.fetchImpl || fetch);
65
+ findings = findings.concat(flattenOsvFindings(osv));
66
+ }
67
+ }
68
+
69
+ findings = findings.concat(matchOfflineIocs(pairs));
70
+ findings = filterByMinSeverity(minSeverity, findings);
71
+
72
+ /** @type {import('./diff-signals.js').Signal[]} */
73
+ let signals = [];
74
+ if (opts.withBaselineDiff) {
75
+ const baseline = loadBaseline(cwd);
76
+ if (baseline) {
77
+ signals = await diffAgainstBaseline(
78
+ baseline,
79
+ parsed,
80
+ watchNames,
81
+ opts.fetchImpl || fetch
82
+ );
83
+ }
84
+ }
85
+
86
+ let npmAuditFindings = [];
87
+ if (opts.npmAudit) {
88
+ npmAuditFindings = await runNpmAuditJson(cwd);
89
+ }
90
+
91
+ return {
92
+ cwd,
93
+ packagesScanned: pairs.length,
94
+ findings,
95
+ signals,
96
+ npmAuditFindings,
97
+ watchNames,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * @param {string} cwd
103
+ * @returns {Promise<object[]>}
104
+ */
105
+ async function runNpmAuditJson(cwd) {
106
+ try {
107
+ const { stdout } = await execFileAsync(
108
+ "npm",
109
+ ["audit", "--json"],
110
+ { cwd, maxBuffer: 20 * 1024 * 1024 }
111
+ );
112
+ const j = JSON.parse(stdout);
113
+ const vulns = j.vulnerabilities || {};
114
+ const out = [];
115
+ for (const [name, v] of Object.entries(vulns)) {
116
+ if (!v.via || !v.effects) continue;
117
+ const sev = String(v.severity || "moderate").toLowerCase();
118
+ out.push({
119
+ name,
120
+ severity: sev,
121
+ source: "npm-audit",
122
+ via: v.via,
123
+ range: v.range,
124
+ });
125
+ }
126
+ return out;
127
+ } catch (e) {
128
+ const out = e.stdout?.toString?.() || e.stdout;
129
+ if (out) {
130
+ try {
131
+ const j = JSON.parse(out);
132
+ if (j.error?.code === "ENOLOCK") return [];
133
+ const vulns = j.vulnerabilities || {};
134
+ const arr = [];
135
+ for (const [name, v] of Object.entries(vulns)) {
136
+ if (!v.via) continue;
137
+ arr.push({
138
+ name,
139
+ severity: String(v.severity || "moderate").toLowerCase(),
140
+ source: "npm-audit",
141
+ via: v.via,
142
+ range: v.range,
143
+ });
144
+ }
145
+ return arr;
146
+ } catch {
147
+ /* fallthrough */
148
+ }
149
+ }
150
+ return [
151
+ { name: "(npm-audit)", source: "npm-audit", error: String(e.message) },
152
+ ];
153
+ }
154
+ }
155
+
156
+ export { readPackageJson, readLockfile, parseNpmLockfile };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kushankurdas/npm-sentinel",
3
+ "version": "0.1.0",
4
+ "description": "Static gate (lockfile + OSV) and isolated Docker npm install with DNS allowlisting",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "kushankurdas",
8
+ "url": "https://github.com/kushankurdas"
9
+ },
10
+ "type": "module",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/kushankurdas/npm-sentinel.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/kushankurdas/npm-sentinel/issues"
17
+ },
18
+ "homepage": "https://github.com/kushankurdas/npm-sentinel#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "bin": {
23
+ "npm-sentinel": "bin/cli.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "lib",
28
+ "docker",
29
+ "docs",
30
+ "CONTRIBUTING.md",
31
+ "SECURITY.md",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/*.test.js"
37
+ },
38
+ "keywords": [
39
+ "npm",
40
+ "security",
41
+ "supply-chain",
42
+ "docker",
43
+ "osv"
44
+ ]
45
+ }