pmsec 0.9.0 → 0.11.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -14
  3. package/package.json +2 -1
  4. package/src/cli.mjs +112 -26
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
@@ -8,37 +8,37 @@ re-verification, build-script attestation, and more.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npx pmsec enable
12
- npx pmsec check
13
- npx pmsec disable
11
+ npx pmsec
12
+ npx pmsec --check
13
+ npx pmsec --disable
14
14
  ```
15
15
 
16
16
  ```bash
17
- uvx pmsec enable
18
- uvx pmsec check
19
- uvx pmsec disable
17
+ uvx pmsec
18
+ uvx pmsec --check
19
+ uvx pmsec --disable
20
20
  ```
21
21
 
22
22
  If your environment already enforces cooldown (or routes through a proxy
23
23
  registry), bootstrap pmsec by overriding just for that call:
24
24
 
25
25
  ```bash
26
- npx --registry=https://registry.npmjs.org/ --min-release-age=0 pmsec check
27
- uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pmsec check
26
+ npx --registry=https://registry.npmjs.org/ --min-release-age=0 pmsec --check
27
+ uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pmsec --check
28
28
  ```
29
29
 
30
30
  Zero runtime dependencies, ESM, requires Node 22+.
31
31
 
32
- ## Commands
32
+ ## Usage
33
33
 
34
- | Command | Description |
34
+ | Invocation | Description |
35
35
  | --- | --- |
36
- | `pmsec enable` | Write the hardening bundle (3-day cooldown + per-tool extras) to every selected tool's user config. |
37
- | `pmsec check` | Read each tool's config; exit 1 if any row is missing or below the bundled value. |
38
- | `pmsec disable` | Remove every key the bundle set; other keys in the file are preserved. |
36
+ | `pmsec` | Default action: write the hardening bundle (1-day cooldown + per-tool extras) to every selected tool's user config. |
37
+ | `pmsec --check` | Read each tool's config; exit 1 if any row is missing or below the bundled value. |
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 3-day default), `--force` (overwrite stricter existing cooldowns; default is monotonic), `--json`.
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.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "pmsec",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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";
@@ -17,19 +18,17 @@ export const VERSION = JSON.parse(
17
18
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
18
19
  ).version;
19
20
 
20
- const USAGE = `pmsec <command> [options]
21
+ const USAGE = `pmsec [options]
21
22
 
22
23
  Zero-config install-time supply-chain hardening across npm, pnpm, yarn,
23
- bun, cargo, mise, uv. \`set\` flips on every safe-by-default key each
24
- tool exposes (cooldown, audit-level, trust-policy, hardened mode,
24
+ bun, cargo, mise, uv. Default action enables every safe-by-default key
25
+ each tool exposes (cooldown, audit-level, trust-policy, hardened mode,
25
26
  attestation re-verification, ...). No knobs.
26
27
 
27
- Commands:
28
- enable Apply the hardening bundle to all selected tools
29
- disable Remove the hardening bundle from selected tools
30
- check Verify the bundle is in place (exit 1 if anything missing)
31
-
32
28
  Options:
29
+ --check Verify the bundle is in place (exit 1 if anything missing)
30
+ --disable Remove the hardening bundle from selected tools
31
+ --doctor Diagnose effective paths/owner/uid (read-only; for MDM debugging)
33
32
  --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv)
34
33
  --days N Override cooldown days (default 1)
35
34
  --force Overwrite stricter existing cooldowns (otherwise enable is monotonic)
@@ -38,11 +37,11 @@ Options:
38
37
  -h, --help Show this help
39
38
 
40
39
  Examples:
41
- npx pmsec enable
42
- npx pmsec enable --days 7
43
- npx pmsec enable --days 1 --force
44
- npx pmsec check
45
- npx pmsec disable --tool npm
40
+ npx pmsec
41
+ npx pmsec --days 7
42
+ npx pmsec --days 1 --force
43
+ npx pmsec --check
44
+ npx pmsec --disable --tool npm
46
45
  `;
47
46
 
