ose-auditor 1.0.3 → 1.0.4

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 (2) hide show
  1. package/ose.js +294 -68
  2. package/package.json +1 -1
package/ose.js CHANGED
@@ -2,17 +2,18 @@
2
2
  /**
3
3
  * ose.js -- npm/npx wrapper for the OSE Auditor Python CLI.
4
4
  *
5
- * This script does NOT reimplement any audit logic. It is a thin launcher
6
- * that:
7
- * 1. Locates a usable Python 3 interpreter on the host machine.
8
- * 2. Checks whether the `ose-auditor` PyPI package (which provides the
9
- * `client.ose` module / `ose` console script) is already importable.
10
- * 3. If not, installs it on first run via `pip install --user ose-auditor`
11
- * (can be skipped with OSE_NO_AUTO_INSTALL=1, e.g. in CI images that
12
- * pre-bake the dependency).
13
- * 4. Forwards argv and the current environment (including OSE_API_KEY,
14
- * OSE_SERVER_URL) straight through to `python -m client.ose`, and
15
- * exits with the same exit code the Python process produced.
5
+ * Installation strategy (in priority order):
6
+ * 1. pipx install ose-auditor -- preferred; isolated env, no PEP 668 issues.
7
+ * 2. venv in ~/.ose-venv -- fallback when pipx is absent.
8
+ * 3. pip install --user -- last resort; catches PEP 668 error and
9
+ * advises the user to install pipx instead.
10
+ *
11
+ * After installation the CLI is invoked as:
12
+ * - pipx path: `python -m client.ose` inside the pipx venv, OR
13
+ * - venv path: `<~/.ose-venv>/bin/python -m client.ose`
14
+ *
15
+ * Environment variables forwarded transparently:
16
+ * OSE_API_KEY, OSE_SERVER_URL, OSE_NO_AUTO_INSTALL
16
17
  *
17
18
  * Usage:
18
19
  * npx ose-auditor audit ./my-project
@@ -24,20 +25,33 @@
24
25
 
25
26
  const { spawnSync } = require("child_process");
26
27
  const os = require("os");
28
+ const path = require("path");
29
+ const fs = require("fs");
27
30
 
28
31
  const MIN_PYTHON_MAJOR = 3;
29
32
  const MIN_PYTHON_MINOR = 9;
30
33
  const PYPI_PACKAGE = "ose-auditor";
34
+ const OSE_VENV_DIR = path.join(os.homedir(), ".ose-venv");
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Generic subprocess helpers
38
+ // ---------------------------------------------------------------------------
31
39
 
32
40
  /**
33
- * Run a command synchronously, capturing stdout/stderr (not inherited),
34
- * for internal probing only -- never used for the final audit invocation.
35
- *
36
- * @param {string} command
37
- * @param {string[]} args
38
- * @returns {{ok: boolean, stdout: string, stderr: string, status: number|null}}
41
+ * Run a command, capturing output (for probing only -- never for audit output).
39
42
  */
