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 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 scan # diagnose your project
9
- npx depsentinel init --write # generate secure baseline
10
- npx depsentinel ci --json # fail CI if critical policies violated
11
- npx depsentinel install express # check package safety before adding it
12
- npx depsentinel doctor # full 26-point security diagnosis
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
  });
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "depsentinel",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "JS/TS supply-chain hardening CLI — scan, secure, and enforce dependency policies",
5
5
  "license": "MIT",
6
6
  "author": "depsentinel contributors",