48
47
  function parseDays(raw) {
@@ -52,7 +51,13 @@ function parseDays(raw) {
52
51
  }
53
52
 
54
53
  function parse(argv) {
55
- const opts = { command: null, json: false, only: null, days: BUNDLE_DAYS, force: false, help: false, version: false };
54
+ const opts = { mode: "enable", json: false, only: null, days: BUNDLE_DAYS, force: false, help: false, version: false };
55
+ let modeSet = false;
56
+ const setMode = (m) => {
57
+ if (modeSet && opts.mode !== m) throw new Error(`--check, --disable, and --doctor are mutually exclusive`);
58
+ opts.mode = m;
59
+ modeSet = true;
60
+ };
56
61
  const positional = [];
57
62
  for (let i = 0; i < argv.length; i++) {
58
63
  const a = argv[i];
@@ -60,6 +65,9 @@ function parse(argv) {
60
65
  else if (a === "-V" || a === "--version") opts.version = true;
61
66
  else if (a === "--json") opts.json = true;
62
67
  else if (a === "--force") opts.force = true;
68
+ else if (a === "--check") setMode("check");
69
+ else if (a === "--disable") setMode("disable");
70
+ else if (a === "--doctor") setMode("doctor");
63
71
  else if (a === "--tool") opts.only = argv[++i].split(",");
64
72
  else if (a.startsWith("--tool=")) opts.only = a.slice(7).split(",");
65
73
  else if (a === "--days") opts.days = parseDays(argv[++i]);
@@ -67,8 +75,7 @@ function parse(argv) {
67
75
  else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
68
76
  else positional.push(a);
69
77
  }
70
- opts.command = positional[0] ?? null;
71
- if (positional.length > 1) throw new Error(`unexpected argument: ${positional[1]}`);
78
+ if (positional.length) throw new Error(`unexpected argument: ${positional[0]}`);
72
79
  return opts;
73
80
  }
74
81
 
@@ -121,11 +128,91 @@ async function runCheck(targets, { json, days }, ctx, out, err) {
121
128
  const ok = failingPrimary.length === 0 && failingExtras.length === 0;
122
129
  if (json) out.write(JSON.stringify({ bundleDays: days, rows, ok }, null, 2) + "\n");
123
130
  else out.write(renderHuman(rows, days));
124
- if (failingPrimary.length) err.write(`pmsec: ${failingPrimary.length} tool(s) below ${days} days — run \`pmsec enable\`\n`);
125
- if (failingExtras.length) err.write(`pmsec: ${failingExtras.length} hardening setting(s) not at safe value — run \`pmsec enable\`\n`);
131
+ if (failingPrimary.length) err.write(`pmsec: ${failingPrimary.length} tool(s) below ${days} days — run \`pmsec\`\n`);
132
+ if (failingExtras.length) err.write(`pmsec: ${failingExtras.length} hardening setting(s) not at safe value — run \`pmsec\`\n`);
126
133
  return ok ? 0 : 1;
127
134
  }
128
135
 
136
+ // `pmsec doctor` runs read-only and reports the same path resolution that
137
+ // enable/check/disable would do, plus identity (uid/euid) and parent-dir
138
+ // writability — the smallest set of facts an MDM operator needs to diagnose
139
+ // "pmsec ran but wrote to nowhere" (root's $HOME) or "wrote a file no one can
140
+ // read" (chown failed under SIP/SELinux). Never mutates the filesystem.
141
+ function probePath(p, uid) {
142
+ const parent = dirname(p);
143
+ let exists = false, writable = false, parentExists = false, owner = null;
144
+ try { accessSync(p, fsConstants.F_OK); exists = true; } catch {}
145
+ if (exists) {
146
+ try { accessSync(p, fsConstants.W_OK); writable = true; } catch {}
147
+ try {
148
+ const st = statSync(p);
149
+ // Only POSIX uids are meaningful; on win32 st.uid is 0 and ignored downstream.
150
+ owner = uid === null ? null : st.uid;
151
+ } catch {}
152
+ }
153
+ try { accessSync(parent, fsConstants.F_OK); parentExists = true; } catch {}
154
+ // Walk up to the deepest existing ancestor — pmsec runs mkdir -p, so what
155
+ // matters is whether that ancestor is writable, not the literal parent.
156
+ let probe = parent, ancestor = null;
157
+ while (probe && probe !== dirname(probe)) {
158
+ try { accessSync(probe, fsConstants.F_OK); ancestor = probe; break; } catch {}
159
+ probe = dirname(probe);
160
+ }
161
+ let parentWritable = false;
162
+ if (ancestor) {
163
+ try { accessSync(ancestor, fsConstants.W_OK); parentWritable = true; } catch {}
164
+ }
165
+ return { path: p, parent, exists, writable, parentExists, parentWritable, owner };
166
+ }
167
+
168
+ function runDoctor(targets, json, ctx, out) {
169
+ const isPosix = ctx.platform !== "win32" && typeof process.getuid === "function";
170
+ const uid = isPosix ? process.getuid() : null;
171
+ const euid = isPosix && typeof process.geteuid === "function" ? process.geteuid() : uid;
172
+ let username = null;
173
+ try { username = userInfo().username; } catch {}
174
+ const tools = targets.map(t => {
175
+ const p = t.path(ctx);
176
+ const probe = probePath(p, uid);
177
+ return { tool: t.name, key: t.key, ...probe };
178
+ });
179
+ // doctor.ok is intentionally narrow: the parent directory must be writable
180
+ // by the running uid. Everything else is informational.
181
+ const okFlag = tools.every(t => t.parentWritable);
182
+ const report = {
183
+ doctor: true,
184
+ version: VERSION,
185
+ platform: ctx.platform,
186
+ uid, euid, username,
187
+ home: ctx.home,
188
+ pmsecHome: ctx.env.PMSEC_HOME ?? null,
189
+ pmsecHomeSource: ctx.env.PMSEC_HOME ? "PMSEC_HOME" : "HOME",
190
+ tools,
191
+ ok: okFlag,
192
+ };
193
+ if (json) {
194
+ out.write(JSON.stringify(report, null, 2) + "\n");
195
+ return okFlag ? 0 : 1;
196
+ }
197
+ out.write(`pmsec ${VERSION} doctor\n`);
198
+ out.write(` platform : ${ctx.platform}\n`);
199
+ if (isPosix) out.write(` uid/euid : ${uid}/${euid}${username ? ` (${username})` : ""}\n`);
200
+ out.write(` HOME : ${ctx.home}\n`);
201
+ out.write(` PMSEC_HOME : ${ctx.env.PMSEC_HOME ?? "(unset — using HOME)"}\n\n`);
202
+ for (const t of tools) {
203
+ const flag = t.parentWritable ? "ok " : "BLOCK ";
204
+ const ownerSuffix = t.exists && t.owner !== null ? ` (uid=${t.owner})` : "";
205
+ out.write(`${flag} ${t.tool.padEnd(4)} ${t.path}${ownerSuffix}\n`);
206
+ if (!t.parentWritable) {
207
+ // Only surface noise when there's a real problem. "parent missing but
208
+ // mkdir -p can fix it" is the common case and isn't worth a line.
209
+ out.write(` no writable ancestor for ${t.parent}\n`);
210
+ }
211
+ }
212
+ if (!okFlag) out.write(`\npmsec doctor: at least one parent directory is not writable as uid ${uid ?? "(non-POSIX)"}.\n`);
213
+ return okFlag ? 0 : 1;
214
+ }
215
+
129
216
  function explainFsError(e, tool) {
130
217
  if (e?.code === "EACCES" || e?.code === "EPERM") {
131
218
  const p = e.path ?? "";
@@ -204,17 +291,16 @@ export async function run(argv, {
204
291
  try { opts = parse(argv); }
205
292
  catch (e) { err.write(`pmsec: ${e.message}\n`); return 2; }
206
293
  if (opts.version) { out.write(`pmsec ${VERSION}\n`); return 0; }
207
- if (opts.help || !opts.command) { out.write(USAGE); return opts.help ? 0 : 2; }
294
+ if (opts.help) { out.write(USAGE); return 0; }
208
295
  let targets;
209
296
  try { targets = selectTools(opts.only); }
210
297
  catch (e) { err.write(`pmsec: ${e.message}\n`); return 2; }
211
298
  const ctx = { env, home, platform };
212
299
  try {
213
- if (opts.command === "check") return await runCheck(targets, opts, ctx, out, err);
214
- if (opts.command === "enable") return await runEnable(targets, opts.json, opts.days, opts.force, ctx, out, err);
215
- if (opts.command === "disable") return await runDisable(targets, opts.json, ctx, out, err);
216
- err.write(`pmsec: unknown command "${opts.command}"\n`);
217
- return 2;
300
+ if (opts.mode === "check") return await runCheck(targets, opts, ctx, out, err);
301
+ if (opts.mode === "disable") return await runDisable(targets, opts.json, ctx, out, err);
302
+ if (opts.mode === "doctor") return runDoctor(targets, opts.json, ctx, out);
303
+ return await runEnable(targets, opts.json, opts.days, opts.force, ctx, out, err);
218
304
  } catch (e) {
219
305
  err.write(`pmsec: ${e.message}\n`);
220
306
  return 1;