43
+ // Allowlisted commands that may be used as the executable.
44
+ // argv from the user is ONLY ever passed as arguments to these
45
+ // fixed executables -- never as the command itself.
46
+ const ALLOWED_COMMANDS = new Set([
47
+ "pipx", "python3", "python", "py", "pip", "pip3",
48
+ "ose", "ose-mcp",
49
+ ]);
50
+
40
51
  function probe(command, args) {
52
+ if (!ALLOWED_COMMANDS.has(command)) {
53
+ return { ok: false, stdout: "", stderr: "disallowed command", status: null };
54
+ }
41
55
  const result = spawnSync(command, args, {
42
56
  encoding: "utf8",
43
57
  windowsHide: true,
@@ -54,27 +68,43 @@ function probe(command, args) {
54
68
  }
55
69
 
56
70
  /**
57
- * Find a usable Python 3 interpreter, preferring `python3` then `python`.
58
- * Verifies the interpreter actually reports Python >= MIN_PYTHON_MAJOR.MIN_PYTHON_MINOR.
59
- *
60
- * @returns {string|null} The interpreter command name, or null if none found.
71
+ * Run a command with inherited stdio (for install output the user should see).
72
+ * Returns the exit status.
61
73
  */
74
+ function run(command, args, env) {
75
+ if (!ALLOWED_COMMANDS.has(command)) {
76
+ process.stderr.write(`[ose-auditor] Refused to run disallowed command: ${command}\n`);
77
+ return null;
78
+ }
79
+ const result = spawnSync(command, args, {
80
+ stdio: "inherit",
81
+ windowsHide: true,
82
+ env: env || process.env,
83
+ });
84
+ if (result.error) return null;
85
+ return result.status;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Python interpreter discovery
90
+ // ---------------------------------------------------------------------------
91
+
62
92
  function findPython() {
63
- const candidates = os.platform() === "win32" ? ["py", "python", "python3"] : ["python3", "python"];
93
+ const candidates =
94
+ os.platform() === "win32"
95
+ ? ["py", "python", "python3"]
96
+ : ["python3", "python"];
64
97
 
65
98
  for (const candidate of candidates) {
66
- const versionCheck = probe(candidate, [
99
+ const check = probe(candidate, [
67
100
  "-c",
68
101
  "import sys; print('%d.%d' % sys.version_info[:2])",
69
102
  ]);
70
- if (!versionCheck.ok) continue;
71
-
72
- const [majorStr, minorStr] = versionCheck.stdout.split(".");
73
- const major = parseInt(majorStr, 10);
74
- const minor = parseInt(minorStr, 10);
103
+ if (!check.ok) continue;
104
+ const [maj, min] = check.stdout.split(".").map(Number);
75
105
  if (
76
- major > MIN_PYTHON_MAJOR ||
77
- (major === MIN_PYTHON_MAJOR && minor >= MIN_PYTHON_MINOR)
106
+ maj > MIN_PYTHON_MAJOR ||
107
+ (maj === MIN_PYTHON_MAJOR && min >= MIN_PYTHON_MINOR)
78
108
  ) {
79
109
  return candidate;
80
110
  }
@@ -82,89 +112,285 @@ function findPython() {
82
112
  return null;
83
113
  }
84
114
 
85
- /**
86
- * Check whether `client.ose` is importable with the given interpreter,
87
- * i.e. whether the ose-auditor package is already installed.
88
- *
89
- * @param {string} python
90
- * @returns {boolean}
91
- */
92
- function isOseInstalled(python) {
115
+ // ---------------------------------------------------------------------------
116
+ // pipx strategy
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function hasPipx() {
120
+ return probe("pipx", ["--version"]).ok;
121
+ }
122
+
123
+ /** Returns the python executable inside the pipx-managed ose-auditor venv. */
124
+ function pipxPython() {
125
+ const result = probe("pipx", ["runpip", PYPI_PACKAGE, "show", "--files"]);
126
+ // Simpler: ask pipx for the venv location via environment inspection.
127
+ // pipx stores venvs in ~/.local/pipx/venvs/<package>/
128
+ const pipxHome =
129
+ process.env.PIPX_HOME ||
130
+ path.join(
131
+ process.env.HOME || os.homedir(),
132
+ os.platform() === "win32" ? "AppData\\Local\\pipx\\pipx" : ".local/pipx"
133
+ );
134
+ const venvBin = path.join(
135
+ pipxHome,
136
+ "venvs",
137
+ PYPI_PACKAGE,
138
+ os.platform() === "win32" ? "Scripts" : "bin",
139
+ os.platform() === "win32" ? "python.exe" : "python"
140
+ );
141
+ if (fs.existsSync(venvBin)) return venvBin;
142
+ // Fallback: pipx inject puts it on PATH as `ose`; use that python
143
+ return null;
144
+ }
145
+
146
+ function isOseInstalledViaPipx() {
147
+ // Check if `ose` CLI is available after pipx install
148
+ return probe("pipx", ["list", "--short"]).stdout.includes(PYPI_PACKAGE);
149
+ }
150
+
151
+ function installViaPipx() {
152
+ process.stderr.write(
153
+ `[ose-auditor] Installing '${PYPI_PACKAGE}' via pipx...\n`
154
+ );
155
+ const status = run("pipx", ["install", "--force", PYPI_PACKAGE]);
156
+ return status === 0;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // venv (~/.ose-venv) strategy
161
+ // ---------------------------------------------------------------------------
162
+
163
+ function venvPython() {
164
+ const bin =
165
+ os.platform() === "win32"
166
+ ? path.join(OSE_VENV_DIR, "Scripts", "python.exe")
167
+ : path.join(OSE_VENV_DIR, "bin", "python");
168
+ return fs.existsSync(bin) ? bin : null;
169
+ }
170
+
171
+ function isOseInstalledInVenv(python) {
93
172
  return probe(python, ["-c", "import client.ose"]).ok;
94
173
  }
95
174
 
96
- /**
97
- * Install the ose-auditor package for the current user via pip.
98
- *
99
- * @param {string} python
100
- * @returns {boolean} true on success
101
- */
102
- function installOse(python) {
175
+ function createVenv(basePython) {
176
+ process.stderr.write(
177
+ `[ose-auditor] Creating virtual environment at ${OSE_VENV_DIR}...\n`
178
+ );
179
+ const status = run(basePython, ["-m", "venv", OSE_VENV_DIR]);
180
+ return status === 0;
181
+ }
182
+
183
+ function installInVenv(python) {
103
184
  process.stderr.write(
104
- `[ose-auditor] First run: installing the '${PYPI_PACKAGE}' Python package via pip...\n`
185
+ `[ose-auditor] Installing '${PYPI_PACKAGE}' into ${OSE_VENV_DIR}...\n`
186
+ );
187
+ const pip =
188
+ os.platform() === "win32"
189
+ ? path.join(OSE_VENV_DIR, "Scripts", "pip.exe")
190
+ : path.join(OSE_VENV_DIR, "bin", "pip");
191
+ const status = run(pip, ["install", "--upgrade", PYPI_PACKAGE]);
192
+ return status === 0;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // pip --user fallback (last resort)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function isOseInstalledGlobally(python) {
200
+ return probe(python, ["-c", "import client.ose"]).ok;
201
+ }
202
+
203
+ function installViaPipUser(python) {
204
+ process.stderr.write(
205
+ `[ose-auditor] Attempting pip install --user '${PYPI_PACKAGE}'...\n`
105
206
  );
106
207
  const result = spawnSync(
107
208
  python,
108
209
  ["-m", "pip", "install", "--user", "--upgrade", PYPI_PACKAGE],
109
- { stdio: "inherit", windowsHide: true }
210
+ { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }
110
211
  );
212
+
213
+ const combinedOutput = (result.stdout || "") + (result.stderr || "");
214
+ if (
215
+ combinedOutput.includes("externally-managed-environment") ||
216
+ combinedOutput.includes("PEP 668")
217
+ ) {
218
+ process.stderr.write(
219
+ "\n[ose-auditor] ✗ pip install was blocked by your system Python (PEP 668 / externally-managed-environment).\n" +
220
+ " Fix: install pipx, then re-run npx ose-auditor:\n\n" +
221
+ " # macOS\n" +
222
+ " brew install pipx && pipx ensurepath\n\n" +
223
+ " # Linux\n" +
224
+ " sudo apt install pipx && pipx ensurepath\n\n" +
225
+ " # Windows (PowerShell)\n" +
226
+ " python -m pip install --user pipx\n\n" +
227
+ " Then re-run: npx ose-auditor audit .\n\n"
228
+ );
229
+ return false;
230
+ }
231
+
111
232
  if (result.status !== 0) {
112
233
  process.stderr.write(
113
- `[ose-auditor] pip install failed (exit code ${result.status}). ` +
114
- `Try installing manually: ${python} -m pip install --user ${PYPI_PACKAGE}\n`
234
+ `[ose-auditor] pip install failed (exit ${result.status}).\n` +
235
+ `Try manually: ${python} -m pip install --user ${PYPI_PACKAGE}\n`
115
236
  );
116
237
  return false;
117
238
  }
118
239
  return true;
119
240
  }
120
241
 
242
+ // ---------------------------------------------------------------------------
243
+ // Main
244
+ // ---------------------------------------------------------------------------
245
+
121
246
  function main() {
122
247
  const args = process.argv.slice(2);
123
248
 
124
- const python = findPython();
125
- if (!python) {
249
+ // ------------------------------------------------------------------
250
+ // 1. Find a usable base Python interpreter
251
+ // ------------------------------------------------------------------
252
+ const basePython = findPython();
253
+ if (!basePython) {
126
254
  process.stderr.write(
127
- `[ose-auditor] Could not find a Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ interpreter on PATH.\n` +
128
- "Install Python from https://python.org and ensure 'python3' is on your PATH, then retry.\n"
255
+ `[ose-auditor] Could not find a Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ interpreter.\n` +
256
+ "Install Python from https://python.org and ensure 'python3' is on your PATH.\n"
129
257
  );
130
258
  process.exit(1);
131
259
  }
132
260
 
133
- if (!isOseInstalled(python)) {
134
- if (process.env.OSE_NO_AUTO_INSTALL === "1") {
261
+ if (process.env.OSE_NO_AUTO_INSTALL === "1") {
262
+ // Skip install entirely; trust that the package is already available.
263
+ const p = venvPython() || basePython;
264
+ if (!isOseInstalledInVenv(p) && !isOseInstalledGlobally(basePython)) {
135
265
  process.stderr.write(
136
- `[ose-auditor] '${PYPI_PACKAGE}' is not installed and OSE_NO_AUTO_INSTALL=1 is set. ` +
137
- `Install it manually: ${python} -m pip install ${PYPI_PACKAGE}\n`
266
+ `[ose-auditor] '${PYPI_PACKAGE}' is not installed and OSE_NO_AUTO_INSTALL=1.\n` +
267
+ `Install manually: pipx install ${PYPI_PACKAGE}\n`
138
268
  );
139
269
  process.exit(1);
140
270
  }
141
- const installed = installOse(python);
142
- if (!installed || !isOseInstalled(python)) {
143
- process.stderr.write(
144
- "[ose-auditor] Installation did not succeed; cannot continue.\n"
145
- );
146
- process.exit(1);
271
+ launchCli(p, args);
272
+ return;
273
+ }
274
+
275
+ // ------------------------------------------------------------------
276
+ // 2. Strategy A: pipx
277
+ // ------------------------------------------------------------------
278
+ if (hasPipx()) {
279
+ if (!isOseInstalledViaPipx()) {
280
+ const ok = installViaPipx();
281
+ if (!ok) {
282
+ process.stderr.write("[ose-auditor] pipx install failed.\n");
283
+ // fall through to venv strategy
284
+ }
285
+ }
286
+ if (isOseInstalledViaPipx()) {
287
+ // Find the python inside pipx's managed venv
288
+ const pp = pipxPython();
289
+ if (pp) {
290
+ launchCli(pp, args);
291
+ return;
292
+ }
293
+ // pipx puts `ose` on PATH directly; try invoking it that way
294
+ launchOseCli(args);
295
+ return;
296
+ }
297
+ }
298
+
299
+ // ------------------------------------------------------------------
300
+ // 3. Strategy B: ~/.ose-venv
301
+ // ------------------------------------------------------------------
302
+ {
303
+ let vPython = venvPython();
304
+ if (!vPython) {
305
+ const created = createVenv(basePython);
306
+ if (!created) {
307
+ process.stderr.write(
308
+ `[ose-auditor] Failed to create venv at ${OSE_VENV_DIR}.\n`
309
+ );
310
+ // fall through to pip --user
311
+ } else {
312
+ vPython = venvPython();
313
+ }
147
314
  }
315
+
316
+ if (vPython) {
317
+ if (!isOseInstalledInVenv(vPython)) {
318
+ const ok = installInVenv(vPython);
319
+ if (!ok) {
320
+ process.stderr.write("[ose-auditor] venv install failed.\n");
321
+ // fall through
322
+ }
323
+ }
324
+ if (isOseInstalledInVenv(vPython)) {
325
+ launchCli(vPython, args);
326
+ return;
327
+ }
328
+ }
329
+ }
330
+
331
+ // ------------------------------------------------------------------
332
+ // 4. Strategy C: pip install --user (last resort)
333
+ // ------------------------------------------------------------------
334
+ {
335
+ const ok = installViaPipUser(basePython);
336
+ if (ok && isOseInstalledGlobally(basePython)) {
337
+ launchCli(basePython, args);
338
+ return;
339
+ }
340
+ process.stderr.write(
341
+ "[ose-auditor] All installation strategies failed. Cannot continue.\n"
342
+ );
343
+ process.exit(1);
148
344
  }
345
+ }
149
346
 
347
+ /** Launch `ose` (the pipx-injected console script) directly. */
348
+ function launchOseCli(args) {
150
349
  if (!process.env.OSE_API_KEY) {
151
350
  process.stderr.write(
152
- "[ose-auditor] Note: OSE_API_KEY is not set. Audits with findings will need it " +
153
- "to fetch patches (https://ose.crestsek.com). Audits with zero findings still work.\n"
351
+ "[ose-auditor] Note: OSE_API_KEY is not set. " +
352
+ "Audits with findings will need it to fetch patches (https://ose.crestsek.com).\n"
353
+ );
354
+ }
355
+ const result = spawnSync("ose", args, {
356
+ stdio: "inherit",
357
+ env: process.env,
358
+ windowsHide: true,
359
+ });
360
+ if (result.error) {
361
+ process.stderr.write(
362
+ `[ose-auditor] Failed to launch 'ose': ${result.error}\n`
154
363
  );
364
+ process.exit(1);
155
365
  }
366
+ process.exit(result.status === null ? 1 : result.status);
367
+ }
156
368
 
369
+ /** Launch `python -m client.ose` with the given interpreter. */
370
+ function launchCli(python, args) {
371
+ // python is always a value returned by findPython(), which only
372
+ // returns strings from the fixed ALLOWED_COMMANDS set above.
373
+ if (!ALLOWED_COMMANDS.has(python) && !require("fs").existsSync(python)) {
374
+ process.stderr.write(`[ose-auditor] Refused to execute unlisted interpreter: ${python}\n`);
375
+ process.exit(1);
376
+ }
377
+ if (!process.env.OSE_API_KEY) {
378
+ process.stderr.write(
379
+ "[ose-auditor] Note: OSE_API_KEY is not set. " +
380
+ "Audits with findings will need it to fetch patches (https://ose.crestsek.com).\n"
381
+ );
382
+ }
157
383
  const result = spawnSync(python, ["-m", "client.ose", ...args], {
158
384
  stdio: "inherit",
159
385
  env: process.env,
160
386
  windowsHide: true,
161
387
  });
162
-
163
388
  if (result.error) {
164
- process.stderr.write(`[ose-auditor] Failed to launch Python CLI: ${result.error}\n`);
389
+ process.stderr.write(
390
+ `[ose-auditor] Failed to launch Python CLI: ${result.error}\n`
391
+ );
165
392
  process.exit(1);
166
393
  }
167
-
168
394
  process.exit(result.status === null ? 1 : result.status);
169
395
  }
170
396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ose-auditor",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Run OSE Auditor (financial-logic security scanner for Node.js/TypeScript) without installing Python yourself -- this wrapper finds a Python 3 interpreter, installs the ose-auditor PyPI package on first run if needed, and forwards all arguments to it.",
5
5
  "license": "MIT",
6
6
  "bin": {