@reddoorla/maintenance 0.1.0 → 0.1.2
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/dist/cli/bin.js +344 -112
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +222 -66
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +290 -82
- package/dist/index.js.map +1 -1
- package/dist/util/pkg.d.ts +6 -2
- package/dist/util/pkg.js +3 -1
- package/dist/util/pkg.js.map +1 -1
- package/package.json +14 -16
|
@@ -4,15 +4,18 @@ import { resolve as resolve2 } from "path";
|
|
|
4
4
|
// src/audits/util/spawn.ts
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve3, reject) => {
|
|
7
|
+
const streaming = opts.streaming === true;
|
|
7
8
|
const child = spawn(cmd, [...args], {
|
|
8
9
|
cwd: opts.cwd,
|
|
9
10
|
env: opts.env ?? process.env,
|
|
10
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
11
|
+
stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"]
|
|
11
12
|
});
|
|
12
13
|
let stdout = "";
|
|
13
14
|
let stderr = "";
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
if (!streaming) {
|
|
16
|
+
child.stdout?.on("data", (chunk) => stdout += String(chunk));
|
|
17
|
+
child.stderr?.on("data", (chunk) => stderr += String(chunk));
|
|
18
|
+
}
|
|
16
19
|
const timer = opts.timeoutMs ? setTimeout(() => {
|
|
17
20
|
child.kill("SIGTERM");
|
|
18
21
|
reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
|
|
@@ -140,7 +143,7 @@ async function depsAudit(ctx) {
|
|
|
140
143
|
// src/audits/lint.ts
|
|
141
144
|
import { existsSync } from "fs";
|
|
142
145
|
import { readFile as readFile2 } from "fs/promises";
|
|
143
|
-
import { join as join2
|
|
146
|
+
import { join as join2 } from "path";
|
|
144
147
|
import { ESLint } from "eslint";
|
|
145
148
|
import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
|
|
146
149
|
import { glob } from "tinyglobby";
|
|
@@ -169,19 +172,19 @@ async function lintAudit(ctx) {
|
|
|
169
172
|
errorOnUnmatchedPattern: false
|
|
170
173
|
});
|
|
171
174
|
const relFiles = await listFiles(site.path);
|
|
172
|
-
const
|
|
173
|
-
const eslintResults = await eslint.lintFiles(filesToLint);
|
|
175
|
+
const eslintResults = await eslint.lintFiles(relFiles);
|
|
174
176
|
const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);
|
|
175
177
|
const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
|
|
176
178
|
const prettierUnformatted = [];
|
|
177
|
-
for (const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
179
|
+
for (const rel of relFiles) {
|
|
180
|
+
const absForResolve = join2(site.path, rel);
|
|
181
|
+
const source = await readFile2(absForResolve, "utf-8");
|
|
182
|
+
const options = await prettierResolveConfig(absForResolve) ?? {};
|
|
183
|
+
const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
|
|
184
|
+
if (!ok) prettierUnformatted.push(rel);
|
|
182
185
|
}
|
|
183
186
|
const status = eslintErrors > 0 || prettierUnformatted.length > 0 ? "fail" : eslintWarnings > 0 ? "warn" : "pass";
|
|
184
|
-
const summary = status === "pass" ? `lint clean across ${
|
|
187
|
+
const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
|
|
185
188
|
return {
|
|
186
189
|
audit: "lint",
|
|
187
190
|
site: siteLabel2(site),
|
|
@@ -191,7 +194,7 @@ async function lintAudit(ctx) {
|
|
|
191
194
|
eslintErrors,
|
|
192
195
|
eslintWarnings,
|
|
193
196
|
prettierUnformatted,
|
|
194
|
-
files:
|
|
197
|
+
files: relFiles.length
|
|
195
198
|
}
|
|
196
199
|
};
|
|
197
200
|
}
|
|
@@ -205,6 +208,61 @@ function classify(v) {
|
|
|
205
208
|
if (v.moderate > 0 || v.low > 0) return "warn";
|
|
206
209
|
return "pass";
|
|
207
210
|
}
|
|
211
|
+
function normalizeSeverity(s) {
|
|
212
|
+
if (s === "low" || s === "moderate" || s === "high" || s === "critical") return s;
|
|
213
|
+
return "low";
|
|
214
|
+
}
|
|
215
|
+
function extractAdvisoriesFromPnpm(parsed) {
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const a of Object.values(parsed.advisories ?? {})) {
|
|
218
|
+
if (!a) continue;
|
|
219
|
+
out.push({
|
|
220
|
+
module: a.module_name ?? "unknown",
|
|
221
|
+
severity: normalizeSeverity(a.severity),
|
|
222
|
+
title: a.title ?? "(no title)",
|
|
223
|
+
...a.cves ? { cves: a.cves } : {},
|
|
224
|
+
...a.url ? { url: a.url } : {}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
function resolveNpmAdvisoryRoot(startName, vulnerabilities) {
|
|
230
|
+
const seen = /* @__PURE__ */ new Set();
|
|
231
|
+
let current = startName;
|
|
232
|
+
while (!seen.has(current)) {
|
|
233
|
+
seen.add(current);
|
|
234
|
+
const entry = vulnerabilities[current];
|
|
235
|
+
if (!entry || !Array.isArray(entry.via)) return { rootName: current };
|
|
236
|
+
const detailed = entry.via.find(
|
|
237
|
+
(e) => typeof e === "object" && e !== null
|
|
238
|
+
);
|
|
239
|
+
if (detailed) return { rootName: current, detail: detailed };
|
|
240
|
+
const next = entry.via.find((e) => typeof e === "string");
|
|
241
|
+
if (!next || next === current) return { rootName: current };
|
|
242
|
+
current = next;
|
|
243
|
+
}
|
|
244
|
+
return { rootName: current };
|
|
245
|
+
}
|
|
246
|
+
function extractAdvisoriesFromNpm(parsed) {
|
|
247
|
+
const vulnerabilities = parsed.vulnerabilities ?? {};
|
|
248
|
+
const roots = /* @__PURE__ */ new Map();
|
|
249
|
+
for (const [name, v] of Object.entries(vulnerabilities)) {
|
|
250
|
+
if (!v) continue;
|
|
251
|
+
const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);
|
|
252
|
+
if (roots.has(rootName)) continue;
|
|
253
|
+
const rootEntry = vulnerabilities[rootName];
|
|
254
|
+
const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);
|
|
255
|
+
const title = detail?.title ?? rootName;
|
|
256
|
+
const url = detail?.url;
|
|
257
|
+
roots.set(rootName, {
|
|
258
|
+
module: rootEntry?.name ?? rootName,
|
|
259
|
+
severity,
|
|
260
|
+
title,
|
|
261
|
+
...url ? { url } : {}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return [...roots.values()];
|
|
265
|
+
}
|
|
208
266
|
async function tryRun(spawn2, cmd, args, cwd) {
|
|
209
267
|
try {
|
|
210
268
|
return await spawn2(cmd, args, { cwd });
|
|
@@ -258,26 +316,27 @@ async function securityAudit(ctx) {
|
|
|
258
316
|
details: { error: String(err), stdout: raw.stdout.slice(0, 500) }
|
|
259
317
|
};
|
|
260
318
|
}
|
|
261
|
-
const
|
|
319
|
+
const counts = {
|
|
262
320
|
low: parsed.metadata?.vulnerabilities?.low ?? 0,
|
|
263
321
|
moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,
|
|
264
322
|
high: parsed.metadata?.vulnerabilities?.high ?? 0,
|
|
265
323
|
critical: parsed.metadata?.vulnerabilities?.critical ?? 0
|
|
266
324
|
};
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
const
|
|
325
|
+
const advisories = used === "pnpm audit" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);
|
|
326
|
+
const status = classify(counts);
|
|
327
|
+
const total = counts.low + counts.moderate + counts.high + counts.critical;
|
|
328
|
+
const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;
|
|
270
329
|
return {
|
|
271
330
|
audit: "security",
|
|
272
331
|
site: label,
|
|
273
332
|
status,
|
|
274
333
|
summary,
|
|
275
|
-
details:
|
|
334
|
+
details: { counts, advisories }
|
|
276
335
|
};
|
|
277
336
|
}
|
|
278
337
|
|
|
279
338
|
// src/audits/lighthouse.ts
|
|
280
|
-
import { writeFile, mkdtemp, rm } from "fs/promises";
|
|
339
|
+
import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
|
|
281
340
|
import { tmpdir } from "os";
|
|
282
341
|
import { join as join3 } from "path";
|
|
283
342
|
|
|
@@ -313,29 +372,56 @@ var lighthouseConfig = {
|
|
|
313
372
|
function siteLabel4(site) {
|
|
314
373
|
return site.name ?? site.path;
|
|
315
374
|
}
|
|
316
|
-
function
|
|
375
|
+
async function readJsonMaybe(path) {
|
|
317
376
|
try {
|
|
318
|
-
const
|
|
319
|
-
|
|
377
|
+
const raw = await readFile3(path, "utf-8");
|
|
378
|
+
return JSON.parse(raw);
|
|
320
379
|
} catch {
|
|
321
380
|
return null;
|
|
322
381
|
}
|
|
323
|
-
|
|
382
|
+
}
|
|
383
|
+
function averageSummaries(entries) {
|
|
384
|
+
if (entries.length === 0) return {};
|
|
385
|
+
const sums = {};
|
|
386
|
+
const counts = {};
|
|
387
|
+
for (const e of entries) {
|
|
388
|
+
for (const [k, v] of Object.entries(e.summary ?? {})) {
|
|
389
|
+
if (typeof v !== "number") continue;
|
|
390
|
+
sums[k] = (sums[k] ?? 0) + v;
|
|
391
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const out = {};
|
|
395
|
+
for (const k of Object.keys(sums)) {
|
|
396
|
+
const total = sums[k] ?? 0;
|
|
397
|
+
const count = counts[k] ?? 1;
|
|
398
|
+
out[k] = total / count;
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
function categoryFromAssertion(a) {
|
|
403
|
+
const colonIdx = a.name.indexOf(":");
|
|
404
|
+
return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
|
|
405
|
+
}
|
|
406
|
+
function messageForAssertion(a) {
|
|
407
|
+
return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
|
|
324
408
|
}
|
|
325
409
|
async function lighthouseAudit(ctx) {
|
|
326
410
|
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
327
411
|
const site = ctx.site;
|
|
328
412
|
const label = siteLabel4(site);
|
|
329
|
-
const
|
|
330
|
-
const configPath = join3(
|
|
413
|
+
const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
|
|
414
|
+
const configPath = join3(configDir, "lighthouserc.json");
|
|
331
415
|
await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
|
|
416
|
+
const resultsDir = join3(site.path, ".lighthouseci");
|
|
417
|
+
await rm(resultsDir, { recursive: true, force: true });
|
|
332
418
|
let raw;
|
|
333
419
|
try {
|
|
334
420
|
raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
|
|
335
421
|
cwd: site.path
|
|
336
422
|
});
|
|
337
423
|
} catch (err) {
|
|
338
|
-
await rm(
|
|
424
|
+
await rm(configDir, { recursive: true, force: true });
|
|
339
425
|
const e = err;
|
|
340
426
|
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
341
427
|
return {
|
|
@@ -347,17 +433,32 @@ async function lighthouseAudit(ctx) {
|
|
|
347
433
|
}
|
|
348
434
|
throw err;
|
|
349
435
|
}
|
|
350
|
-
await rm(
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
436
|
+
await rm(configDir, { recursive: true, force: true });
|
|
437
|
+
const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
|
|
438
|
+
if (!manifest || manifest.length === 0) {
|
|
439
|
+
return {
|
|
440
|
+
audit: "lighthouse",
|
|
441
|
+
site: label,
|
|
442
|
+
status: "fail",
|
|
443
|
+
summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
|
|
447
|
+
const failed = assertionResults.filter((a) => !a.passed);
|
|
448
|
+
const assertions = failed.map((a) => ({
|
|
449
|
+
category: categoryFromAssertion(a),
|
|
450
|
+
level: a.level,
|
|
451
|
+
message: messageForAssertion(a)
|
|
452
|
+
}));
|
|
453
|
+
const anyError = assertions.some((a) => a.level === "error");
|
|
454
|
+
const anyWarn = assertions.some((a) => a.level === "warn");
|
|
359
455
|
const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
|
|
360
|
-
const
|
|
456
|
+
const normalized = {
|
|
457
|
+
summary: averageSummaries(manifest),
|
|
458
|
+
assertionsFailed: failed.length,
|
|
459
|
+
assertions
|
|
460
|
+
};
|
|
461
|
+
const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
|
|
361
462
|
return {
|
|
362
463
|
audit: "lighthouse",
|
|
363
464
|
site: label,
|
|
@@ -368,7 +469,7 @@ async function lighthouseAudit(ctx) {
|
|
|
368
469
|
}
|
|
369
470
|
|
|
370
471
|
// src/audits/a11y.ts
|
|
371
|
-
import { writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
472
|
+
import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
372
473
|
import { tmpdir as tmpdir2 } from "os";
|
|
373
474
|
import { join as join4 } from "path";
|
|
374
475
|
|
|
@@ -404,48 +505,81 @@ var playwrightA11yConfig = defineConfig({
|
|
|
404
505
|
});
|
|
405
506
|
|
|
406
507
|
// src/audits/a11y.ts
|
|
508
|
+
var RESULTS_REL = ".reddoor-a11y/results.json";
|
|
407
509
|
function siteLabel5(site) {
|
|
408
510
|
return site.name ?? site.path;
|
|
409
511
|
}
|
|
410
|
-
function
|
|
512
|
+
async function readJsonMaybe2(path) {
|
|
411
513
|
try {
|
|
412
|
-
const
|
|
413
|
-
|
|
514
|
+
const raw = await readFile4(path, "utf-8");
|
|
515
|
+
return JSON.parse(raw);
|
|
414
516
|
} catch {
|
|
415
517
|
return null;
|
|
416
518
|
}
|
|
417
|
-
return null;
|
|
418
519
|
}
|
|
419
520
|
function buildSpec() {
|
|
420
|
-
return `
|
|
421
|
-
import { test, expect } from "@playwright/test";
|
|
521
|
+
return `import { test, expect } from "@playwright/test";
|
|
422
522
|
import AxeBuilder from "@axe-core/playwright";
|
|
523
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
524
|
+
import { dirname } from "node:path";
|
|
525
|
+
|
|
423
526
|
const pages = ${JSON.stringify(a11yRoutes)};
|
|
424
|
-
|
|
425
|
-
|
|
527
|
+
const OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;
|
|
528
|
+
|
|
529
|
+
// Playwright's default per-test timeout is 30s. We loop through every
|
|
530
|
+
// configured route in a single test, so the budget needs to scale.
|
|
531
|
+
test.setTimeout(5 * 60_000);
|
|
532
|
+
|
|
533
|
+
test("a11y across configured routes", async ({ page }) => {
|
|
534
|
+
const violations = [];
|
|
535
|
+
for (const { path, name } of pages) {
|
|
426
536
|
await page.goto(path);
|
|
427
537
|
const results = await new AxeBuilder({ page })
|
|
428
538
|
.withTags(["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"])
|
|
429
539
|
.analyze();
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
540
|
+
for (const v of results.violations) {
|
|
541
|
+
violations.push({
|
|
542
|
+
id: v.id,
|
|
543
|
+
impact: v.impact ?? "moderate",
|
|
544
|
+
route: name,
|
|
545
|
+
help: v.help,
|
|
546
|
+
helpUrl: v.helpUrl,
|
|
547
|
+
nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const byImpact = {};
|
|
552
|
+
for (const v of violations) {
|
|
553
|
+
byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;
|
|
554
|
+
}
|
|
555
|
+
if (OUTPUT) {
|
|
556
|
+
await mkdir(dirname(OUTPUT), { recursive: true });
|
|
557
|
+
await writeFile(
|
|
558
|
+
OUTPUT,
|
|
559
|
+
JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
expect(violations).toEqual([]);
|
|
563
|
+
});
|
|
433
564
|
`;
|
|
434
565
|
}
|
|
435
566
|
async function a11yAudit(ctx) {
|
|
436
567
|
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
437
568
|
const site = ctx.site;
|
|
438
569
|
const label = siteLabel5(site);
|
|
439
|
-
const
|
|
440
|
-
const specPath = join4(
|
|
570
|
+
const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
|
|
571
|
+
const specPath = join4(specDir, "a11y.spec.ts");
|
|
441
572
|
await writeFile2(specPath, buildSpec(), "utf-8");
|
|
573
|
+
const resultsPath = join4(site.path, RESULTS_REL);
|
|
574
|
+
await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
|
|
442
575
|
let raw;
|
|
443
576
|
try {
|
|
444
|
-
raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=
|
|
445
|
-
cwd: site.path
|
|
577
|
+
raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
|
|
578
|
+
cwd: site.path,
|
|
579
|
+
env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath }
|
|
446
580
|
});
|
|
447
581
|
} catch (err) {
|
|
448
|
-
await rm2(
|
|
582
|
+
await rm2(specDir, { recursive: true, force: true });
|
|
449
583
|
const e = err;
|
|
450
584
|
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
451
585
|
return {
|
|
@@ -457,22 +591,26 @@ async function a11yAudit(ctx) {
|
|
|
457
591
|
}
|
|
458
592
|
throw err;
|
|
459
593
|
}
|
|
460
|
-
await rm2(
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
594
|
+
await rm2(specDir, { recursive: true, force: true });
|
|
595
|
+
const artifact = await readJsonMaybe2(resultsPath);
|
|
596
|
+
if (!artifact) {
|
|
597
|
+
return {
|
|
598
|
+
audit: "a11y",
|
|
599
|
+
site: label,
|
|
600
|
+
status: "fail",
|
|
601
|
+
summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
|
|
605
|
+
const hasAny = artifact.totalViolations > 0;
|
|
468
606
|
const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
|
|
469
|
-
const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${
|
|
607
|
+
const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${artifact.totalViolations} violations`;
|
|
470
608
|
return {
|
|
471
609
|
audit: "a11y",
|
|
472
610
|
site: label,
|
|
473
611
|
status,
|
|
474
612
|
summary,
|
|
475
|
-
details:
|
|
613
|
+
details: artifact
|
|
476
614
|
};
|
|
477
615
|
}
|
|
478
616
|
|
|
@@ -522,7 +660,8 @@ function localPath(path, opts = {}) {
|
|
|
522
660
|
}
|
|
523
661
|
|
|
524
662
|
// src/inventory/json.ts
|
|
525
|
-
import { readFile as
|
|
663
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
664
|
+
import { isAbsolute } from "path";
|
|
526
665
|
function validate(raw) {
|
|
527
666
|
if (!Array.isArray(raw)) {
|
|
528
667
|
throw new Error("inventory JSON must be an array of sites");
|
|
@@ -535,6 +674,11 @@ function validate(raw) {
|
|
|
535
674
|
if (typeof e.path !== "string" || e.path.length === 0) {
|
|
536
675
|
throw new Error(`inventory entry ${i} is missing required field: path`);
|
|
537
676
|
}
|
|
677
|
+
if (!isAbsolute(e.path)) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`inventory entry ${i}: path must be absolute (got "${e.path}"). Relative paths are rejected so cwd at invocation can't change which site is targeted.`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
538
682
|
const site = { path: e.path };
|
|
539
683
|
if (typeof e.name === "string") site.name = e.name;
|
|
540
684
|
if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
|
|
@@ -546,7 +690,7 @@ function validate(raw) {
|
|
|
546
690
|
}
|
|
547
691
|
function fromJsonFile(path) {
|
|
548
692
|
return async () => {
|
|
549
|
-
const raw = JSON.parse(await
|
|
693
|
+
const raw = JSON.parse(await readFile5(path, "utf-8"));
|
|
550
694
|
return validate(raw);
|
|
551
695
|
};
|
|
552
696
|
}
|
|
@@ -585,11 +729,22 @@ async function resolveSites(input) {
|
|
|
585
729
|
|
|
586
730
|
// src/cli/fleet/clone-if-needed.ts
|
|
587
731
|
import { stat, readdir, mkdir } from "fs/promises";
|
|
588
|
-
import { join as join5 } from "path";
|
|
732
|
+
import { isAbsolute as isAbsolute2, join as join5 } from "path";
|
|
589
733
|
function deriveNameFromRepoUrl(repoUrl) {
|
|
590
734
|
const slash = repoUrl.split("/").pop() ?? repoUrl;
|
|
591
735
|
return slash.replace(/\.git$/, "");
|
|
592
736
|
}
|
|
737
|
+
function assertSafeName(name) {
|
|
738
|
+
if (isAbsolute2(name)) {
|
|
739
|
+
throw new Error(`unsafe site name (absolute path not allowed): ${name}`);
|
|
740
|
+
}
|
|
741
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
742
|
+
throw new Error(`unsafe site name (path separator not allowed): ${name}`);
|
|
743
|
+
}
|
|
744
|
+
if (name.split(/[\\/]/).some((seg) => seg === "..")) {
|
|
745
|
+
throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
593
748
|
async function isNonEmptyDir(path) {
|
|
594
749
|
try {
|
|
595
750
|
const s = await stat(path);
|
|
@@ -606,6 +761,7 @@ async function cloneIfNeeded(site, opts) {
|
|
|
606
761
|
throw new Error(`site path does not exist (${site.path}) and no repoUrl is set \u2014 cannot clone`);
|
|
607
762
|
}
|
|
608
763
|
const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
|
|
764
|
+
assertSafeName(name);
|
|
609
765
|
const target = join5(opts.workdir, name);
|
|
610
766
|
await mkdir(opts.workdir, { recursive: true });
|
|
611
767
|
if (await isNonEmptyDir(target)) {
|