pmsec 0.10.0 → 0.13.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Hikaru Egashira
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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # pmsec (Node)
2
2
 
3
3
  Zero-config install-time supply-chain hardening for npm, pnpm, yarn, bun,
4
- cargo, mise, and uv. One command flips on every safe-by-default knob each
4
+ cargo, mise, uv, and bundler. One command flips on every safe-by-default knob each
5
5
  package manager exposes — install cooldown, signature trust policy, lockfile
6
6
  re-verification, build-script attestation, and more.
7
7
 
@@ -38,7 +38,7 @@ Zero runtime dependencies, ESM, requires Node 22+.
38
38
  | `pmsec --disable` | Remove every key the bundle set; other keys in the file are preserved. |
39
39
  | `pmsec --version` | Print the installed pmsec version. |
40
40
 
41
- Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv`, `--days N` (override the 1-day default), `--force` (overwrite stricter existing cooldowns; default is monotonic), `--json`. `--check` and `--disable` are mutually exclusive.
41
+ Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv,bundler`, `--days N` (override the 1-day default), `--force` (overwrite stricter existing cooldowns; default is monotonic), `--json`. `--check` and `--disable` are mutually exclusive.
42
42
 
43
43
  When the target file is owned by another user (typical: `~/.npmrc` left
44
44
  root-owned by an old `sudo npm config set`), the write fails with `EACCES`
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pmsec",
3
- "version": "0.10.0",
3
+ "version": "0.13.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Zero-config install-time supply-chain hardening for npm, pnpm, yarn, bun, cargo, mise, and uv.",
7
+ "description": "Zero-config install-time supply-chain hardening for npm, pnpm, yarn, bun, cargo, mise, uv, and bundler.",
8
8
  "homepage": "https://github.com/HikaruEgashira/pmsec#readme",
9
9
  "repository": {
10
10
  "type": "git",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "src",
28
+ "LICENSE",
28
29
  "README.md"
29
30
  ],
30
31
  "scripts": {
package/src/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { homedir } from "node:os";
2
- import { readFileSync } from "node:fs";
1
+ import { homedir, userInfo } from "node:os";
2
+ import { readFileSync, accessSync, constants as fsConstants, statSync } from "node:fs";
3
+ import { dirname } from "node:path";
3
4
  import { shellQuote } from "./util/io.mjs";
4
5
  import * as npm from "./tools/npm.mjs";
5
6
  import * as pnpm from "./tools/pnpm.mjs";
@@ -8,8 +9,9 @@ import * as bun from "./tools/bun.mjs";
8
9
  import * as cargo from "./tools/cargo.mjs";
9
10
  import * as mise from "./tools/mise.mjs";
10
11
  import * as uv from "./tools/uv.mjs";
12
+ import * as bundler from "./tools/bundler.mjs";
11
13
 
12
- const TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv];
14
+ const TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv, bundler];
13
15
  // Default cooldown for the hardening bundle. Override per-invocation with
14
16
  // `--days N`; the default tracks the safest value we'd recommend.
15
17
  const BUNDLE_DAYS = 1;
