depsentinel 0.1.2 → 0.1.5
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/README.md +44 -5
- package/dist/cli.js +27 -2
- package/dist/commands/init.js +9 -3
- package/dist/core/doctor-checks.js +59 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
|
+
### 1) Scan the project
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx depsentinel scan
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 2) Generate secure defaults
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx depsentinel init --write
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 3) Run full diagnosis
|
|
20
|
+
|
|
7
21
|
```bash
|
|
8
|
-
npx depsentinel
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
npx depsentinel doctor
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 4) Add CI policy gate
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx depsentinel ci --json
|
|
13
29
|
```
|
|
14
30
|
|
|
15
31
|
## Commands
|
|
@@ -25,6 +41,29 @@ npx depsentinel doctor # full 26-point security diagnosis
|
|
|
25
41
|
| `trust add\|remove\|list` | Manage allow/ignore build-script trust per package manager |
|
|
26
42
|
| `override add\|remove\|list` | Manage policy exceptions with reason and expiration |
|
|
27
43
|
|
|
44
|
+
### Trust examples
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
depsentinel trust add sharp --mode allow-build --write
|
|
48
|
+
depsentinel trust add fsevents --mode ignore-build --write
|
|
49
|
+
depsentinel trust list --pm pnpm
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Typical flows
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# First-time hardening
|
|
56
|
+
depsentinel scan
|
|
57
|
+
depsentinel init --write
|
|
58
|
+
depsentinel doctor
|
|
59
|
+
|
|
60
|
+
# Dependency preflight
|
|
61
|
+
depsentinel install <package>
|
|
62
|
+
|
|
63
|
+
# CI gate
|
|
64
|
+
depsentinel ci --json
|
|
65
|
+
```
|
|
66
|
+
|
|
28
67
|
## What it enforces
|
|
29
68
|
|
|
30
69
|
- **Disable post-install scripts** — `.npmrc` `ignore-scripts=true`
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cac } from "cac";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
5
|
import { runCi } from "./commands/ci.js";
|
|
4
6
|
import { runDoctor } from "./commands/doctor.js";
|
|
5
7
|
import { runFix } from "./commands/fix.js";
|
|
@@ -10,6 +12,17 @@ import { runTrust } from "./commands/trust.js";
|
|
|
10
12
|
import { overrideAdd, overrideList, overrideRemove } from "./core/overrides.js";
|
|
11
13
|
export function createCli() {
|
|
12
14
|
const cli = cac("depsentinel");
|
|
15
|
+
async function askYesNo(question, defaultYes) {
|
|
16
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
17
|
+
return defaultYes;
|
|
18
|
+
const rl = createInterface({ input, output });
|
|
19
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
20
|
+
const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
|
|
21
|
+
rl.close();
|
|
22
|
+
if (answer === "")
|
|
23
|
+
return defaultYes;
|
|
24
|
+
return answer === "y" || answer === "yes" || answer === "s" || answer === "si";
|
|
25
|
+
}
|
|
13
26
|
cli
|
|
14
27
|
.command("scan", "Run dependency risk scan")
|
|
15
28
|
.option("--json", "Emit machine-readable JSON")
|
|
@@ -36,11 +49,23 @@ export function createCli() {
|
|
|
36
49
|
.option("--preset <preset>", "Preset to initialize (base|expo)")
|
|
37
50
|
.option("--write", "Apply changes (default is dry-run)")
|
|
38
51
|
.option("--json", "Emit machine-readable JSON")
|
|
39
|
-
.action((options) => {
|
|
52
|
+
.action(async (options) => {
|
|
53
|
+
const context = options.json
|
|
54
|
+
? {
|
|
55
|
+
publishesToNpm: true,
|
|
56
|
+
publishFromCi: true,
|
|
57
|
+
usesOidcTrustedPublisher: false
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
publishesToNpm: await askYesNo("Does this project publish packages to npm?", false),
|
|
61
|
+
publishFromCi: await askYesNo("Does this project publish from CI?", true),
|
|
62
|
+
usesOidcTrustedPublisher: await askYesNo("Does npm publish use OIDC Trusted Publisher?", false)
|
|
63
|
+
};
|
|
40
64
|
const result = runInit({
|
|
41
65
|
preset: options.preset,
|
|
42
66
|
dryRun: !Boolean(options.write),
|
|
43
|
-
json: Boolean(options.json)
|
|
67
|
+
json: Boolean(options.json),
|
|
68
|
+
context
|
|
44
69
|
});
|
|
45
70
|
process.stdout.write(`${result.output}\n`);
|
|
46
71
|
});
|
package/dist/commands/init.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { applySafePlan, planSafeFile } from "../core/safe-write.js";
|
|
3
3
|
import { detectProjectFacts } from "../core/detector.js";
|
|
4
|
-
function buildDepsentinelConfig(preset) {
|
|
4
|
+
function buildDepsentinelConfig(preset, context) {
|
|
5
5
|
return JSON.stringify({
|
|
6
6
|
schemaVersion: "1.0.0",
|
|
7
7
|
preset,
|
|
8
8
|
policyCatalog: "v1",
|
|
9
9
|
failOn: ["critical"],
|
|
10
|
-
overrides: []
|
|
10
|
+
overrides: [],
|
|
11
|
+
context
|
|
11
12
|
}, null, 2);
|
|
12
13
|
}
|
|
13
14
|
function buildNpmRc() {
|
|
@@ -103,8 +104,13 @@ export function runInit(options = {}) {
|
|
|
103
104
|
const preset = options.preset ?? "base";
|
|
104
105
|
const dryRun = options.dryRun ?? true;
|
|
105
106
|
const facts = detectProjectFacts(cwd);
|
|
107
|
+
const context = options.context ?? {
|
|
108
|
+
publishesToNpm: true,
|
|
109
|
+
publishFromCi: true,
|
|
110
|
+
usesOidcTrustedPublisher: false
|
|
111
|
+
};
|
|
106
112
|
const planned = [
|
|
107
|
-
planSafeFile(path.join(cwd, "depsentinel.json"), `${buildDepsentinelConfig(preset)}\n`),
|
|
113
|
+
planSafeFile(path.join(cwd, "depsentinel.json"), `${buildDepsentinelConfig(preset, context)}\n`),
|
|
108
114
|
planSafeFile(path.join(cwd, ".npmrc"), buildNpmRc()),
|
|
109
115
|
planSafeFile(path.join(cwd, ".npmignore"), buildNpmIgnore()),
|
|
110
116
|
planSafeFile(path.join(cwd, ".github", "workflows", "depsentinel-ci.yml"), `${buildCiWorkflow()}\n`)
|
|
@@ -27,24 +27,64 @@ function skip(id, category, title, detail, remediation) {
|
|
|
27
27
|
return { id, category, severity: "low", status: "skipped", title, detail, remediation, automated: false };
|
|
28
28
|
}
|
|
29
29
|
export function collectDiagnoses(rootDir, facts) {
|
|
30
|
+
const context = readProjectContext(rootDir);
|
|
30
31
|
return [
|
|
32
|
+
checkMixedLockfiles(rootDir, facts),
|
|
33
|
+
checkPackageManagerField(rootDir, facts),
|
|
31
34
|
checkNpmRc(rootDir),
|
|
32
|
-
checkNpmIgnore(rootDir),
|
|
33
|
-
checkPackageJsonFiles(rootDir),
|
|
35
|
+
checkNpmIgnore(rootDir, context),
|
|
36
|
+
checkPackageJsonFiles(rootDir, context),
|
|
34
37
|
checkPnpmWorkspace(rootDir, facts),
|
|
35
38
|
checkBunfig(rootDir, facts),
|
|
36
39
|
checkYarnRc(rootDir, facts),
|
|
37
40
|
checkLockfileCommitted(rootDir),
|
|
38
|
-
checkCiProvenance(rootDir),
|
|
41
|
+
checkCiProvenance(rootDir, context),
|
|
39
42
|
checkLintLockfile(rootDir),
|
|
40
43
|
checkSbomScript(rootDir),
|
|
41
44
|
checkEnvPlaintext(rootDir),
|
|
42
45
|
checkNpxHardening(),
|
|
43
|
-
checkNpm2fa(),
|
|
46
|
+
checkNpm2fa(context),
|
|
44
47
|
checkDevContainer(rootDir),
|
|
45
48
|
checkNodeModulesGitignored(rootDir)
|
|
46
49
|
];
|
|
47
50
|
}
|
|
51
|
+
function checkPackageManagerField(rootDir, facts) {
|
|
52
|
+
const pkgPath = path.join(rootDir, "package.json");
|
|
53
|
+
if (!existsSync(pkgPath)) {
|
|
54
|
+
return skip("config.package-manager.no-pkg", "config", "No package.json", "Cannot verify packageManager field without package.json.", "Ensure package.json exists.");
|
|
55
|
+
}
|
|
56
|
+
const pkg = readJsonSafe(pkgPath, {});
|
|
57
|
+
const field = pkg.packageManager?.trim();
|
|
58
|
+
if (!field) {
|
|
59
|
+
return fail("config.package-manager.missing", "config", "medium", "Missing packageManager field in package.json", "Without packageManager, installs can drift across npm/pnpm/yarn/bun versions between machines and CI.", "Add packageManager to package.json (for example: `pnpm@11.2.2` or `npm@10.x`).");
|
|
60
|
+
}
|
|
61
|
+
const declared = field.split("@")[0];
|
|
62
|
+
if (!["npm", "pnpm", "yarn", "bun"].includes(declared)) {
|
|
63
|
+
return fail("config.package-manager.invalid", "config", "medium", `Invalid packageManager value: ${field}`, "packageManager must declare npm, pnpm, yarn, or bun with a version.", "Set packageManager to a valid value, like `pnpm@11.2.2`.");
|
|
64
|
+
}
|
|
65
|
+
if (facts.packageManager !== "unknown" && declared !== facts.packageManager) {
|
|
66
|
+
return fail("config.package-manager.mismatch", "config", "high", `packageManager mismatch: declared ${declared}, detected ${facts.packageManager}`, "Declared package manager does not match lockfile-detected package manager.", "Use one package manager strategy: align packageManager with lockfile, and remove conflicting lockfiles.");
|
|
67
|
+
}
|
|
68
|
+
return pass("config.package-manager.present", "config", `packageManager pinned: ${field}`);
|
|
69
|
+
}
|
|
70
|
+
function checkMixedLockfiles(rootDir, facts) {
|
|
71
|
+
const lockfiles = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"].filter((name) => existsSync(path.join(rootDir, name)));
|
|
72
|
+
if (lockfiles.length <= 1) {
|
|
73
|
+
return pass("dependencies.lockfile.single", "dependencies", "Single lockfile strategy detected");
|
|
74
|
+
}
|
|
75
|
+
return fail("dependencies.lockfile.mixed", "dependencies", "high", `Multiple lockfiles detected: ${lockfiles.join(", ")}`, `Mixed lockfiles cause non-deterministic installs and tool mismatch (detected manager: ${facts.packageManager}).`, "Keep exactly one lockfile for the chosen package manager and delete the others.");
|
|
76
|
+
}
|
|
77
|
+
function readProjectContext(rootDir) {
|
|
78
|
+
const configPath = path.join(rootDir, "depsentinel.json");
|
|
79
|
+
const parsed = readJsonSafe(configPath, {
|
|
80
|
+
context: { publishesToNpm: true, publishFromCi: true, usesOidcTrustedPublisher: false }
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
publishesToNpm: parsed.context?.publishesToNpm ?? true,
|
|
84
|
+
publishFromCi: parsed.context?.publishFromCi ?? true,
|
|
85
|
+
usesOidcTrustedPublisher: parsed.context?.usesOidcTrustedPublisher ?? false
|
|
86
|
+
};
|
|
87
|
+
}
|
|
48
88
|
function checkNpmRc(rootDir) {
|
|
49
89
|
const npmrc = path.join(rootDir, ".npmrc");
|
|
50
90
|
if (!existsSync(npmrc))
|
|
@@ -61,18 +101,21 @@ function checkNpmRc(rootDir) {
|
|
|
61
101
|
return pass("config.npmrc.secure", "config", ".npmrc has secure baseline");
|
|
62
102
|
return fail("config.npmrc.incomplete", "config", "medium", `.npmrc missing: ${missing.join(", ")}`, "Your .npmrc lacks key security settings.", "Run `depsentinel init` to regenerate .npmrc.");
|
|
63
103
|
}
|
|
64
|
-
function checkNpmIgnore(rootDir) {
|
|
104
|
+
function checkNpmIgnore(rootDir, context) {
|
|
105
|
+
if (!context.publishesToNpm) {
|
|
106
|
+
return skip("config.npmignore.non-publish", "config", "Package is not published to npm", ".npmignore publish safeguards are not required for non-published apps.", "Set `context.publishesToNpm=true` in depsentinel.json if this changes.");
|
|
107
|
+
}
|
|
65
108
|
if (existsSync(path.join(rootDir, ".npmignore")))
|
|
66
109
|
return pass("config.npmignore.present", "config", ".npmignore present");
|
|
67
110
|
return fail("config.npmignore.missing", "config", "medium", "Missing .npmignore", "Without .npmignore, sensitive files may leak into published packages.", "Add a .npmignore file listing patterns to exclude from npm publish.");
|
|
68
111
|
}
|
|
69
|
-
function checkPackageJsonFiles(rootDir) {
|
|
112
|
+
function checkPackageJsonFiles(rootDir, context) {
|
|
70
113
|
const pkg = path.join(rootDir, "package.json");
|
|
71
114
|
if (!existsSync(pkg))
|
|
72
115
|
return skip("config.package-json.missing", "config", "No package.json found", "Cannot evaluate package.json settings.", "Ensure package.json exists.");
|
|
73
116
|
const parsed = readJsonSafe(pkg, { files: undefined, private: undefined });
|
|
74
|
-
if (parsed.private)
|
|
75
|
-
return pass("config.package-json.files-private", "config", "Private package (no publish risk)");
|
|
117
|
+
if (parsed.private || !context.publishesToNpm)
|
|
118
|
+
return pass("config.package-json.files-private", "config", "Private/non-published package (no publish risk)");
|
|
76
119
|
if (parsed.files && parsed.files.length > 0)
|
|
77
120
|
return pass("config.package-json.files-present", "config", "package.json has `files` allowlist");
|
|
78
121
|
return fail("config.package-json.files-missing", "config", "medium", "Missing `files` field in package.json", "Without `files`, npm publishes everything not excluded by .npmignore/.gitignore.", "Add a `files` array to package.json with only the dist/ entry point you want published.");
|
|
@@ -129,7 +172,10 @@ function checkLockfileCommitted(rootDir) {
|
|
|
129
172
|
return skip("ci.lockfile-committed.no-lockfile", "ci", "No lockfile to check", "No lockfile found to verify commit status.", "Generate a lockfile with `npm install` and commit it.");
|
|
130
173
|
return skip("ci.lockfile-committed.manual", "ci", "Verify lockfile committed", "Use `git ls-files package-lock.json` to confirm your lockfile is committed.", "Run `git add package-lock.json && git commit -m \"chore: add lockfile\"`.");
|
|
131
174
|
}
|
|
132
|
-
function checkCiProvenance(rootDir) {
|
|
175
|
+
function checkCiProvenance(rootDir, context) {
|
|
176
|
+
if (!context.publishesToNpm || !context.publishFromCi) {
|
|
177
|
+
return skip("ci.provenance.not-applicable", "ci", "Publish provenance not required", "Project context says npm publishing from CI is disabled.", "Set `context.publishesToNpm=true` and `context.publishFromCi=true` if you start publishing from CI.");
|
|
178
|
+
}
|
|
133
179
|
const workflowsDir = path.join(rootDir, ".github", "workflows");
|
|
134
180
|
if (!existsSync(workflowsDir))
|
|
135
181
|
return fail("ci.provenance.no-workflows", "ci", "medium", "No GitHub Actions workflows found", "Cannot verify provenance/id-token configuration without CI workflows.", "Add `permissions: id-token: write` to your publish workflow for npm provenance.");
|
|
@@ -186,7 +232,10 @@ function checkEnvPlaintext(rootDir) {
|
|
|
186
232
|
function checkNpxHardening() {
|
|
187
233
|
return skip("maintainer.npx-hardening.manual", "maintainer", "Verify npx hardening", "npx can silently pull fresh malicious packages without lockfile verification.", "Create a dedicated workspace with pre-installed npx packages, and use `npx --offline --workspace <path>` to block network fetches. See docs/security-best-practices.md for step-by-step instructions.");
|
|
188
234
|
}
|
|
189
|
-
function checkNpm2fa() {
|
|
235
|
+
function checkNpm2fa(context) {
|
|
236
|
+
if (!context.publishesToNpm) {
|
|
237
|
+
return skip("maintainer.2fa.not-applicable", "maintainer", "npm account 2FA not required", "Project context says package is not published to npm.", "Enable this check by setting `context.publishesToNpm=true` in depsentinel.json.");
|
|
238
|
+
}
|
|
190
239
|
return skip("maintainer.2fa.manual", "maintainer", "Verify npm account 2FA", "Accounts without 2FA are vulnerable to credential theft and package takeover.", "Run `npm profile enable-2fa auth-and-writes` to enable 2FA for your npm account.");
|
|
191
240
|
}
|
|
192
241
|
function checkDevContainer(rootDir) {
|