@@ -20,14 +22,15 @@ export const VERSION = JSON.parse(
20
22
  const USAGE = `pmsec [options]
21
23
 
22
24
  Zero-config install-time supply-chain hardening across npm, pnpm, yarn,
23
- bun, cargo, mise, uv. Default action enables every safe-by-default key
24
- each tool exposes (cooldown, audit-level, trust-policy, hardened mode,
25
+ bun, cargo, mise, uv, bundler. Default action enables every safe-by-default
26
+ key each tool exposes (cooldown, audit-level, trust-policy, hardened mode,
25
27
  attestation re-verification, ...). No knobs.
26
28
 
27
29
  Options:
28
30
  --check Verify the bundle is in place (exit 1 if anything missing)
29
31
  --disable Remove the hardening bundle from selected tools
30
- --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv)
32
+ --doctor Diagnose effective paths/owner/uid (read-only; for unattended-deployment debugging)
33
+ --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv,bundler)
31
34
  --days N Override cooldown days (default 1)
32
35
  --force Overwrite stricter existing cooldowns (otherwise enable is monotonic)
33
36
  --json Emit JSON output
@@ -52,7 +55,7 @@ function parse(argv) {
52
55
  const opts = { mode: "enable", json: false, only: null, days: BUNDLE_DAYS, force: false, help: false, version: false };
53
56
  let modeSet = false;
54
57
  const setMode = (m) => {
55
- if (modeSet && opts.mode !== m) throw new Error(`--check and --disable are mutually exclusive`);
58
+ if (modeSet && opts.mode !== m) throw new Error(`--check, --disable, and --doctor are mutually exclusive`);
56
59
  opts.mode = m;
57
60
  modeSet = true;
58
61
  };
@@ -65,6 +68,7 @@ function parse(argv) {
65
68
  else if (a === "--force") opts.force = true;
66
69
  else if (a === "--check") setMode("check");
67
70
  else if (a === "--disable") setMode("disable");
71
+ else if (a === "--doctor") setMode("doctor");
68
72
  else if (a === "--tool") opts.only = argv[++i].split(",");
69
73
  else if (a.startsWith("--tool=")) opts.only = a.slice(7).split(",");
70
74
  else if (a === "--days") opts.days = parseDays(argv[++i]);
@@ -130,6 +134,86 @@ async function runCheck(targets, { json, days }, ctx, out, err) {
130
134
  return ok ? 0 : 1;
131
135
  }
132
136
 
137
+ // `pmsec doctor` runs read-only and reports the same path resolution that
138
+ // enable/check/disable would do, plus identity (uid/euid) and parent-dir
139
+ // writability — the smallest set of facts an operator needs to diagnose
140
+ // "pmsec ran but wrote to nowhere" (root's $HOME) or "wrote a file no one
141
+ // can read" (chown failed under SIP/SELinux). Never mutates the filesystem.
142
+ function probePath(p, uid) {
143
+ const parent = dirname(p);
144
+ let exists = false, writable = false, parentExists = false, owner = null;
145
+ try { accessSync(p, fsConstants.F_OK); exists = true; } catch {}
146
+ if (exists) {
147
+ try { accessSync(p, fsConstants.W_OK); writable = true; } catch {}
148
+ try {
149
+ const st = statSync(p);
150
+ // Only POSIX uids are meaningful; on win32 st.uid is 0 and ignored downstream.
151
+ owner = uid === null ? null : st.uid;
152
+ } catch {}
153
+ }
154
+ try { accessSync(parent, fsConstants.F_OK); parentExists = true; } catch {}
155
+ // Walk up to the deepest existing ancestor — pmsec runs mkdir -p, so what
156
+ // matters is whether that ancestor is writable, not the literal parent.
157
+ let probe = parent, ancestor = null;
158
+ while (probe && probe !== dirname(probe)) {
159
+ try { accessSync(probe, fsConstants.F_OK); ancestor = probe; break; } catch {}
160
+ probe = dirname(probe);
161
+ }
162
+ let parentWritable = false;
163
+ if (ancestor) {
164
+ try { accessSync(ancestor, fsConstants.W_OK); parentWritable = true; } catch {}
165
+ }
166
+ return { path: p, parent, exists, writable, parentExists, parentWritable, owner };
167
+ }
168
+
169
+ function runDoctor(targets, json, ctx, out) {
170
+ const isPosix = ctx.platform !== "win32" && typeof process.getuid === "function";
171
+ const uid = isPosix ? process.getuid() : null;
172
+ const euid = isPosix && typeof process.geteuid === "function" ? process.geteuid() : uid;
173
+ let username = null;
174
+ try { username = userInfo().username; } catch {}
175
+ const tools = targets.map(t => {
176
+ const p = t.path(ctx);
177
+ const probe = probePath(p, uid);
178
+ return { tool: t.name, key: t.key, ...probe };
179
+ });
180
+ // doctor.ok is intentionally narrow: the parent directory must be writable
181
+ // by the running uid. Everything else is informational.
182
+ const okFlag = tools.every(t => t.parentWritable);
183
+ const report = {
184
+ doctor: true,
185
+ version: VERSION,
186
+ platform: ctx.platform,
187
+ uid, euid, username,
188
+ home: ctx.home,
189
+ pmsecHome: ctx.env.PMSEC_HOME ?? null,
190
+ pmsecHomeSource: ctx.env.PMSEC_HOME ? "PMSEC_HOME" : "HOME",
191
+ tools,
192
+ ok: okFlag,
193
+ };
194
+ if (json) {
195
+ out.write(JSON.stringify(report, null, 2) + "\n");
196
+ return okFlag ? 0 : 1;
197
+ }
198
+ out.write(`pmsec ${VERSION} doctor\n`);
199
+ out.write(` platform : ${ctx.platform}\n`);
200
+ if (isPosix) out.write(` uid/euid : ${uid}/${euid}${username ? ` (${username})` : ""}\n`);
201
+ out.write(` HOME : ${ctx.home}\n`);
202
+ out.write(` PMSEC_HOME : ${ctx.env.PMSEC_HOME ?? "(unset — using HOME)"}\n\n`);
203
+ for (const t of tools) {
204
+ const flag = t.parentWritable ? "ok " : "BLOCK ";
205
+ const ownerSuffix = t.exists && t.owner !== null ? ` (uid=${t.owner})` : "";
206
+ out.write(`${flag} ${t.tool.padEnd(4)} ${t.path}${ownerSuffix}\n`);
207
+ if (!t.parentWritable) {
208
+ // Only surface noise when there's a real problem. "parent missing but
209
+ // mkdir -p can fix it" is the common case and isn't worth a line.
210
+ out.write(` no writable ancestor for ${t.parent}\n`);
211
+ }
212
+ }
213
+ if (!okFlag) out.write(`\npmsec doctor: at least one parent directory is not writable as uid ${uid ?? "(non-POSIX)"}.\n`);
214
+ return okFlag ? 0 : 1;
215
+ }
216
+
133
217
  function explainFsError(e, tool) {
134
218
  if (e?.code === "EACCES" || e?.code === "EPERM") {
135
219
  const p = e.path ?? "";
@@ -216,6 +300,7 @@ export async function run(argv, {
216
300
  try {
217
301
  if (opts.mode === "check") return await runCheck(targets, opts, ctx, out, err);
218
302
  if (opts.mode === "disable") return await runDisable(targets, opts.json, ctx, out, err);
303
+ if (opts.mode === "doctor") return runDoctor(targets, opts.json, ctx, out);
219
304
  return await runEnable(targets, opts.json, opts.days, opts.force, ctx, out, err);
220
305
  } catch (e) {
221
306
  err.write(`pmsec: ${e.message}\n`);
@@ -0,0 +1,46 @@
1
+ import { bundleConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { buildPreflight } from "../util/version.mjs";
5
+
6
+ export const name = "bundler";
7
+ export const key = "BUNDLE_COOLDOWN";
8
+ export const docs = "https://bundler.io/man/bundle-config.1.html";
9
+ export const minBin = [4, 0, 13];
10
+ export const extras = [];
11
+ const SEP = ":";
12
+
13
+ export function path(ctx) { return bundleConfigPath(ctx.env, ctx.home); }
14
+
15
+ export const preflight = buildPreflight(name, minBin,
16
+ "BUNDLE_COOLDOWN is silently ignored. Upgrade bundler (v4.0.13+) to enforce the cooldown.");
17
+
18
+ // bundler stores the cooldown as a plain integer number of days, quoted in the
19
+ // YAML config (e.g. `BUNDLE_COOLDOWN: "7"`). Accept it with or without quotes.
20
+ function parseDays(value) {
21
+ if (value === null) return null;
22
+ const m = value.match(/^"?\s*(\d+)\s*"?$/);
23
+ return m ? Number(m[1]) : null;
24
+ }
25
+
26
+ export async function read(ctx) {
27
+ const p = path(ctx);
28
+ const raw = await readSafe(p);
29
+ const value = readKey(raw, key, { sep: SEP });
30
+ return { path: p, configured: value, days: parseDays(value), extras: [] };
31
+ }
32
+
33
+ export async function write(days, ctx) {
34
+ const p = path(ctx);
35
+ const text = setKey(await readSafe(p), key, `${key}: "${days}"`, { sep: SEP });
36
+ await writeAtomic(p, text);
37
+ return { path: p };
38
+ }
39
+
40
+ export async function unset(ctx) {
41
+ const p = path(ctx);
42
+ const before = await readSafe(p);
43
+ const { text: after, removed } = removeKey(before, key, { sep: SEP });
44
+ if (removed) await writeAtomic(p, after);
45
+ return { path: p, removed };
46
+ }
package/src/tools/npm.mjs CHANGED
@@ -9,7 +9,9 @@ export const key = "min-release-age";
9
9
  export const docs = "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age";
10
10
  export const minBin = [11, 10, 0];
11
11
  export const extras = [
12
- { key: "audit-level", expected: "high", line: "audit-level=high" }
12
+ { key: "audit-level", expected: "high", line: "audit-level=high" },
13
+ { key: "allow-git", expected: "root", line: "allow-git=root" },
14
+ { key: "allow-remote", expected: "root", line: "allow-remote=root" }
13
15
  ];
14
16
 
15
17
  export function path(ctx) { return npmrcPath(ctx.env, ctx.home); }
@@ -10,7 +10,8 @@ export const docs = "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate"
10
10
  export const minBin = [4, 10, 0];
11
11
  const SEP = ":";
12
12
  export const extras = [
13
- { key: "enableHardenedMode", expected: "true", line: "enableHardenedMode: true", sep: SEP }
13
+ { key: "enableHardenedMode", expected: "true", line: "enableHardenedMode: true", sep: SEP },
14
+ { key: "enableScripts", expected: "false", line: "enableScripts: false", sep: SEP }
14
15
  ];
15
16
 
16
17
  export function path(ctx) { return yarnrcPath(ctx.env, ctx.home); }
@@ -42,6 +42,15 @@ export function cargoConfigPath(env = process.env, home = homedir()) {
42
42
  return join(home, ".cargo", "config.toml");
43
43
  }
44
44
 
45
+ // Bundler's global config. BUNDLE_USER_CONFIG points at the file directly;
46
+ // BUNDLE_USER_HOME points at the home dir (config lives at <home>/config).
47
+ // Both default to ~/.bundle, matching bundler's own resolution order.
48
+ export function bundleConfigPath(env = process.env, home = homedir()) {
49
+ if (env.BUNDLE_USER_CONFIG) return env.BUNDLE_USER_CONFIG;
50
+ if (env.BUNDLE_USER_HOME) return join(env.BUNDLE_USER_HOME, "config");
51
+ return join(home, ".bundle", "config");
52
+ }
53
+
45
54
  export function miseConfigPath(env = process.env, home = homedir(), platform = process.platform) {
46
55
  if (env.MISE_GLOBAL_CONFIG_FILE) return env.MISE_GLOBAL_CONFIG_FILE;
47
56
  if (platform === "win32") {
@@ -34,8 +34,9 @@ export function gte(v, target) {
34
34
  }
35
35
 
36
36
  export function buildPreflight(name, minBin, suffix) {
37
- return () => {
38
- const v = detectVersion(name);
37
+ const overrideKey = `PMSEC_${name.toUpperCase()}_VERSION`;
38
+ return (ctx) => {
39
+ const v = detectVersion(name, ["--version"], { env: ctx?.env, overrideKey });
39
40
  if (v === null) return { ok: true, message: null };
40
41
  if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
41
42
  return { ok: true, warn: true, version: v.raw,