shippingszn 0.8.2 → 0.8.3

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/index.js CHANGED
@@ -1,104 +1,2114 @@
1
1
  #!/usr/bin/env node
2
- import * as path from "node:path";
3
- import * as process from "node:process";
2
+
3
+ // src/index.ts
4
+ import * as path11 from "node:path";
5
+ import * as process2 from "node:process";
4
6
  import { createRequire } from "node:module";
5
- import { ALL_CHECKS } from "./checks.js";
6
- import { listFiles, getTrackedFiles } from "./scan.js";
7
- import { CHECKLIST_ITEMS, permalinkFor } from "./items.js";
8
- import { publishScan } from "./publish.js";
9
- import { shouldUploadProof, uploadProof, } from "./proof.js";
10
- import { buildRemediationPrompt, } from "./remediation.js";
11
- import { assessLaunchReadiness, normalizeLaunchFinding, } from "@workspace/launch-readiness";
12
- const UNTRACKED_DOWNGRADE = {
13
- critical: "lower",
14
- high: "lower",
15
- medium: "lower",
16
- lower: "lower",
7
+
8
+ // src/checks/secrets.ts
9
+ import * as path3 from "node:path";
10
+
11
+ // src/scan.ts
12
+ import { promises as fs } from "node:fs";
13
+ import * as path from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+ function getTrackedFiles(rootDir) {
16
+ const result = spawnSync("git", ["-C", rootDir, "ls-files", "-z"], {
17
+ encoding: "utf8",
18
+ maxBuffer: 64 * 1024 * 1024
19
+ });
20
+ if (result.status !== 0 || !result.stdout) return null;
21
+ const out = /* @__PURE__ */ new Set();
22
+ for (const rel of result.stdout.split("\0")) {
23
+ if (rel) out.add(rel);
24
+ }
25
+ return out;
26
+ }
27
+ function getNotIgnoredFiles(rootDir) {
28
+ const result = spawnSync(
29
+ "git",
30
+ ["-C", rootDir, "ls-files", "-co", "--exclude-standard", "-z"],
31
+ { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
32
+ );
33
+ if (result.status !== 0 || !result.stdout) return null;
34
+ const out = /* @__PURE__ */ new Set();
35
+ for (const rel of result.stdout.split("\0")) {
36
+ if (rel) out.add(rel);
37
+ }
38
+ return out;
39
+ }
40
+ var DEFAULT_IGNORES = /* @__PURE__ */ new Set([
41
+ "node_modules",
42
+ ".git",
43
+ "dist",
44
+ "build",
45
+ ".next",
46
+ ".nuxt",
47
+ ".turbo",
48
+ ".cache",
49
+ ".vercel",
50
+ ".netlify",
51
+ "out",
52
+ "coverage",
53
+ ".pnpm-store",
54
+ ".yarn",
55
+ ".expo",
56
+ ".local",
57
+ "attached_assets",
58
+ "vendor"
59
+ ]);
60
+ var TEXT_EXT = /* @__PURE__ */ new Set([
61
+ ".ts",
62
+ ".tsx",
63
+ ".js",
64
+ ".jsx",
65
+ ".mjs",
66
+ ".cjs",
67
+ ".json",
68
+ ".md",
69
+ ".mdx",
70
+ ".html",
71
+ ".htm",
72
+ ".css",
73
+ ".scss",
74
+ ".vue",
75
+ ".svelte",
76
+ ".astro",
77
+ ".py",
78
+ ".rb",
79
+ ".go",
80
+ ".rs",
81
+ ".java",
82
+ ".kt",
83
+ ".swift",
84
+ ".php",
85
+ ".cs",
86
+ ".env",
87
+ ".example",
88
+ ".sample",
89
+ ".local",
90
+ ".yaml",
91
+ ".yml",
92
+ ".toml",
93
+ ".ini",
94
+ ".conf",
95
+ ".sh"
96
+ ]);
97
+ var MAX_FILE_BYTES = 512 * 1024;
98
+ var MAX_DEPTH = 24;
99
+ var MAX_FILES = 5e4;
100
+ async function listFiles(rootDir) {
101
+ const out = [];
102
+ const visited = /* @__PURE__ */ new Set();
103
+ const rootResolved = path.resolve(rootDir);
104
+ const allowed = getNotIgnoredFiles(rootDir);
105
+ async function walk(dir, depth) {
106
+ if (depth > MAX_DEPTH) return;
107
+ if (out.length >= MAX_FILES) return;
108
+ let entries;
109
+ try {
110
+ entries = await fs.readdir(dir, { withFileTypes: true });
111
+ } catch {
112
+ return;
113
+ }
114
+ for (const entry of entries) {
115
+ if (out.length >= MAX_FILES) return;
116
+ if (entry.name.startsWith(".") && DEFAULT_IGNORES.has(entry.name))
117
+ continue;
118
+ if (DEFAULT_IGNORES.has(entry.name)) continue;
119
+ const abs = path.join(dir, entry.name);
120
+ if (entry.isSymbolicLink()) continue;
121
+ if (entry.isDirectory()) {
122
+ if (entry.name === "fixtures") {
123
+ const parentBase = path.basename(dir);
124
+ if (parentBase === "test" || parentBase === "tests" || parentBase === "__tests__") {
125
+ continue;
126
+ }
127
+ }
128
+ let real;
129
+ try {
130
+ real = await fs.realpath(abs);
131
+ } catch {
132
+ continue;
133
+ }
134
+ const relFromRoot = path.relative(rootResolved, real);
135
+ if (relFromRoot.startsWith("..") || path.isAbsolute(relFromRoot)) {
136
+ continue;
137
+ }
138
+ if (visited.has(real)) continue;
139
+ visited.add(real);
140
+ await walk(abs, depth + 1);
141
+ } else if (entry.isFile()) {
142
+ const rel = path.relative(rootDir, abs);
143
+ if (allowed) {
144
+ const relPosixPath = rel.split(path.sep).join("/");
145
+ if (!allowed.has(relPosixPath)) continue;
146
+ }
147
+ let size = 0;
148
+ try {
149
+ const st = await fs.stat(abs);
150
+ size = st.size;
151
+ } catch {
152
+ continue;
153
+ }
154
+ out.push({ absPath: abs, relPath: rel, size });
155
+ }
156
+ }
157
+ }
158
+ await walk(rootDir, 0);
159
+ return out;
160
+ }
161
+ function isTextFile(file) {
162
+ const base = path.basename(file.relPath).toLowerCase();
163
+ if (base.startsWith(".env")) return true;
164
+ if (base === "dockerfile" || base === "makefile" || base === "procfile")
165
+ return true;
166
+ const ext = path.extname(file.relPath).toLowerCase();
167
+ if (TEXT_EXT.has(ext)) return true;
168
+ return false;
169
+ }
170
+ async function readFileSafe(file) {
171
+ if (file.size > MAX_FILE_BYTES) return null;
172
+ try {
173
+ return await fs.readFile(file.absPath, "utf8");
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ async function fileExists(p) {
179
+ try {
180
+ await fs.stat(p);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ // src/checks/helpers.ts
188
+ import * as path2 from "node:path";
189
+ function relPosix(p) {
190
+ return p.split(path2.sep).join("/");
191
+ }
192
+ function findLine(content, idx) {
193
+ let line = 1;
194
+ for (let i = 0; i < idx; i++) if (content.charCodeAt(i) === 10) line++;
195
+ return line;
196
+ }
197
+ var IGNORE_MARKER = "shippingszn:ignore";
198
+ var IGNORE_NEXT_LINE_MARKER = "shippingszn:ignore-next-line";
199
+ function getLine(content, charIndex) {
200
+ const lineStart = content.lastIndexOf("\n", charIndex - 1) + 1;
201
+ const lineEnd = content.indexOf("\n", charIndex);
202
+ return content.slice(lineStart, lineEnd === -1 ? void 0 : lineEnd);
203
+ }
204
+ function getPreviousLine(content, charIndex) {
205
+ const lineStart = content.lastIndexOf("\n", charIndex - 1) + 1;
206
+ if (lineStart === 0) return null;
207
+ const prevEnd = lineStart - 1;
208
+ const prevStart = content.lastIndexOf("\n", prevEnd - 1) + 1;
209
+ return content.slice(prevStart, prevEnd);
210
+ }
211
+ function lineContainsIgnoreMarker(content, charIndex) {
212
+ if (getLine(content, charIndex).includes(IGNORE_MARKER)) return true;
213
+ const prev = getPreviousLine(content, charIndex);
214
+ return prev !== null && prev.includes(IGNORE_NEXT_LINE_MARKER);
215
+ }
216
+ var PUBLIC_DIR_CANDIDATES = [
217
+ "public",
218
+ "static",
219
+ "www",
220
+ "dist",
221
+ "build",
222
+ "out"
223
+ ];
224
+ async function findPublicDirs(ctx) {
225
+ const dirs = [];
226
+ for (const cand of PUBLIC_DIR_CANDIDATES) {
227
+ const p = path2.join(ctx.rootDir, cand);
228
+ if (await fileExists(p)) dirs.push(cand);
229
+ }
230
+ const seen = new Set(dirs);
231
+ for (const f of ctx.files) {
232
+ const parts = f.relPath.split("/");
233
+ for (let i = 0; i < parts.length - 1; i++) {
234
+ if (parts[i] === "public" || parts[i] === "static") {
235
+ const dir = parts.slice(0, i + 1).join("/");
236
+ if (!seen.has(dir)) {
237
+ seen.add(dir);
238
+ dirs.push(dir);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return dirs;
244
+ }
245
+ var PATTERN_DEFINITION_FILES = /* @__PURE__ */ new Set([
246
+ "tools/cli/src/checks/dangerous.ts",
247
+ "tools/cli/src/checks/quality.ts",
248
+ "tools/cli/src/checks/language.ts",
249
+ "tools/cli/README.md",
250
+ // Test fixture for the redaction module: contains intentional fake
251
+ // secret patterns whose whole purpose is to verify the redactor scrubs
252
+ // them. Functionally identical to tools/cli/test/fixtures/, just lives
253
+ // in a different package.
254
+ "artifacts/api-server/src/lib/__tests__/redaction.test.ts"
255
+ ]);
256
+ var PATTERN_DEFINITION_PREFIXES = [
257
+ "tools/cli/test/fixtures/",
258
+ "artifacts/checklist/src/data/checklist/"
259
+ ];
260
+ function isScanExempt(relPath) {
261
+ const p = relPosix(relPath);
262
+ if (PATTERN_DEFINITION_FILES.has(p)) return true;
263
+ for (const prefix of PATTERN_DEFINITION_PREFIXES) {
264
+ if (p.startsWith(prefix) || p.includes("/" + prefix)) return true;
265
+ }
266
+ return false;
267
+ }
268
+ var SERVE_INDICATORS = [
269
+ "emitFile",
270
+ "setHeader",
271
+ "res.end",
272
+ "res.send",
273
+ "configureServer",
274
+ "configurePreviewServer",
275
+ "app.get",
276
+ "app.use",
277
+ "router.get",
278
+ "router.use"
279
+ ];
280
+ var DYNAMIC_ASSET_SOURCE_EXTS = /* @__PURE__ */ new Set([
281
+ ".ts",
282
+ ".tsx",
283
+ ".js",
284
+ ".jsx",
285
+ ".mjs",
286
+ ".cjs"
287
+ ]);
288
+ async function isAssetEmittedDynamically(ctx, assetName) {
289
+ const literalPatterns = [`"${assetName}"`, `'${assetName}'`, `/${assetName}`];
290
+ for (const file of ctx.files) {
291
+ if (!isTextFile(file)) continue;
292
+ const ext = path2.extname(file.relPath).toLowerCase();
293
+ if (!DYNAMIC_ASSET_SOURCE_EXTS.has(ext)) continue;
294
+ const content = await readFileSafe(file);
295
+ if (!content) continue;
296
+ if (!literalPatterns.some((p) => content.includes(p))) continue;
297
+ if (!SERVE_INDICATORS.some((s) => content.includes(s))) continue;
298
+ return true;
299
+ }
300
+ return false;
301
+ }
302
+
303
+ // src/checks/secrets.ts
304
+ var SECRET_PATTERNS = [
305
+ {
306
+ id: "openai-key",
307
+ name: "OpenAI API key",
308
+ regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/,
309
+ severity: "critical"
310
+ },
311
+ {
312
+ id: "anthropic-key",
313
+ name: "Anthropic API key",
314
+ regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
315
+ severity: "critical"
316
+ },
317
+ {
318
+ id: "stripe-live-secret",
319
+ name: "Stripe live secret key",
320
+ regex: /\bsk_live_[A-Za-z0-9]{16,}\b/,
321
+ severity: "critical"
322
+ },
323
+ {
324
+ id: "stripe-live-publishable",
325
+ name: "Stripe live publishable key",
326
+ regex: /\bpk_live_[A-Za-z0-9]{16,}\b/,
327
+ severity: "high"
328
+ },
329
+ {
330
+ id: "aws-access-key",
331
+ name: "AWS access key id",
332
+ regex: /\bAKIA[0-9A-Z]{16}\b/,
333
+ severity: "critical"
334
+ },
335
+ {
336
+ id: "google-api-key",
337
+ name: "Google API key",
338
+ regex: /\bAIza[0-9A-Za-z_-]{35}\b/,
339
+ severity: "critical"
340
+ },
341
+ {
342
+ id: "github-token",
343
+ name: "GitHub personal access token",
344
+ regex: /\bghp_[A-Za-z0-9]{30,}\b/,
345
+ severity: "critical"
346
+ },
347
+ {
348
+ id: "slack-token",
349
+ name: "Slack token",
350
+ regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/,
351
+ severity: "high"
352
+ },
353
+ {
354
+ id: "private-key-block",
355
+ name: "Private key block",
356
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
357
+ severity: "critical"
358
+ },
359
+ {
360
+ id: "notion-token",
361
+ name: "Notion integration token",
362
+ regex: /\bsecret_[A-Za-z0-9]{43}\b/,
363
+ severity: "critical"
364
+ },
365
+ {
366
+ id: "vercel-token",
367
+ name: "Vercel token",
368
+ regex: /\b(?:VERCEL_TOKEN|vercel_token|vercelToken)\b\s*[:=]\s*['"]?[A-Za-z0-9]{24}['"]?/,
369
+ severity: "critical"
370
+ },
371
+ {
372
+ id: "sendgrid-api-key",
373
+ name: "SendGrid API key",
374
+ regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,
375
+ severity: "critical"
376
+ },
377
+ {
378
+ id: "twilio-account-sid",
379
+ name: "Twilio Account SID",
380
+ regex: /\bAC[a-f0-9]{32}\b/,
381
+ severity: "high"
382
+ }
383
+ ];
384
+ var JWT_REGEX = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
385
+ function tryDecodeJwtPayload(token) {
386
+ const parts = token.split(".");
387
+ if (parts.length !== 3) return null;
388
+ let b = parts[1].replace(/-/g, "+").replace(/_/g, "/");
389
+ while (b.length % 4 !== 0) b += "=";
390
+ try {
391
+ return Buffer.from(b, "base64").toString("utf8");
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ var SECRET_SCAN_SKIP_NAMES = /* @__PURE__ */ new Set([
397
+ "package-lock.json",
398
+ "pnpm-lock.yaml",
399
+ "yarn.lock",
400
+ "bun.lockb"
401
+ ]);
402
+ async function checkHardcodedSecrets(ctx) {
403
+ const findings = [];
404
+ for (const file of ctx.files) {
405
+ const base = path3.basename(file.relPath);
406
+ if (SECRET_SCAN_SKIP_NAMES.has(base)) continue;
407
+ if (!isTextFile(file)) continue;
408
+ if (isScanExempt(file.relPath)) continue;
409
+ const content = await readFileSafe(file);
410
+ if (!content) continue;
411
+ const isEnvExample = /\.example$|\.sample$|\.template$/i.test(base) || base === ".env.example";
412
+ if (isEnvExample) continue;
413
+ const matchedRanges = [];
414
+ for (const pat of SECRET_PATTERNS) {
415
+ const m = pat.regex.exec(content);
416
+ if (!m) continue;
417
+ const start = m.index;
418
+ const end = m.index + m[0].length;
419
+ if (matchedRanges.some(([s, e]) => start < e && end > s)) continue;
420
+ matchedRanges.push([start, end]);
421
+ const line = findLine(content, start);
422
+ findings.push({
423
+ checkId: `secret-${pat.id}`,
424
+ itemId: "secrets",
425
+ severity: pat.severity,
426
+ message: `Possible ${pat.name} hardcoded in source.`,
427
+ file: relPosix(file.relPath),
428
+ line,
429
+ evidence: `${m[0].slice(0, 6)}\u2026${m[0].slice(-4)} (${m[0].length} chars)`
430
+ });
431
+ }
432
+ JWT_REGEX.lastIndex = 0;
433
+ let jm;
434
+ while ((jm = JWT_REGEX.exec(content)) !== null) {
435
+ const start = jm.index;
436
+ const end = jm.index + jm[0].length;
437
+ if (matchedRanges.some(([s, e]) => start < e && end > s)) continue;
438
+ matchedRanges.push([start, end]);
439
+ const payload = tryDecodeJwtPayload(jm[0]);
440
+ const isServiceRole = !!payload && /"role"\s*:\s*"service_role"/.test(payload);
441
+ const line = findLine(content, start);
442
+ if (isServiceRole) {
443
+ findings.push({
444
+ checkId: "secret-supabase-service-role-jwt",
445
+ itemId: "secrets",
446
+ severity: "critical",
447
+ message: "Possible Supabase service-role JWT hardcoded in source.",
448
+ file: relPosix(file.relPath),
449
+ line,
450
+ evidence: `${jm[0].slice(0, 6)}\u2026${jm[0].slice(-4)} (${jm[0].length} chars)`
451
+ });
452
+ } else {
453
+ findings.push({
454
+ checkId: "secret-jwt",
455
+ itemId: "secrets",
456
+ severity: "high",
457
+ message: "Possible JWT hardcoded in source.",
458
+ file: relPosix(file.relPath),
459
+ line,
460
+ evidence: `${jm[0].slice(0, 6)}\u2026${jm[0].slice(-4)} (${jm[0].length} chars)`
461
+ });
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ return findings;
467
+ }
468
+ var CONFIG_FILE_BASENAMES = /* @__PURE__ */ new Set([
469
+ ".env",
470
+ ".env.local",
471
+ ".env.production"
472
+ ]);
473
+ var CONFIG_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".toml"]);
474
+ function looksLikeConfigFile(relPath) {
475
+ const base = path3.basename(relPath);
476
+ if (CONFIG_FILE_BASENAMES.has(base)) return true;
477
+ const ext = path3.extname(base).toLowerCase();
478
+ if (CONFIG_FILE_EXTENSIONS.has(ext)) return true;
479
+ return false;
480
+ }
481
+ var CONFIG_ASSIGNMENT_REGEX = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:"([^"\n]+)"|'([^'\n]+)'|([^\s#"'][^\s#]*))/gm;
482
+ var VITE_SECRET_KEY_REGEX = /^VITE_[A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL|PRIVATE)$/;
483
+ var HEX_SECRET_REGEX = /^[A-Fa-f0-9]{32,}$/;
484
+ var BASE64_SECRET_REGEX = /^[A-Za-z0-9+/_-]{40,}={0,2}$/;
485
+ var TEMPLATED_VALUE_REGEX = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
486
+ var CONFIG_KEY_ALLOWLIST = /* @__PURE__ */ new Set([
487
+ "DATABASE_URL"
488
+ // contains URL/host fragments, handled by other rules
489
+ ]);
490
+ async function checkConfigSecretLeaks(ctx) {
491
+ const findings = [];
492
+ for (const file of ctx.files) {
493
+ if (!looksLikeConfigFile(file.relPath)) continue;
494
+ if (!isTextFile(file)) continue;
495
+ const base = path3.basename(file.relPath);
496
+ if (/\.example$|\.sample$|\.template$/i.test(base)) continue;
497
+ const content = await readFileSafe(file);
498
+ if (!content) continue;
499
+ CONFIG_ASSIGNMENT_REGEX.lastIndex = 0;
500
+ let m;
501
+ while ((m = CONFIG_ASSIGNMENT_REGEX.exec(content)) !== null) {
502
+ const key = m[1];
503
+ const value = (m[2] ?? m[3] ?? m[4] ?? "").trim();
504
+ if (!value) continue;
505
+ if (TEMPLATED_VALUE_REGEX.test(value)) continue;
506
+ const line = findLine(content, m.index);
507
+ if (VITE_SECRET_KEY_REGEX.test(key)) {
508
+ findings.push({
509
+ checkId: "config-vite-prefixed-secret",
510
+ itemId: "secrets",
511
+ severity: "high",
512
+ message: `${key} is set in ${relPosix(file.relPath)}. Variables prefixed with VITE_ are baked into the browser bundle by Vite at build time and are readable by every visitor \u2014 they are not secrets. Rename the variable (drop VITE_) and access it server-side only, or move the signing/auth flow behind a backend endpoint.`,
513
+ file: relPosix(file.relPath),
514
+ line,
515
+ evidence: `${key}=${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`
516
+ });
517
+ continue;
518
+ }
519
+ if (CONFIG_KEY_ALLOWLIST.has(key)) continue;
520
+ const isHex = HEX_SECRET_REGEX.test(value);
521
+ const isBase64 = BASE64_SECRET_REGEX.test(value);
522
+ if (!isHex && !isBase64) continue;
523
+ findings.push({
524
+ checkId: "config-hardcoded-credential",
525
+ itemId: "secrets",
526
+ severity: "critical",
527
+ message: `${key} in ${relPosix(file.relPath)} looks like a hardcoded credential (${value.length}-char ${isHex ? "hex" : "base64-shaped"} value). Move it to your platform's secret store (Railway / Vercel / Fly env vars, or a dedicated vault) and reference it from there. Rotate the leaked value at the source.`,
528
+ file: relPosix(file.relPath),
529
+ line,
530
+ evidence: `${key}=${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`
531
+ });
532
+ }
533
+ }
534
+ return findings;
535
+ }
536
+
537
+ // src/checks/env.ts
538
+ import * as path4 from "node:path";
539
+ async function checkEnvCommitted(ctx) {
540
+ const findings = [];
541
+ const gitignorePath = path4.join(ctx.rootDir, ".gitignore");
542
+ let gitignore = "";
543
+ if (await fileExists(gitignorePath)) {
544
+ gitignore = await readFileSafe({ absPath: gitignorePath, relPath: ".gitignore", size: 0 }) ?? "";
545
+ }
546
+ const ignoresEnv = /^\s*\.env(\s|$)/m.test(gitignore) || /^\s*\*\.env(\s|$)/m.test(gitignore);
547
+ for (const file of ctx.files) {
548
+ const base = path4.basename(file.relPath);
549
+ if (base !== ".env" && base !== ".env.local" && base !== ".env.production") continue;
550
+ if (!ignoresEnv) {
551
+ findings.push({
552
+ checkId: "env-not-ignored",
553
+ itemId: "secrets",
554
+ severity: "high",
555
+ message: `${base} found and your .gitignore does not appear to ignore .env files.`,
556
+ file: relPosix(file.relPath)
557
+ });
558
+ }
559
+ }
560
+ return findings;
561
+ }
562
+ async function checkEnvExample(ctx) {
563
+ const hasEnv = ctx.files.some((f) => path4.basename(f.relPath) === ".env");
564
+ const hasExample = ctx.files.some((f) => {
565
+ const b = path4.basename(f.relPath);
566
+ return b === ".env.example" || b === ".env.sample" || b === ".env.template";
567
+ });
568
+ if (hasEnv && !hasExample) {
569
+ return [
570
+ {
571
+ checkId: "missing-env-example",
572
+ itemId: "secrets",
573
+ severity: "medium",
574
+ message: "Found a .env file but no .env.example. Add a sanitized .env.example so collaborators know which variables are required."
575
+ }
576
+ ];
577
+ }
578
+ return [];
579
+ }
580
+ async function checkGitignore(ctx) {
581
+ const gitignorePath = path4.join(ctx.rootDir, ".gitignore");
582
+ if (!await fileExists(gitignorePath)) {
583
+ return [
584
+ {
585
+ checkId: "missing-gitignore",
586
+ itemId: "github",
587
+ severity: "high",
588
+ message: "No .gitignore at the project root. Add one tuned to your stack so you don't accidentally commit secrets, local DBs, or build artifacts."
589
+ }
590
+ ];
591
+ }
592
+ return [];
593
+ }
594
+
595
+ // src/checks/public-assets.ts
596
+ function robotsDisallowsAll(content) {
597
+ const lines = content.split(/\r?\n/);
598
+ let inWildcardBlock = false;
599
+ let sawWildcardBlock = false;
600
+ for (const raw of lines) {
601
+ const line = raw.replace(/#.*$/, "").trim();
602
+ if (line === "") {
603
+ inWildcardBlock = false;
604
+ continue;
605
+ }
606
+ const match = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
607
+ if (!match) continue;
608
+ const directive = match[1].toLowerCase();
609
+ const value = match[2].trim();
610
+ if (directive === "user-agent") {
611
+ inWildcardBlock = value === "*";
612
+ if (inWildcardBlock) sawWildcardBlock = true;
613
+ continue;
614
+ }
615
+ if (inWildcardBlock && directive === "disallow" && value === "/") {
616
+ return true;
617
+ }
618
+ }
619
+ void sawWildcardBlock;
620
+ return false;
621
+ }
622
+ async function hasDisallowAllRobots(ctx) {
623
+ const robotsFiles = ctx.files.filter(
624
+ (f) => /(^|\/)robots\.txt$/i.test(f.relPath)
625
+ );
626
+ for (const file of robotsFiles) {
627
+ const content = await readFileSafe(file);
628
+ if (!content) continue;
629
+ if (robotsDisallowsAll(content)) return true;
630
+ }
631
+ return false;
632
+ }
633
+ async function checkRobotsTxt(ctx) {
634
+ const has = ctx.files.some((f) => /(^|\/)robots\.txt$/i.test(f.relPath));
635
+ if (has) return [];
636
+ if (await isAssetEmittedDynamically(ctx, "robots.txt")) return [];
637
+ const dirs = await findPublicDirs(ctx);
638
+ if (dirs.length === 0) return [];
639
+ return [
640
+ {
641
+ checkId: "missing-robots-txt",
642
+ itemId: "seo",
643
+ severity: "medium",
644
+ message: `No robots.txt found in any public directory (looked in: ${dirs.join(", ")}). Add one so search engines know what to crawl.`
645
+ }
646
+ ];
647
+ }
648
+ async function checkSitemapXml(ctx) {
649
+ const has = ctx.files.some((f) => /(^|\/)sitemap\.xml$/i.test(f.relPath));
650
+ if (has) return [];
651
+ if (await isAssetEmittedDynamically(ctx, "sitemap.xml")) return [];
652
+ const dirs = await findPublicDirs(ctx);
653
+ if (dirs.length === 0) return [];
654
+ if (await hasDisallowAllRobots(ctx)) return [];
655
+ return [
656
+ {
657
+ checkId: "missing-sitemap-xml",
658
+ itemId: "seo",
659
+ severity: "medium",
660
+ message: `No sitemap.xml found in any public directory (looked in: ${dirs.join(", ")}). Add one to help search engines index your pages.`
661
+ }
662
+ ];
663
+ }
664
+ async function checkFavicon(ctx) {
665
+ const dirs = await findPublicDirs(ctx);
666
+ if (dirs.length === 0) return [];
667
+ const faviconRegex = /(^|\/)(favicon\.(ico|png|svg)|apple-touch-icon\.png|icon\.svg)$/i;
668
+ const has = ctx.files.some((f) => faviconRegex.test(f.relPath));
669
+ if (has) return [];
670
+ return [
671
+ {
672
+ checkId: "missing-favicon",
673
+ itemId: "launch-polish",
674
+ severity: "lower",
675
+ message: "No custom favicon found in your public directory. The default browser favicon (or the framework starter one) tells visitors this is an unfinished AI-built project."
676
+ }
677
+ ];
678
+ }
679
+ async function checkLlmsTxt(ctx) {
680
+ const has = ctx.files.some((f) => /(^|\/)llms\.txt$/i.test(f.relPath));
681
+ if (has) return [];
682
+ if (await isAssetEmittedDynamically(ctx, "llms.txt")) return [];
683
+ const dirs = await findPublicDirs(ctx);
684
+ if (dirs.length === 0) return [];
685
+ return [
686
+ {
687
+ checkId: "missing-llms-txt",
688
+ itemId: "aeo",
689
+ severity: "lower",
690
+ message: `No llms.txt found in any public directory (looked in: ${dirs.join(", ")}). Add one so AI crawlers (ChatGPT, Perplexity, Claude) can understand your site.`
691
+ }
692
+ ];
693
+ }
694
+ async function checkPwaManifest(ctx) {
695
+ const dirs = await findPublicDirs(ctx);
696
+ if (dirs.length === 0) return [];
697
+ const manifestRegex = /(^|\/)(manifest\.(json|webmanifest)|site\.webmanifest)$/i;
698
+ const has = ctx.files.some((f) => manifestRegex.test(f.relPath));
699
+ if (has) return [];
700
+ return [
701
+ {
702
+ checkId: "missing-pwa-manifest",
703
+ itemId: "installable-app",
704
+ severity: "lower",
705
+ message: `No PWA manifest found in any public directory (looked in: ${dirs.join(", ")}). Add manifest.json (or site.webmanifest) so users can install your app to their phone's home screen.`
706
+ }
707
+ ];
708
+ }
709
+
710
+ // src/checks/headers.ts
711
+ import * as path5 from "node:path";
712
+ var SECURITY_HEADER_NAMES = [
713
+ "Strict-Transport-Security",
714
+ "Content-Security-Policy",
715
+ "X-Content-Type-Options",
716
+ "X-Frame-Options",
717
+ "Referrer-Policy"
718
+ ];
719
+ var CANDIDATE_EXTENSIONS = /* @__PURE__ */ new Set([
720
+ ".ts",
721
+ ".tsx",
722
+ ".js",
723
+ ".jsx",
724
+ ".mjs",
725
+ ".cjs",
726
+ ".mts",
727
+ ".cts",
728
+ ".json",
729
+ ".toml"
730
+ ]);
731
+ var CANDIDATE_BASENAMES = /* @__PURE__ */ new Set([
732
+ "vite.config.ts",
733
+ "vite.config.js",
734
+ "vite.config.mjs",
735
+ "vite.config.cjs",
736
+ "next.config.js",
737
+ "next.config.mjs",
738
+ "next.config.ts",
739
+ "nuxt.config.ts",
740
+ "svelte.config.js",
741
+ "astro.config.mjs",
742
+ "astro.config.ts",
743
+ "vercel.json",
744
+ "netlify.toml"
745
+ ]);
746
+ var CANDIDATE_DIR_SEGMENTS = [
747
+ "server/",
748
+ "api/",
749
+ "backend/",
750
+ "middleware/",
751
+ "middlewares/",
752
+ "lib/server/",
753
+ "src/server/",
754
+ "src/api/",
755
+ "src/middleware/",
756
+ "src/middlewares/",
757
+ "apps/server/",
758
+ "apps/api/",
759
+ "packages/server/",
760
+ "packages/api/",
761
+ "artifacts/server/",
762
+ "artifacts/api-server/"
763
+ ];
764
+ function isCandidateFile(relPath) {
765
+ const lower = relPath.replace(/\\/g, "/").toLowerCase();
766
+ if (lower.includes("/node_modules/") || lower.includes("/dist/") || lower.includes("/build/") || lower.includes("/.next/") || lower.endsWith(".test.ts") || lower.endsWith(".spec.ts") || lower.endsWith(".test.tsx") || lower.endsWith(".spec.tsx") || lower.endsWith(".test.js") || lower.endsWith(".spec.js")) {
767
+ return false;
768
+ }
769
+ const base = path5.basename(lower);
770
+ if (CANDIDATE_BASENAMES.has(base)) return true;
771
+ const ext = path5.extname(lower);
772
+ if (!CANDIDATE_EXTENSIONS.has(ext)) return false;
773
+ if (/\b(?:server|index|app|main|bootstrap|middleware|security)\.(?:t|j|m|c)?sx?$/.test(
774
+ base
775
+ )) {
776
+ return true;
777
+ }
778
+ for (const seg of CANDIDATE_DIR_SEGMENTS) {
779
+ if (lower.includes("/" + seg) || lower.startsWith(seg)) return true;
780
+ }
781
+ return false;
782
+ }
783
+ async function checkSecurityHeaders(ctx) {
784
+ const candidates = ctx.files.filter((f) => isCandidateFile(f.relPath));
785
+ if (candidates.length === 0) return [];
786
+ for (const file of candidates) {
787
+ const content = await readFileSafe(file);
788
+ if (!content) continue;
789
+ const lower = content.toLowerCase();
790
+ for (const h of SECURITY_HEADER_NAMES) {
791
+ if (lower.includes(h.toLowerCase())) return [];
792
+ }
793
+ if (lower.includes("helmet(") || lower.includes('require("helmet")') || lower.includes('from "helmet"') || lower.includes("from 'helmet'")) {
794
+ return [];
795
+ }
796
+ }
797
+ return [
798
+ {
799
+ checkId: "missing-security-headers",
800
+ itemId: "https-headers",
801
+ severity: "high",
802
+ message: "Couldn't find any common security headers (CSP, HSTS, X-Frame-Options, etc.) or helmet() middleware in your server/host configs. Add them so the browser enforces baseline defenses."
803
+ }
804
+ ];
805
+ }
806
+
807
+ // src/checks/dangerous.ts
808
+ import * as path6 from "node:path";
809
+ var DANGEROUS_PATTERNS = [
810
+ {
811
+ id: "dangerously-set-inner-html",
812
+ regex: /dangerouslySetInnerHTML/,
813
+ itemId: "common-attacks",
814
+ severity: "high",
815
+ message: "Use of dangerouslySetInnerHTML \u2014 make sure the content is sanitized or comes from a trusted source."
816
+ },
817
+ {
818
+ id: "eval-call",
819
+ regex: /(^|[^A-Za-z0-9_$])eval\s*\(/,
820
+ itemId: "common-attacks",
821
+ severity: "high",
822
+ message: "Use of eval() \u2014 almost always avoidable and a common path to remote code execution if any input is user-controlled."
823
+ },
824
+ {
825
+ id: "cors-wildcard",
826
+ regex: /Access-Control-Allow-Origin\s*[:=]\s*['"`]\*['"`]/i,
827
+ itemId: "common-attacks",
828
+ severity: "medium",
829
+ message: "Wildcard CORS (Access-Control-Allow-Origin: *). Lock this down to specific origins for any authenticated endpoint."
830
+ },
831
+ {
832
+ id: "shell-exec-call",
833
+ regex: /(^|[^A-Za-z0-9_$.])(exec|execSync)\s*\(/,
834
+ itemId: "common-attacks",
835
+ severity: "high",
836
+ message: "Shell execution via exec/execSync \u2014 if any argument includes user input, this is a command-injection path. Prefer execFile with an argument array, or validate input strictly against an allowlist."
837
+ }
838
+ ];
839
+ async function checkDangerousPatterns(ctx) {
840
+ const findings = [];
841
+ for (const file of ctx.files) {
842
+ if (!isTextFile(file)) continue;
843
+ if (isScanExempt(file.relPath)) continue;
844
+ const ext = path6.extname(file.relPath).toLowerCase();
845
+ if (![
846
+ ".ts",
847
+ ".tsx",
848
+ ".js",
849
+ ".jsx",
850
+ ".mjs",
851
+ ".cjs",
852
+ ".json",
853
+ ".html"
854
+ ].includes(ext))
855
+ continue;
856
+ const content = await readFileSafe(file);
857
+ if (!content) continue;
858
+ for (const pat of DANGEROUS_PATTERNS) {
859
+ const m = pat.regex.exec(content);
860
+ if (!m) continue;
861
+ if (lineContainsIgnoreMarker(content, m.index)) continue;
862
+ const line = findLine(content, m.index);
863
+ findings.push({
864
+ checkId: pat.id,
865
+ itemId: pat.itemId,
866
+ severity: pat.severity,
867
+ message: pat.message,
868
+ file: relPosix(file.relPath),
869
+ line
870
+ });
871
+ }
872
+ }
873
+ return findings;
874
+ }
875
+
876
+ // src/checks/language.ts
877
+ import * as path7 from "node:path";
878
+ var PYTHON_PATTERNS = [
879
+ {
880
+ id: "py-pickle-loads",
881
+ regex: /\bpickle\s*\.\s*loads?\s*\(/,
882
+ itemId: "common-attacks",
883
+ severity: "high",
884
+ message: "Use of pickle.loads / pickle.load \u2014 deserializing pickle data from untrusted sources allows arbitrary code execution. Use json or another safe format."
885
+ },
886
+ {
887
+ id: "py-subprocess-shell-true",
888
+ regex: /\bshell\s*=\s*True\b/,
889
+ itemId: "common-attacks",
890
+ severity: "high",
891
+ message: "subprocess call with shell=True \u2014 if any argument is user-controlled this is a shell injection. Pass an argv list instead."
892
+ },
893
+ {
894
+ id: "py-flask-debug-true",
895
+ regex: /\.run\s*\([^)]*\bdebug\s*=\s*True/,
896
+ itemId: "dev-prod-data",
897
+ severity: "high",
898
+ message: "Flask app.run(debug=True) \u2014 Werkzeug's debugger exposes a remote Python shell. Never enable this in production; gate on an env var."
899
+ },
900
+ {
901
+ id: "py-django-debug-true",
902
+ regex: /^\s*DEBUG\s*=\s*True\b/m,
903
+ itemId: "dev-prod-data",
904
+ severity: "high",
905
+ message: "Django DEBUG = True at module scope \u2014 leaks stack traces, settings, and SQL to anyone who hits an error page in production. Read it from an env var."
906
+ },
907
+ {
908
+ id: "py-hardcoded-secret-key",
909
+ regex: /^\s*SECRET_KEY\s*=\s*['"][^'"\n]{8,}['"]/m,
910
+ itemId: "secrets",
911
+ severity: "critical",
912
+ message: "Hardcoded SECRET_KEY in a Django/Flask settings file. Load it from os.environ / os.getenv instead and keep the real value out of source control."
913
+ }
914
+ ];
915
+ var RUBY_PATTERNS = [
916
+ {
917
+ id: "rb-eval",
918
+ regex: /(^|[^A-Za-z0-9_])eval\s*\(/,
919
+ itemId: "common-attacks",
920
+ severity: "high",
921
+ message: "Use of eval in Ruby \u2014 executes arbitrary code and is almost always avoidable. Replace with safer alternatives like send, public_send, or a hash lookup."
922
+ },
923
+ {
924
+ id: "rb-html-safe",
925
+ regex: /\.html_safe\b/,
926
+ itemId: "common-attacks",
927
+ severity: "medium",
928
+ message: "Call to .html_safe in Ruby/Rails \u2014 bypasses ERB's automatic HTML escaping. Make sure the string isn't user-controlled or you'll have an XSS hole."
929
+ },
930
+ {
931
+ id: "rb-hardcoded-secret-key-base",
932
+ regex: /^\s*secret_key_base\s*:\s*['"]?[A-Za-z0-9]{16,}['"]?/m,
933
+ itemId: "secrets",
934
+ severity: "critical",
935
+ message: "Hardcoded secret_key_base in a Rails config/credentials file. Move it to ENV['SECRET_KEY_BASE'] or Rails' encrypted credentials."
936
+ }
937
+ ];
938
+ var GO_PATTERNS = [
939
+ {
940
+ id: "go-listen-and-serve-no-tls",
941
+ regex: /\bhttp\s*\.\s*ListenAndServe\s*\(/,
942
+ itemId: "https-headers",
943
+ severity: "high",
944
+ message: "http.ListenAndServe \u2014 serves plain HTTP with no TLS. Use http.ListenAndServeTLS, terminate TLS at a proxy, or run behind a managed host that does."
945
+ },
946
+ {
947
+ id: "go-hardcoded-token-literal",
948
+ regex: /\b(?:token|apiKey|api_key|secret)\s*(?::=|=)\s*"[A-Za-z0-9_\-]{20,}"/i,
949
+ itemId: "secrets",
950
+ severity: "high",
951
+ message: "Hardcoded token/secret literal in Go source. Read it from os.Getenv or a secret manager instead of compiling it into the binary."
952
+ }
953
+ ];
954
+ var RAILS_YAML_PATTERNS = [
955
+ RUBY_PATTERNS.find((p) => p.id === "rb-hardcoded-secret-key-base")
956
+ ];
957
+ var LANG_PATTERN_SETS = [
958
+ { exts: [".py"], patterns: PYTHON_PATTERNS },
959
+ { exts: [".rb", ".erb"], patterns: RUBY_PATTERNS },
960
+ { exts: [".yml", ".yaml"], patterns: RAILS_YAML_PATTERNS },
961
+ { exts: [".go"], patterns: GO_PATTERNS }
962
+ ];
963
+ async function checkLanguagePatterns(ctx) {
964
+ const findings = [];
965
+ for (const file of ctx.files) {
966
+ if (!isTextFile(file)) continue;
967
+ const ext = path7.extname(file.relPath).toLowerCase();
968
+ const set = LANG_PATTERN_SETS.find((s) => s.exts.includes(ext));
969
+ if (!set) continue;
970
+ const content = await readFileSafe(file);
971
+ if (!content) continue;
972
+ for (const pat of set.patterns) {
973
+ const m = pat.regex.exec(content);
974
+ if (!m) continue;
975
+ const line = findLine(content, m.index);
976
+ findings.push({
977
+ checkId: pat.id,
978
+ itemId: pat.itemId,
979
+ severity: pat.severity,
980
+ message: pat.message,
981
+ file: relPosix(file.relPath),
982
+ line
983
+ });
984
+ }
985
+ }
986
+ return findings;
987
+ }
988
+ function isPythonSettingsLike(relPath, content) {
989
+ const base = path7.basename(relPath).toLowerCase();
990
+ if (base === "settings.py" || base === "config.py" || base === "app.py" || base === "wsgi.py" || base === "asgi.py") {
991
+ return true;
992
+ }
993
+ if (/\bfrom\s+django\b/.test(content) || /\bimport\s+django\b/.test(content)) return true;
994
+ if (/\bfrom\s+flask\s+import\b/.test(content) || /\bFlask\s*\(/.test(content)) return true;
995
+ return false;
996
+ }
997
+ function pythonReadsSecretKeyFromEnv(content) {
998
+ const envRefs = [
999
+ /SECRET_KEY\s*=\s*os\.environ(?:\.get)?\b/,
1000
+ /SECRET_KEY\s*=\s*os\.getenv\b/,
1001
+ /SECRET_KEY\s*=\s*environ(?:\.get)?\b/,
1002
+ /SECRET_KEY\s*=\s*getenv\b/,
1003
+ /SECRET_KEY\s*=\s*config\s*\(/,
1004
+ /SECRET_KEY\s*=\s*decouple\.config\s*\(/,
1005
+ /SECRET_KEY\s*=\s*env\s*\(/,
1006
+ /SECRET_KEY\s*=\s*env\.str\s*\(/,
1007
+ /app\.config\[\s*['"]SECRET_KEY['"]\s*\]\s*=\s*os\.(?:environ|getenv)\b/
1008
+ ];
1009
+ return envRefs.some((r) => r.test(content));
1010
+ }
1011
+ async function checkPythonSecretKeyEnv(ctx) {
1012
+ const candidates = [];
1013
+ let anyPython = false;
1014
+ for (const file of ctx.files) {
1015
+ if (path7.extname(file.relPath).toLowerCase() !== ".py") continue;
1016
+ anyPython = true;
1017
+ const content = await readFileSafe(file);
1018
+ if (!content) continue;
1019
+ if (isPythonSettingsLike(file.relPath, content)) {
1020
+ candidates.push({ file, content });
1021
+ }
1022
+ }
1023
+ if (!anyPython || candidates.length === 0) return [];
1024
+ let mentionsSecretKey = false;
1025
+ let envBacked = false;
1026
+ let firstMention = null;
1027
+ for (const { file, content } of candidates) {
1028
+ const m = /\bSECRET_KEY\b/.exec(content);
1029
+ if (m) {
1030
+ mentionsSecretKey = true;
1031
+ if (!firstMention) firstMention = { file, line: findLine(content, m.index) };
1032
+ }
1033
+ if (pythonReadsSecretKeyFromEnv(content)) {
1034
+ envBacked = true;
1035
+ break;
1036
+ }
1037
+ }
1038
+ if (envBacked) return [];
1039
+ if (!mentionsSecretKey) {
1040
+ return [
1041
+ {
1042
+ checkId: "py-missing-secret-key-env",
1043
+ itemId: "secrets",
1044
+ severity: "high",
1045
+ message: "Detected a Django/Flask project but couldn't find SECRET_KEY anywhere in your settings. Configure it from an env var (e.g. os.environ['SECRET_KEY']) before deploying.",
1046
+ file: relPosix(candidates[0].file.relPath)
1047
+ }
1048
+ ];
1049
+ }
1050
+ return [
1051
+ {
1052
+ checkId: "py-secret-key-not-from-env",
1053
+ itemId: "secrets",
1054
+ severity: "high",
1055
+ message: "Django/Flask SECRET_KEY is set in source but not read from an environment variable. Pull it from os.environ / os.getenv (or python-decouple / django-environ) so the real value stays out of the repo.",
1056
+ file: firstMention ? relPosix(firstMention.file.relPath) : void 0,
1057
+ line: firstMention?.line
1058
+ }
1059
+ ];
1060
+ }
1061
+ function isRailsProject(_ctx, files) {
1062
+ for (const f of files) {
1063
+ const rel = f.relPath.split(path7.sep).join("/");
1064
+ if (/(^|\/)config\/application\.rb$/.test(rel)) return true;
1065
+ if (/(^|\/)config\/environments\/[a-z]+\.rb$/.test(rel)) return true;
1066
+ }
1067
+ return false;
1068
+ }
1069
+ async function gemfileMentionsRails(ctx) {
1070
+ const gemfile = ctx.files.find((f) => path7.basename(f.relPath) === "Gemfile");
1071
+ if (!gemfile) return false;
1072
+ const content = await readFileSafe(gemfile);
1073
+ if (!content) return false;
1074
+ return /\bgem\s+['"]rails['"]/m.test(content);
1075
+ }
1076
+ async function checkRubySecretKeyBaseEnv(ctx) {
1077
+ const isRails = isRailsProject(ctx, ctx.files) || await gemfileMentionsRails(ctx);
1078
+ if (!isRails) return [];
1079
+ const configFiles = [];
1080
+ for (const file of ctx.files) {
1081
+ const rel = file.relPath.split(path7.sep).join("/");
1082
+ const ext = path7.extname(rel).toLowerCase();
1083
+ const inConfig = /(^|\/)config\//.test(rel);
1084
+ if (!inConfig) continue;
1085
+ if (![".rb", ".yml", ".yaml"].includes(ext)) continue;
1086
+ const content = await readFileSafe(file);
1087
+ if (!content) continue;
1088
+ configFiles.push({ file, content });
1089
+ }
1090
+ if (configFiles.length === 0) return [];
1091
+ let mentionsSecret = false;
1092
+ let envBacked = false;
1093
+ let firstMention = null;
1094
+ for (const { file, content } of configFiles) {
1095
+ const m = /\bsecret_key_base\b/.exec(content);
1096
+ if (m) {
1097
+ mentionsSecret = true;
1098
+ if (!firstMention) firstMention = { file, line: findLine(content, m.index) };
1099
+ }
1100
+ if (/ENV\[\s*['"]SECRET_KEY_BASE['"]\s*\]/.test(content) || /ENV\.fetch\(\s*['"]SECRET_KEY_BASE['"]/.test(content) || /Rails\.application\.credentials/.test(content)) {
1101
+ envBacked = true;
1102
+ }
1103
+ }
1104
+ if (envBacked) return [];
1105
+ if (!mentionsSecret) {
1106
+ return [
1107
+ {
1108
+ checkId: "rb-missing-secret-key-base-env",
1109
+ itemId: "secrets",
1110
+ severity: "high",
1111
+ message: "Detected a Rails project but couldn't find secret_key_base wired up to ENV['SECRET_KEY_BASE'] or Rails.application.credentials anywhere in config/. Configure it before deploying."
1112
+ }
1113
+ ];
1114
+ }
1115
+ return [
1116
+ {
1117
+ checkId: "rb-secret-key-base-not-from-env",
1118
+ itemId: "secrets",
1119
+ severity: "high",
1120
+ message: "Rails secret_key_base is referenced in config/ but not pulled from ENV['SECRET_KEY_BASE'] or Rails.application.credentials. Move the real value out of source.",
1121
+ file: firstMention ? relPosix(firstMention.file.relPath) : void 0,
1122
+ line: firstMention?.line
1123
+ }
1124
+ ];
1125
+ }
1126
+
1127
+ // src/checks/quality.ts
1128
+ import * as path8 from "node:path";
1129
+ var TODO_REGEX = /\b(?:TODO|FIXME|XXX|HACK)\b/;
1130
+ var PLACEHOLDER_REGEX = /\b(lorem ipsum|placeholder text|john doe|jane doe|test@example\.com)\b/i;
1131
+ async function checkPlaceholderContent(ctx) {
1132
+ const findings = [];
1133
+ let todoCount = 0;
1134
+ const placeholderHits = [];
1135
+ for (const file of ctx.files) {
1136
+ if (!isTextFile(file)) continue;
1137
+ if (isScanExempt(file.relPath)) continue;
1138
+ const ext = path8.extname(file.relPath).toLowerCase();
1139
+ if (![".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".mdx"].includes(ext)) continue;
1140
+ const content = await readFileSafe(file);
1141
+ if (!content) continue;
1142
+ const todoMatch = TODO_REGEX.exec(content);
1143
+ if (todoMatch && !lineContainsIgnoreMarker(content, todoMatch.index)) todoCount++;
1144
+ const phMatch = PLACEHOLDER_REGEX.exec(content);
1145
+ if (phMatch && !lineContainsIgnoreMarker(content, phMatch.index)) {
1146
+ placeholderHits.push({
1147
+ checkId: "placeholder-content",
1148
+ itemId: "ai-audit",
1149
+ severity: "medium",
1150
+ message: `Placeholder content "${phMatch[0]}" found \u2014 make sure it isn't shown to real users.`,
1151
+ file: relPosix(file.relPath),
1152
+ line: findLine(content, phMatch.index)
1153
+ });
1154
+ }
1155
+ }
1156
+ if (todoCount > 0) {
1157
+ findings.push({
1158
+ checkId: "todo-comments",
1159
+ itemId: "ai-audit",
1160
+ severity: "lower",
1161
+ message: `Found ${todoCount} file(s) with TODO/FIXME/XXX/HACK comments. Walk through them before launch and decide which are real work.`
1162
+ });
1163
+ }
1164
+ return findings.concat(placeholderHits.slice(0, 25));
1165
+ }
1166
+
1167
+ // src/checks/index.ts
1168
+ var ALL_CHECKS = [
1169
+ { id: "hardcoded-secrets", run: checkHardcodedSecrets },
1170
+ { id: "config-secret-leaks", run: checkConfigSecretLeaks },
1171
+ { id: "env-committed", run: checkEnvCommitted },
1172
+ { id: "env-example", run: checkEnvExample },
1173
+ { id: "gitignore", run: checkGitignore },
1174
+ { id: "robots-txt", run: checkRobotsTxt },
1175
+ { id: "sitemap-xml", run: checkSitemapXml },
1176
+ { id: "favicon", run: checkFavicon },
1177
+ { id: "llms-txt", run: checkLlmsTxt },
1178
+ { id: "pwa-manifest", run: checkPwaManifest },
1179
+ { id: "security-headers", run: checkSecurityHeaders },
1180
+ { id: "dangerous-patterns", run: checkDangerousPatterns },
1181
+ { id: "language-patterns", run: checkLanguagePatterns },
1182
+ { id: "python-secret-key-env", run: checkPythonSecretKeyEnv },
1183
+ { id: "ruby-secret-key-base-env", run: checkRubySecretKeyBaseEnv },
1184
+ { id: "placeholder-content", run: checkPlaceholderContent }
1185
+ ];
1186
+
1187
+ // src/items.ts
1188
+ var CHECKLIST_ITEMS = {
1189
+ secrets: {
1190
+ id: "secrets",
1191
+ title: "Lock up your API keys and passwords",
1192
+ priority: "critical"
1193
+ },
1194
+ "common-attacks": {
1195
+ id: "common-attacks",
1196
+ title: "Block the most common automated attacks",
1197
+ priority: "critical"
1198
+ },
1199
+ "https-headers": {
1200
+ id: "https-headers",
1201
+ title: "Force HTTPS and add browser-level defenses",
1202
+ priority: "critical"
1203
+ },
1204
+ "dev-prod-data": {
1205
+ id: "dev-prod-data",
1206
+ title: "Keep your test data away from real users",
1207
+ priority: "critical"
1208
+ },
1209
+ github: {
1210
+ id: "github",
1211
+ title: "Get your code into GitHub safely",
1212
+ priority: "high"
1213
+ },
1214
+ seo: {
1215
+ id: "seo",
1216
+ title: "Make sure search engines and link previews work",
1217
+ priority: "medium"
1218
+ },
1219
+ "launch-polish": {
1220
+ id: "launch-polish",
1221
+ title: "Last-mile launch polish",
1222
+ priority: "medium"
1223
+ },
1224
+ "ai-audit": {
1225
+ id: "ai-audit",
1226
+ title: "Audit what your AI builder actually shipped",
1227
+ priority: "critical"
1228
+ },
1229
+ aeo: {
1230
+ id: "aeo",
1231
+ title: "Make AI assistants able to recommend you",
1232
+ priority: "lower"
1233
+ },
1234
+ "installable-app": {
1235
+ id: "installable-app",
1236
+ title: "Make your app installable on phones",
1237
+ priority: "lower"
1238
+ }
17
1239
  };
18
- const UNTRACKED_SUFFIX = " (Note: this file is not tracked in git — it can't leak through a repo push, so severity is softened. If you ever commit it or ship it in a public artifact, re-scan.)";
19
- function applyTrackingAwareSeverity(findings, tracked) {
20
- if (!tracked)
21
- return findings;
22
- return findings.map((f) => {
23
- if (!f.file)
24
- return f;
25
- if (tracked.has(f.file))
26
- return f;
27
- const nextSeverity = UNTRACKED_DOWNGRADE[f.severity];
28
- if (nextSeverity === f.severity)
29
- return f;
30
- return {
31
- ...f,
32
- severity: nextSeverity,
33
- message: f.message + UNTRACKED_SUFFIX,
34
- };
1240
+ function permalinkFor(itemId, baseUrl) {
1241
+ const trimmed = baseUrl.replace(/\/+$/, "");
1242
+ return `${trimmed}/i/${itemId}`;
1243
+ }
1244
+
1245
+ // src/publish.ts
1246
+ import { promises as fs2 } from "node:fs";
1247
+ import * as path9 from "node:path";
1248
+ var DEFAULT_BASE_URL = "https://shippingszn.com";
1249
+ var PUBLISH_TIMEOUT_MS = 3e3;
1250
+ function shouldPublish() {
1251
+ const v = process.env["SHIPPINGSZN_PUBLISH"] ?? "";
1252
+ return v === "1" || v.toLowerCase() === "true" || v === "yes";
1253
+ }
1254
+ async function detectStack(cwd2) {
1255
+ const tags = /* @__PURE__ */ new Set();
1256
+ try {
1257
+ const raw = await fs2.readFile(path9.join(cwd2, "package.json"), "utf8");
1258
+ const pkg = JSON.parse(raw);
1259
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1260
+ const has = (n) => n in deps;
1261
+ if (has("react")) tags.add("react");
1262
+ if (has("next")) tags.add("next");
1263
+ if (has("vue") || has("nuxt")) tags.add("vue");
1264
+ if (has("svelte") || has("@sveltejs/kit")) tags.add("svelte");
1265
+ if (has("astro")) tags.add("astro");
1266
+ if (has("express")) tags.add("express");
1267
+ if (has("fastify")) tags.add("fastify");
1268
+ if (has("hono")) tags.add("hono");
1269
+ if (has("drizzle-orm")) tags.add("drizzle");
1270
+ if (has("prisma")) tags.add("prisma");
1271
+ if (has("@supabase/supabase-js")) tags.add("supabase");
1272
+ if (has("firebase")) tags.add("firebase");
1273
+ if (has("stripe")) tags.add("stripe");
1274
+ if (has("typescript")) tags.add("ts");
1275
+ if (has("vite")) tags.add("vite");
1276
+ if (has("expo")) tags.add("expo");
1277
+ if (has("react-native")) tags.add("react-native");
1278
+ } catch {
1279
+ }
1280
+ const checks = [
1281
+ ["requirements.txt", "python"],
1282
+ ["pyproject.toml", "python"],
1283
+ ["Gemfile", "ruby"],
1284
+ ["go.mod", "go"],
1285
+ ["Cargo.toml", "rust"],
1286
+ ["composer.json", "php"],
1287
+ ["pom.xml", "java"],
1288
+ ["build.gradle", "java"]
1289
+ ];
1290
+ for (const [file, tag] of checks) {
1291
+ try {
1292
+ await fs2.access(path9.join(cwd2, file));
1293
+ tags.add(tag);
1294
+ } catch {
1295
+ }
1296
+ }
1297
+ return [...tags].slice(0, 12);
1298
+ }
1299
+ function buildPayload(totals, filesScanned, stack, scannerVersion) {
1300
+ const out = {
1301
+ filesScanned,
1302
+ findingsCritical: totals.critical ?? 0,
1303
+ findingsHigh: totals.high ?? 0,
1304
+ findingsMedium: totals.medium ?? 0,
1305
+ findingsLower: totals.lower ?? 0,
1306
+ scannerVersion
1307
+ };
1308
+ if (stack.length > 0) out.stack = stack;
1309
+ return out;
1310
+ }
1311
+ async function publishScan(totals, filesScanned, opts) {
1312
+ if (!shouldPublish()) return "skipped";
1313
+ const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
1314
+ const stack = await detectStack(opts.cwd);
1315
+ const payload = buildPayload(
1316
+ totals,
1317
+ filesScanned,
1318
+ stack,
1319
+ opts.scannerVersion
1320
+ );
1321
+ const controller = new AbortController();
1322
+ const timer = setTimeout(() => controller.abort(), PUBLISH_TIMEOUT_MS);
1323
+ try {
1324
+ const res = await fetch(`${baseUrl}/api/wall`, {
1325
+ method: "POST",
1326
+ headers: { "content-type": "application/json" },
1327
+ body: JSON.stringify(payload),
1328
+ signal: controller.signal
35
1329
  });
1330
+ clearTimeout(timer);
1331
+ return res.ok ? "published" : "failed";
1332
+ } catch {
1333
+ clearTimeout(timer);
1334
+ return "failed";
1335
+ }
36
1336
  }
37
- const DEFAULT_BASE_URL = "https://shippingszn.com";
38
- const PKG_VERSION = (() => {
39
- const require = createRequire(import.meta.url);
40
- const pkg = require("../package.json");
41
- return pkg.version;
42
- })();
43
- function parseArgs(argv) {
44
- const opts = {
45
- cwd: process.cwd(),
46
- json: false,
47
- baseUrl: process.env.SHIPPINGSZN_BASE_URL ??
48
- process.env.VIBE_LAUNCH_CHECK_BASE_URL ??
49
- DEFAULT_BASE_URL,
50
- help: false,
51
- version: false,
52
- noColor: !!process.env.NO_COLOR,
53
- publish: false,
54
- proof: shouldUploadProof(),
1337
+
1338
+ // src/proof.ts
1339
+ import * as path10 from "node:path";
1340
+ var PROOF_TIMEOUT_MS = 5e3;
1341
+ var MAX_FINDINGS = 100;
1342
+ function shouldUploadProof() {
1343
+ const v = process.env["SHIPPINGSZN_PROOF"] ?? "";
1344
+ return v === "1" || v.toLowerCase() === "true" || v === "yes";
1345
+ }
1346
+ function clampText(value, max, fallback = "") {
1347
+ const text = value ?? fallback;
1348
+ return text.length > max ? text.slice(0, max - 3) + "..." : text;
1349
+ }
1350
+ function locationFromFinding(finding) {
1351
+ if (!finding.file) return void 0;
1352
+ return finding.line ? `${finding.file}:${finding.line}` : finding.file;
1353
+ }
1354
+ function buildBadgeMarkdown(baseUrl, id, score) {
1355
+ const params = new URLSearchParams({
1356
+ scanResultId: id,
1357
+ theme: "dark"
1358
+ });
1359
+ const proofUrl = `${baseUrl}/proof/${encodeURIComponent(id)}`;
1360
+ return `[![Launch readiness proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
1361
+ }
1362
+ function buildProofPayload(report, scannerVersion) {
1363
+ const counts = {
1364
+ critical: report.totals.critical,
1365
+ high: report.totals.high,
1366
+ medium: report.totals.medium,
1367
+ lower: report.totals.lower
1368
+ };
1369
+ return {
1370
+ version: 1,
1371
+ source: report.source ?? "cli",
1372
+ scanner: "shippingszn",
1373
+ targetName: path10.basename(report.cwd) || "CLI scan",
1374
+ score: report.launchReadiness.score,
1375
+ label: report.launchReadiness.label,
1376
+ decision: report.launchReadiness.decision,
1377
+ decisionLabel: report.launchReadiness.decisionLabel,
1378
+ goNoGoLabel: report.launchReadiness.goNoGoLabel,
1379
+ confidence: report.launchReadiness.confidence,
1380
+ checkedAt: report.generatedAt,
1381
+ counts,
1382
+ findings: report.findings.slice(0, MAX_FINDINGS).map((finding) => ({
1383
+ severity: finding.severity,
1384
+ title: clampText(finding.itemTitle || finding.checkId, 160),
1385
+ body: clampText(finding.message, 3e3),
1386
+ whatFailed: clampText(finding.whatFailed, 3e3),
1387
+ whyItBlocksLaunch: clampText(finding.whyItBlocksLaunch, 3e3),
1388
+ fixInstructions: clampText(finding.fixInstructions, 4e3),
1389
+ aiBuilderPrompt: clampText(finding.aiBuilderPrompt, 4e3),
1390
+ evidence: clampText(finding.evidence, 3e3),
1391
+ confidence: finding.confidence,
1392
+ fixPrompt: clampText(finding.aiBuilderPrompt, 4e3),
1393
+ verify: clampText(finding.verificationStep, 3e3),
1394
+ verificationStep: clampText(finding.verificationStep, 3e3),
1395
+ ...locationFromFinding(finding) ? { location: clampText(locationFromFinding(finding), 500) } : {},
1396
+ permalink: finding.permalink,
1397
+ itemTitle: clampText(finding.itemTitle, 160)
1398
+ })),
1399
+ filesScanned: report.filesScanned,
1400
+ topNextStep: clampText(report.launchReadiness.topNextStep, 1e3),
1401
+ reportRecommended: report.launchReadiness.reportRecommended,
1402
+ ...report.launchReadiness.reportUrl ? { reportUrl: report.launchReadiness.reportUrl } : {},
1403
+ scannerVersion
1404
+ };
1405
+ }
1406
+ function buildWallPayload(report, scanResultId, scannerVersion) {
1407
+ return {
1408
+ source: "cli",
1409
+ scanResultId,
1410
+ score: report.launchReadiness.score,
1411
+ label: report.launchReadiness.label,
1412
+ filesScanned: report.filesScanned,
1413
+ findingsCritical: report.totals.critical,
1414
+ findingsHigh: report.totals.high,
1415
+ findingsMedium: report.totals.medium,
1416
+ findingsLower: report.totals.lower,
1417
+ scannerVersion
1418
+ };
1419
+ }
1420
+ async function publishProofToWall(baseUrl, report, scanResultId, scannerVersion) {
1421
+ const controller = new AbortController();
1422
+ const timer = setTimeout(() => controller.abort(), PROOF_TIMEOUT_MS);
1423
+ try {
1424
+ const res = await fetch(`${baseUrl}/api/wall`, {
1425
+ method: "POST",
1426
+ headers: {
1427
+ "content-type": "application/json",
1428
+ "user-agent": `shippingszn-cli/${scannerVersion}`
1429
+ },
1430
+ body: JSON.stringify(
1431
+ buildWallPayload(report, scanResultId, scannerVersion)
1432
+ ),
1433
+ signal: controller.signal
1434
+ });
1435
+ clearTimeout(timer);
1436
+ if (!res.ok) {
1437
+ return { error: `Wall publish failed with HTTP ${res.status}.` };
1438
+ }
1439
+ return { wallUrl: `${baseUrl}/wall` };
1440
+ } catch (err) {
1441
+ clearTimeout(timer);
1442
+ return {
1443
+ error: `Wall publish failed: ${err instanceof Error ? err.message : String(err)}`
55
1444
  };
56
- for (let i = 0; i < argv.length; i++) {
57
- const a = argv[i];
58
- if (a === "--help" || a === "-h")
59
- opts.help = true;
60
- else if (a === "--version" || a === "-v")
61
- opts.version = true;
62
- else if (a === "--json")
63
- opts.json = true;
64
- else if (a === "--publish")
65
- opts.publish = true;
66
- else if (a === "--proof")
67
- opts.proof = true;
68
- else if (a === "--no-color")
69
- opts.noColor = true;
70
- else if (a === "--base-url")
71
- opts.baseUrl = argv[++i] ?? opts.baseUrl;
72
- else if (a === "--cwd")
73
- opts.cwd = path.resolve(argv[++i] ?? opts.cwd);
74
- else if (!a.startsWith("-"))
75
- opts.cwd = path.resolve(a);
76
- }
77
- return opts;
1445
+ }
78
1446
  }
79
- function color(enabled) {
80
- const wrap = (codes) => (s) => enabled ? `\x1b[${codes}m${s}\x1b[0m` : s;
1447
+ async function uploadProof(report, opts) {
1448
+ const baseUrl = opts.baseUrl.replace(/\/$/, "");
1449
+ const payload = buildProofPayload(report, opts.scannerVersion);
1450
+ const controller = new AbortController();
1451
+ const timer = setTimeout(() => controller.abort(), PROOF_TIMEOUT_MS);
1452
+ try {
1453
+ const res = await fetch(`${baseUrl}/api/scan-results`, {
1454
+ method: "POST",
1455
+ headers: {
1456
+ "content-type": "application/json",
1457
+ "user-agent": `shippingszn-cli/${opts.scannerVersion}`
1458
+ },
1459
+ body: JSON.stringify(payload),
1460
+ signal: controller.signal
1461
+ });
1462
+ clearTimeout(timer);
1463
+ if (!res.ok) {
1464
+ return {
1465
+ status: "failed",
1466
+ error: `Proof upload failed with HTTP ${res.status}.`
1467
+ };
1468
+ }
1469
+ const body = await res.json();
1470
+ const id = typeof body.id === "string" ? body.id : "";
1471
+ if (!id) {
1472
+ return {
1473
+ status: "failed",
1474
+ error: "Proof upload succeeded but the response did not include an id."
1475
+ };
1476
+ }
1477
+ const wall = await publishProofToWall(
1478
+ baseUrl,
1479
+ report,
1480
+ id,
1481
+ opts.scannerVersion
1482
+ );
81
1483
  return {
82
- bold: wrap("1"),
83
- dim: wrap("2"),
84
- red: wrap("31"),
85
- yellow: wrap("33"),
86
- blue: wrap("34"),
87
- cyan: wrap("36"),
88
- green: wrap("32"),
89
- magenta: wrap("35"),
90
- gray: wrap("90"),
1484
+ status: "uploaded",
1485
+ id,
1486
+ proofUrl: `${baseUrl}/proof/${encodeURIComponent(id)}`,
1487
+ reportUrl: `${baseUrl}/report?${new URLSearchParams({ scanResultId: id }).toString()}`,
1488
+ badgeMarkdown: buildBadgeMarkdown(
1489
+ baseUrl,
1490
+ id,
1491
+ report.launchReadiness.score
1492
+ ),
1493
+ ...wall.wallUrl ? { wallUrl: wall.wallUrl } : {},
1494
+ ...wall.error ? { wallPublishError: wall.error } : {}
91
1495
  };
1496
+ } catch (err) {
1497
+ clearTimeout(timer);
1498
+ return {
1499
+ status: "failed",
1500
+ error: `Proof upload failed: ${err instanceof Error ? err.message : String(err)}`
1501
+ };
1502
+ }
92
1503
  }
93
- const SEVERITY_ORDER = ["critical", "high", "medium", "lower"];
94
- const SEVERITY_LABEL = {
95
- critical: "CRITICAL",
96
- high: "HIGH",
97
- medium: "MEDIUM",
98
- lower: "LOWER",
1504
+
1505
+ // src/remediation.ts
1506
+ var SEVERITY_INTENT = {
1507
+ critical: "Treat this as launch-blocking until fixed.",
1508
+ high: "Fix this before public traffic or paid acquisition.",
1509
+ medium: "Fix this before announcing or indexing the launch.",
1510
+ lower: "Fix this when polishing the launch artifact."
1511
+ };
1512
+ var ESCALATION = {
1513
+ critical: "Escalate before launch if this touches secrets, auth, payments, customer data, production infrastructure, or anything already exposed publicly.",
1514
+ high: "Escalate if the fix affects auth, payments, deploy config, browser security, or a user-visible production path.",
1515
+ medium: "Escalate only if the fix changes routing, indexing, analytics, or public launch messaging.",
1516
+ lower: "Escalation is usually unnecessary unless this blocks installability, branding, or a promised launch surface."
1517
+ };
1518
+ function locationForPrompt(finding) {
1519
+ if (!finding.file) return "No specific file was attached to the finding.";
1520
+ const line = finding.line ? `:${finding.line}` : "";
1521
+ return `Likely location: ${finding.file}${line}.`;
1522
+ }
1523
+ function proofStepForSeverity(severity, proofCreatePath, reportUrl) {
1524
+ const proofStep = `After the fix verifies clean, run npx shippingszn --json and paste or upload the JSON at ${proofCreatePath} to create launch proof.`;
1525
+ if (severity === "critical" || severity === "high") {
1526
+ return `${proofStep} If this app has users, revenue, client trust, or paid API exposure, attach the clean scan to a paid launch-readiness report at ${reportUrl}.`;
1527
+ }
1528
+ return proofStep;
1529
+ }
1530
+ function buildRemediationPrompt({
1531
+ finding,
1532
+ item,
1533
+ proofCreatePath,
1534
+ reportUrl
1535
+ }) {
1536
+ const itemTitle = item?.title ?? finding.itemId;
1537
+ const location = locationForPrompt(finding);
1538
+ const intent = SEVERITY_INTENT[finding.severity];
1539
+ return {
1540
+ fixPrompt: `You are fixing a shippingszn launch-readiness finding. Severity: ${finding.severity}. Checklist area: ${itemTitle}. Finding: ${finding.message} ${location} ${intent} Make the smallest production-safe code or config change that removes the underlying risk, preserve existing behavior, and list the files changed. Do not suppress the scanner unless you can prove the finding is a false positive.`,
1541
+ verify: "Re-run npx shippingszn --json and confirm this exact finding is gone. Also run the app's normal typecheck/test/build command when the fix changes code, config, routing, security behavior, or public assets.",
1542
+ escalation: ESCALATION[finding.severity],
1543
+ proofNextStep: proofStepForSeverity(
1544
+ finding.severity,
1545
+ proofCreatePath,
1546
+ reportUrl
1547
+ )
1548
+ };
1549
+ }
1550
+
1551
+ // ../../lib/launch-readiness/src/index.ts
1552
+ var SEVERITY_WEIGHTS = {
1553
+ critical: 35,
1554
+ high: 22,
1555
+ medium: 10,
1556
+ lower: 5
1557
+ };
1558
+ var SCORE_BANDS = [
1559
+ {
1560
+ id: "blocked",
1561
+ minScore: 0,
1562
+ maxScore: 59,
1563
+ label: "No-go: launch blocked",
1564
+ decision: "no-go",
1565
+ goNoGoLabel: "No-go"
1566
+ },
1567
+ {
1568
+ id: "fix_first",
1569
+ minScore: 60,
1570
+ maxScore: 79,
1571
+ label: "Fix-first",
1572
+ decision: "fix-first",
1573
+ goNoGoLabel: "Fix first"
1574
+ },
1575
+ {
1576
+ id: "verify_first",
1577
+ minScore: 80,
1578
+ maxScore: 89,
1579
+ label: "Verify before launch",
1580
+ decision: "verify-first",
1581
+ goNoGoLabel: "Verify first"
1582
+ },
1583
+ {
1584
+ id: "launchable",
1585
+ minScore: 90,
1586
+ maxScore: 100,
1587
+ label: "Launchable with proof",
1588
+ decision: "go",
1589
+ goNoGoLabel: "Go"
1590
+ }
1591
+ ];
1592
+ var SOURCE_SCORE_CAP = {
1593
+ url: 88,
1594
+ cli: 100,
1595
+ github: 100,
1596
+ manual: 72
1597
+ };
1598
+ var COVERAGE_LABELS = {
1599
+ public_surface: "Public launch surface",
1600
+ repo_static: "Repository static scan",
1601
+ secrets: "Secrets and config exposure",
1602
+ auth: "Auth and private surfaces",
1603
+ paid_api: "Paid API and abuse risk",
1604
+ deployment: "Deployment and runtime config",
1605
+ content: "Launch content and metadata",
1606
+ proof: "Proof/report handoff"
1607
+ };
1608
+ var SEVERITY_RANK = {
1609
+ critical: 0,
1610
+ high: 1,
1611
+ medium: 2,
1612
+ lower: 3
1613
+ };
1614
+ function clampText2(value, fallback, max = 4e3) {
1615
+ const trimmed = value?.trim();
1616
+ const out = trimmed || fallback;
1617
+ return out.length > max ? `${out.slice(0, max - 3)}...` : out;
1618
+ }
1619
+ function clampCount(value) {
1620
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1621
+ return Math.max(0, Math.round(value));
1622
+ }
1623
+ function clampScore(value) {
1624
+ return Math.max(0, Math.min(100, Math.round(value)));
1625
+ }
1626
+ function canonicalSeverity(value) {
1627
+ if (value === "fatal" || value === "error" || value === "critical") {
1628
+ return "critical";
1629
+ }
1630
+ if (value === "high") return "high";
1631
+ if (value === "medium" || value === "warning") return "medium";
1632
+ return "lower";
1633
+ }
1634
+ function emptyLaunchReadinessCounts() {
1635
+ return { critical: 0, high: 0, medium: 0, lower: 0 };
1636
+ }
1637
+ function normalizeLaunchCounts(counts) {
1638
+ return {
1639
+ critical: clampCount(counts?.critical),
1640
+ high: clampCount(counts?.high),
1641
+ medium: clampCount(counts?.medium),
1642
+ lower: clampCount(counts?.lower)
1643
+ };
1644
+ }
1645
+ function countLaunchFindings(findings) {
1646
+ const counts = emptyLaunchReadinessCounts();
1647
+ for (const finding of findings) {
1648
+ counts[canonicalSeverity(finding.severity)] += 1;
1649
+ }
1650
+ return counts;
1651
+ }
1652
+ function confidenceForSource(source) {
1653
+ if (source === "url") return "medium";
1654
+ if (source === "manual") return "low";
1655
+ return "high";
1656
+ }
1657
+ function normalizeConfidence(value, fallback) {
1658
+ return value === "low" || value === "medium" || value === "high" ? value : fallback;
1659
+ }
1660
+ function sourceLabel(source) {
1661
+ if (source === "github") return "GitHub Action scan";
1662
+ if (source === "cli") return "CLI scan";
1663
+ if (source === "url") return "public URL scan";
1664
+ return "manual intake";
1665
+ }
1666
+ function coverageArea(id, status, evidence, confidence) {
1667
+ return {
1668
+ id,
1669
+ label: COVERAGE_LABELS[id],
1670
+ status,
1671
+ evidence,
1672
+ confidence: confidence ?? (status === "checked" ? "high" : status === "partial" ? "medium" : "low")
1673
+ };
1674
+ }
1675
+ function coverageForSource(source) {
1676
+ if (source === "url") {
1677
+ return [
1678
+ coverageArea(
1679
+ "public_surface",
1680
+ "checked",
1681
+ "Fetched the public URL and evaluated launch-page basics.",
1682
+ "medium"
1683
+ ),
1684
+ coverageArea(
1685
+ "repo_static",
1686
+ "not_checked",
1687
+ "No repository files were scanned from the public URL path."
1688
+ ),
1689
+ coverageArea(
1690
+ "secrets",
1691
+ "not_checked",
1692
+ "Secrets, committed env files, and config leaks require the CLI or GitHub Action."
1693
+ ),
1694
+ coverageArea(
1695
+ "auth",
1696
+ "not_checked",
1697
+ "Private routes and role guards cannot be proven from a public unauthenticated fetch."
1698
+ ),
1699
+ coverageArea(
1700
+ "paid_api",
1701
+ "not_checked",
1702
+ "Spend caps, rate limits, and provider abuse controls require repository or operator context."
1703
+ ),
1704
+ coverageArea(
1705
+ "deployment",
1706
+ "partial",
1707
+ "HTTPS and response behavior were checked; runtime env and deploy config were not."
1708
+ ),
1709
+ coverageArea(
1710
+ "content",
1711
+ "checked",
1712
+ "Public metadata, placeholder copy, and launch-page signals were checked.",
1713
+ "medium"
1714
+ ),
1715
+ coverageArea(
1716
+ "proof",
1717
+ "checked",
1718
+ "The result can be saved as proof and used to generate a report handoff.",
1719
+ "medium"
1720
+ )
1721
+ ];
1722
+ }
1723
+ if (source === "cli" || source === "github") {
1724
+ const isGithub = source === "github";
1725
+ return [
1726
+ coverageArea(
1727
+ "public_surface",
1728
+ "partial",
1729
+ "Repository signals were scanned; live production behavior still needs a URL scan."
1730
+ ),
1731
+ coverageArea(
1732
+ "repo_static",
1733
+ "checked",
1734
+ isGithub ? "GitHub Actions scanned checked-out repository files." : "The local CLI scanned repository files from the project root."
1735
+ ),
1736
+ coverageArea(
1737
+ "secrets",
1738
+ "checked",
1739
+ "Common committed secret and config exposure patterns were scanned."
1740
+ ),
1741
+ coverageArea(
1742
+ "auth",
1743
+ "partial",
1744
+ "Static auth-risk signals were checked; runtime role behavior still needs verification."
1745
+ ),
1746
+ coverageArea(
1747
+ "paid_api",
1748
+ "partial",
1749
+ "Static paid-provider and limiter signals were checked; real spend caps still need owner verification."
1750
+ ),
1751
+ coverageArea(
1752
+ "deployment",
1753
+ "partial",
1754
+ "Deploy-facing files and config signals were scanned; hosted environment settings may still be outside the repo."
1755
+ ),
1756
+ coverageArea(
1757
+ "content",
1758
+ "checked",
1759
+ "Launch metadata, public assets, placeholder content, and checklist-backed file signals were scanned."
1760
+ ),
1761
+ coverageArea(
1762
+ "proof",
1763
+ "checked",
1764
+ "The result can create proof, badge, Wall, and report handoff artifacts."
1765
+ )
1766
+ ];
1767
+ }
1768
+ return [
1769
+ coverageArea(
1770
+ "public_surface",
1771
+ "partial",
1772
+ "Manual intake may include public URL context, but no automated URL scan is guaranteed.",
1773
+ "low"
1774
+ ),
1775
+ coverageArea(
1776
+ "repo_static",
1777
+ "partial",
1778
+ "Manual evidence can describe repository risks, but the scanner has not verified them.",
1779
+ "low"
1780
+ ),
1781
+ coverageArea(
1782
+ "secrets",
1783
+ "partial",
1784
+ "Manual evidence can mention secrets; run the CLI or GitHub Action for proof.",
1785
+ "low"
1786
+ ),
1787
+ coverageArea(
1788
+ "auth",
1789
+ "partial",
1790
+ "Manual evidence can mention auth risk; runtime verification is still required.",
1791
+ "low"
1792
+ ),
1793
+ coverageArea(
1794
+ "paid_api",
1795
+ "partial",
1796
+ "Manual evidence can mention paid API risk; provider-side spend controls are still unverified.",
1797
+ "low"
1798
+ ),
1799
+ coverageArea(
1800
+ "deployment",
1801
+ "partial",
1802
+ "Manual evidence can mention deployment risk; hosted settings are not automatically checked.",
1803
+ "low"
1804
+ ),
1805
+ coverageArea(
1806
+ "content",
1807
+ "partial",
1808
+ "Manual evidence can mention content polish; run a URL scan for public-page proof.",
1809
+ "low"
1810
+ ),
1811
+ coverageArea(
1812
+ "proof",
1813
+ "checked",
1814
+ "The result can still be packaged into a report handoff.",
1815
+ "low"
1816
+ )
1817
+ ];
1818
+ }
1819
+ function coverageSummary(source, coverage) {
1820
+ const checked = coverage.filter((area) => area.status === "checked").length;
1821
+ const partial = coverage.filter((area) => area.status === "partial").length;
1822
+ const missing = coverage.filter((area) => area.status === "not_checked");
1823
+ if (missing.length === 0) {
1824
+ return `${sourceLabel(source)} covered ${checked} readiness areas with ${partial} areas requiring owner verification.`;
1825
+ }
1826
+ return `${sourceLabel(source)} covered ${checked} readiness areas, partially covered ${partial}, and left ${missing.map((area) => area.label).join(", ")} unverified.`;
1827
+ }
1828
+ function defaultWhy(severity, source) {
1829
+ if (severity === "critical") {
1830
+ return `This is treated as launch-blocking evidence from the ${sourceLabel(source)} because it can expose users, secrets, revenue, or the primary launch surface.`;
1831
+ }
1832
+ if (severity === "high") {
1833
+ return `This can turn a launch into a trust, security, cost, or conversion problem even if the app appears to work.`;
1834
+ }
1835
+ if (severity === "medium") {
1836
+ return `This weakens launch readiness and should be fixed before public announcement, indexing, or paid traffic.`;
1837
+ }
1838
+ return `This is lower-priority readiness polish, but it still belongs in the fix queue before the launch proof is treated as clean.`;
1839
+ }
1840
+ function locationLine(finding) {
1841
+ if (finding.location?.trim()) return finding.location.trim();
1842
+ if (!finding.file?.trim()) return "";
1843
+ return finding.line ? `${finding.file.trim()}:${finding.line}` : finding.file.trim();
1844
+ }
1845
+ function defaultPrompt(input) {
1846
+ const where = input.location ? `Likely location: ${input.location}.` : "Inspect the relevant app, deploy, and configuration files before editing.";
1847
+ return [
1848
+ "You are fixing a shippingszn launch-readiness blocker in an AI-built app.",
1849
+ `Severity: ${input.severity}.`,
1850
+ `Finding: ${input.title}.`,
1851
+ `What failed: ${input.whatFailed}`,
1852
+ `Why it blocks launch: ${input.whyItBlocksLaunch}`,
1853
+ where,
1854
+ `Fix exactly this: ${input.fixInstructions}`,
1855
+ `Verification required: ${input.verificationStep}`,
1856
+ "Make the smallest production-safe change, preserve existing behavior, list files changed, and do not suppress the scanner unless you prove a false positive."
1857
+ ].join(" ");
1858
+ }
1859
+ function normalizeLaunchFinding(finding, source = "manual") {
1860
+ const severity = canonicalSeverity(finding.severity);
1861
+ const title = clampText2(
1862
+ finding.title ?? finding.itemTitle,
1863
+ "Launch-readiness finding",
1864
+ 160
1865
+ );
1866
+ const body = clampText2(
1867
+ finding.body ?? finding.message,
1868
+ "The scan flagged this as a launch-readiness risk.",
1869
+ 3e3
1870
+ );
1871
+ const whatFailed = clampText2(finding.whatFailed, body, 3e3);
1872
+ const whyItBlocksLaunch = clampText2(
1873
+ finding.whyItBlocksLaunch,
1874
+ body || defaultWhy(severity, source),
1875
+ 3e3
1876
+ );
1877
+ const fixInstructions = clampText2(
1878
+ finding.fixInstructions ?? finding.fixPrompt,
1879
+ `Fix the underlying ${severity} launch-readiness risk and keep the app behavior intact.`,
1880
+ 4e3
1881
+ );
1882
+ const verificationStep = clampText2(
1883
+ finding.verificationStep ?? finding.verify,
1884
+ "Run the same scan again and confirm this finding is gone before launch.",
1885
+ 3e3
1886
+ );
1887
+ const location = locationLine(finding);
1888
+ const aiBuilderPrompt = clampText2(
1889
+ finding.aiBuilderPrompt,
1890
+ defaultPrompt({
1891
+ severity,
1892
+ title,
1893
+ whatFailed,
1894
+ whyItBlocksLaunch,
1895
+ fixInstructions,
1896
+ verificationStep,
1897
+ location
1898
+ }),
1899
+ 4e3
1900
+ );
1901
+ const evidence = clampText2(
1902
+ finding.evidence,
1903
+ location ? `Observed at ${location}.` : body,
1904
+ 3e3
1905
+ );
1906
+ const confidence = normalizeConfidence(
1907
+ finding.confidence,
1908
+ confidenceForSource(source)
1909
+ );
1910
+ return {
1911
+ severity,
1912
+ title,
1913
+ whatFailed,
1914
+ whyItBlocksLaunch,
1915
+ fixInstructions,
1916
+ aiBuilderPrompt,
1917
+ verificationStep,
1918
+ evidence,
1919
+ confidence,
1920
+ body,
1921
+ message: body,
1922
+ fixPrompt: aiBuilderPrompt,
1923
+ verify: verificationStep,
1924
+ ...location ? { location } : {},
1925
+ ...finding.file ? { file: finding.file } : {},
1926
+ ...finding.line ? { line: finding.line } : {},
1927
+ ...finding.permalink ? { permalink: finding.permalink } : {},
1928
+ ...finding.itemTitle ? { itemTitle: finding.itemTitle } : {}
1929
+ };
1930
+ }
1931
+ function prioritizeLaunchFindings(findings, source = "manual") {
1932
+ return findings.map((finding) => normalizeLaunchFinding(finding, source)).sort((a, b) => {
1933
+ const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
1934
+ if (bySeverity !== 0) return bySeverity;
1935
+ return a.title.localeCompare(b.title);
1936
+ });
1937
+ }
1938
+ function scoreFromCounts(counts, source) {
1939
+ const weightedPenalty = counts.critical * SEVERITY_WEIGHTS.critical + counts.high * SEVERITY_WEIGHTS.high + counts.medium * SEVERITY_WEIGHTS.medium + counts.lower * SEVERITY_WEIGHTS.lower;
1940
+ const rawScore = clampScore(100 - weightedPenalty);
1941
+ const scoreCap = SOURCE_SCORE_CAP[source];
1942
+ const score = Math.min(rawScore, scoreCap);
1943
+ return {
1944
+ score,
1945
+ rawScore,
1946
+ weightedPenalty,
1947
+ coveragePenalty: Math.max(0, rawScore - score)
1948
+ };
1949
+ }
1950
+ function scoreBandFor(score) {
1951
+ return SCORE_BANDS.find(
1952
+ (band) => score >= band.minScore && score <= band.maxScore
1953
+ ) ?? SCORE_BANDS[0];
1954
+ }
1955
+ function bandFor(score, counts) {
1956
+ if (counts.critical > 0) return SCORE_BANDS[0];
1957
+ if (counts.high > 0) return SCORE_BANDS[1];
1958
+ return scoreBandFor(score);
1959
+ }
1960
+ function decisionText(band, counts, source) {
1961
+ if (counts.critical > 0) {
1962
+ return `Do not launch yet. ${counts.critical} critical blocker${counts.critical === 1 ? "" : "s"} must be fixed and rescanned before public traffic.`;
1963
+ }
1964
+ if (counts.high > 0) {
1965
+ return `Fix the high-risk launch gaps before a real campaign, customer demo, or paid traffic.`;
1966
+ }
1967
+ if (counts.medium > 0 || counts.lower > 0) {
1968
+ return `No critical or high blocker was detected, but the remaining readiness gaps should be fixed and verified before treating the ${sourceLabel(source)} as clean proof.`;
1969
+ }
1970
+ if (source === "url") {
1971
+ return "No public-page blockers were found. Run the CLI or GitHub Action before calling the app launch-ready.";
1972
+ }
1973
+ return `${band.goNoGoLabel}: no scan-detected blockers were found. Keep the proof attached to the launch record.`;
1974
+ }
1975
+ function topNextStep(blockers, counts, source) {
1976
+ const top = blockers[0];
1977
+ if (top) {
1978
+ return `Fix ${top.title}: ${top.whatFailed}`;
1979
+ }
1980
+ if (source === "url") {
1981
+ return "Run the repo scan next so secrets, auth, API routes, and generated-code risks are checked before launch.";
1982
+ }
1983
+ if (counts.medium > 0 || counts.lower > 0) {
1984
+ return "Fix the remaining readiness gaps and rescan before publishing proof.";
1985
+ }
1986
+ return "Attach this clean scan proof to the launch record and keep monitoring the first production traffic.";
1987
+ }
1988
+ function aggregateConfidence(source, findings) {
1989
+ if (findings.some((finding) => finding.confidence === "low")) return "low";
1990
+ if (source === "url") return "medium";
1991
+ return "high";
1992
+ }
1993
+ function assessLaunchReadiness(input) {
1994
+ const source = input.source ?? "manual";
1995
+ const findings = prioritizeLaunchFindings(input.findings ?? [], source);
1996
+ const counts = input.counts ? normalizeLaunchCounts(input.counts) : countLaunchFindings(findings);
1997
+ const { score, rawScore, weightedPenalty, coveragePenalty } = scoreFromCounts(
1998
+ counts,
1999
+ source
2000
+ );
2001
+ const band = bandFor(score, counts);
2002
+ const coverage = coverageForSource(source);
2003
+ const checkedAreas = coverage.filter((area) => area.status === "checked");
2004
+ const uncheckedAreas = coverage.filter((area) => area.status !== "checked");
2005
+ const blockers = findings.filter(
2006
+ (finding) => finding.severity === "critical" || finding.severity === "high"
2007
+ );
2008
+ const decision = decisionText(band, counts, source);
2009
+ const reportRecommended = counts.critical > 0 || counts.high > 0 || coveragePenalty > 0;
2010
+ return {
2011
+ score,
2012
+ rawScore,
2013
+ label: band.label,
2014
+ decision,
2015
+ decisionLabel: band.label,
2016
+ launchDecision: band.decision,
2017
+ goNoGoLabel: band.goNoGoLabel,
2018
+ band,
2019
+ counts,
2020
+ severityWeights: SEVERITY_WEIGHTS,
2021
+ weightedPenalty,
2022
+ coveragePenalty,
2023
+ coverage,
2024
+ coverageSummary: coverageSummary(source, coverage),
2025
+ checkedAreas,
2026
+ uncheckedAreas,
2027
+ topNextStep: topNextStep(blockers.length ? blockers : findings, counts, source),
2028
+ reportRecommended,
2029
+ confidence: aggregateConfidence(source, findings),
2030
+ blockers,
2031
+ findings
2032
+ };
2033
+ }
2034
+
2035
+ // src/index.ts
2036
+ var UNTRACKED_DOWNGRADE = {
2037
+ critical: "lower",
2038
+ high: "lower",
2039
+ medium: "lower",
2040
+ lower: "lower"
2041
+ };
2042
+ var UNTRACKED_SUFFIX = " (Note: this file is not tracked in git \u2014 it can't leak through a repo push, so severity is softened. If you ever commit it or ship it in a public artifact, re-scan.)";
2043
+ function applyTrackingAwareSeverity(findings, tracked) {
2044
+ if (!tracked) return findings;
2045
+ return findings.map((f) => {
2046
+ if (!f.file) return f;
2047
+ if (tracked.has(f.file)) return f;
2048
+ const nextSeverity = UNTRACKED_DOWNGRADE[f.severity];
2049
+ if (nextSeverity === f.severity) return f;
2050
+ return {
2051
+ ...f,
2052
+ severity: nextSeverity,
2053
+ message: f.message + UNTRACKED_SUFFIX
2054
+ };
2055
+ });
2056
+ }
2057
+ var DEFAULT_BASE_URL2 = "https://shippingszn.com";
2058
+ var PKG_VERSION = (() => {
2059
+ const require2 = createRequire(import.meta.url);
2060
+ const pkg = require2("../package.json");
2061
+ return pkg.version;
2062
+ })();
2063
+ function parseArgs(argv2) {
2064
+ const opts = {
2065
+ cwd: process2.cwd(),
2066
+ json: false,
2067
+ baseUrl: process2.env.SHIPPINGSZN_BASE_URL ?? process2.env.VIBE_LAUNCH_CHECK_BASE_URL ?? DEFAULT_BASE_URL2,
2068
+ help: false,
2069
+ version: false,
2070
+ noColor: !!process2.env.NO_COLOR,
2071
+ publish: false,
2072
+ proof: shouldUploadProof()
2073
+ };
2074
+ for (let i = 0; i < argv2.length; i++) {
2075
+ const a = argv2[i];
2076
+ if (a === "--help" || a === "-h") opts.help = true;
2077
+ else if (a === "--version" || a === "-v") opts.version = true;
2078
+ else if (a === "--json") opts.json = true;
2079
+ else if (a === "--publish") opts.publish = true;
2080
+ else if (a === "--proof") opts.proof = true;
2081
+ else if (a === "--no-color") opts.noColor = true;
2082
+ else if (a === "--base-url") opts.baseUrl = argv2[++i] ?? opts.baseUrl;
2083
+ else if (a === "--cwd") opts.cwd = path11.resolve(argv2[++i] ?? opts.cwd);
2084
+ else if (!a.startsWith("-")) opts.cwd = path11.resolve(a);
2085
+ }
2086
+ return opts;
2087
+ }
2088
+ function color(enabled) {
2089
+ const wrap = (codes) => (s) => enabled ? `\x1B[${codes}m${s}\x1B[0m` : s;
2090
+ return {
2091
+ bold: wrap("1"),
2092
+ dim: wrap("2"),
2093
+ red: wrap("31"),
2094
+ yellow: wrap("33"),
2095
+ blue: wrap("34"),
2096
+ cyan: wrap("36"),
2097
+ green: wrap("32"),
2098
+ magenta: wrap("35"),
2099
+ gray: wrap("90")
2100
+ };
2101
+ }
2102
+ var SEVERITY_ORDER = ["critical", "high", "medium", "lower"];
2103
+ var SEVERITY_LABEL = {
2104
+ critical: "CRITICAL",
2105
+ high: "HIGH",
2106
+ medium: "MEDIUM",
2107
+ lower: "LOWER"
99
2108
  };
100
2109
  function printHelp() {
101
- process.stdout.write(`shippingszn v${PKG_VERSION}
2110
+ process2.stdout.write(
2111
+ `shippingszn v${PKG_VERSION}
102
2112
 
103
2113
  Read-only scanner that checks the current project against a small set of
104
2114
  high-signal launch-readiness items from shippingszn.
@@ -115,7 +2125,7 @@ Options:
115
2125
  printing a proof URL, Wall URL, report URL, and badge.
116
2126
  Can also be enabled with SHIPPINGSZN_PROOF=1.
117
2127
  --base-url <url> Base URL used to build links back to checklist items.
118
- (default: ${DEFAULT_BASE_URL})
2128
+ (default: ${DEFAULT_BASE_URL2})
119
2129
  --cwd <path> Directory to scan. Default: current working directory.
120
2130
  --no-color Disable ANSI colors in the human-readable report.
121
2131
  -h, --help Show this help.
@@ -124,255 +2134,312 @@ Options:
124
2134
  The scanner only reads files. It never writes, modifies, deletes, or makes
125
2135
  network requests unless you explicitly opt in with --publish or --proof.
126
2136
  Exit code is non-zero if any Critical findings are detected.
127
- `);
128
- }
129
- function buildTopNextStep(findings) {
130
- const top = findings[0];
131
- if (!top) {
132
- return "Attach this clean repo scan proof to the launch record, then monitor first production traffic.";
133
- }
134
- return `Fix ${top.itemTitle}: ${top.message}`;
2137
+ `
2138
+ );
135
2139
  }
136
2140
  function scanSource() {
137
- return process.env.GITHUB_ACTIONS === "true" ? "github" : "cli";
2141
+ return process2.env.GITHUB_ACTIONS === "true" ? "github" : "cli";
138
2142
  }
139
2143
  async function run() {
140
- const opts = parseArgs(process.argv.slice(2));
141
- if (opts.help) {
142
- printHelp();
143
- return 0;
144
- }
145
- if (opts.version) {
146
- process.stdout.write(`${PKG_VERSION}\n`);
147
- return 0;
148
- }
149
- const c = color(!opts.noColor && process.stdout.isTTY === true && !opts.json);
150
- const files = await listFiles(opts.cwd);
151
- const tracked = getTrackedFiles(opts.cwd);
152
- const ctx = { rootDir: opts.cwd, files };
153
- const all = [];
154
- for (const check of ALL_CHECKS) {
155
- try {
156
- const out = await check.run(ctx);
157
- all.push(...out);
158
- }
159
- catch (err) {
160
- const msg = err instanceof Error ? err.message : String(err);
161
- all.push({
162
- checkId: `${check.id}:error`,
163
- itemId: "ai-audit",
164
- severity: "lower",
165
- message: `Check ${check.id} crashed: ${msg}`,
166
- });
167
- }
2144
+ const opts = parseArgs(process2.argv.slice(2));
2145
+ if (opts.help) {
2146
+ printHelp();
2147
+ return 0;
2148
+ }
2149
+ if (opts.version) {
2150
+ process2.stdout.write(`${PKG_VERSION}
2151
+ `);
2152
+ return 0;
2153
+ }
2154
+ const c = color(!opts.noColor && process2.stdout.isTTY === true && !opts.json);
2155
+ const files = await listFiles(opts.cwd);
2156
+ const tracked = getTrackedFiles(opts.cwd);
2157
+ const ctx = { rootDir: opts.cwd, files };
2158
+ const all = [];
2159
+ for (const check of ALL_CHECKS) {
2160
+ try {
2161
+ const out = await check.run(ctx);
2162
+ all.push(...out);
2163
+ } catch (err) {
2164
+ const msg = err instanceof Error ? err.message : String(err);
2165
+ all.push({
2166
+ checkId: `${check.id}:error`,
2167
+ itemId: "ai-audit",
2168
+ severity: "lower",
2169
+ message: `Check ${check.id} crashed: ${msg}`
2170
+ });
168
2171
  }
169
- const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
170
- const reportUrl = `${trimmedBaseUrl}/report`;
171
- const proofCreatePath = `${trimmedBaseUrl}/scan`;
172
- const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON in the web scan proof flow to create a shareable launch proof result.";
173
- const tracked_aware = applyTrackingAwareSeverity(all, tracked);
174
- const source = scanSource();
175
- const enriched = tracked_aware.map((f) => {
176
- const item = CHECKLIST_ITEMS[f.itemId];
177
- const remediation = buildRemediationPrompt({
178
- finding: f,
179
- item,
180
- proofCreatePath,
181
- reportUrl,
182
- });
183
- const itemTitle = item?.title ?? f.itemId;
184
- const normalized = normalizeLaunchFinding({
185
- severity: f.severity,
186
- title: itemTitle,
187
- body: f.message,
188
- message: f.message,
189
- evidence: f.evidence,
190
- file: f.file,
191
- line: f.line,
192
- permalink: permalinkFor(f.itemId, opts.baseUrl),
193
- itemTitle,
194
- fixInstructions: remediation.fixPrompt,
195
- aiBuilderPrompt: remediation.fixPrompt,
196
- verificationStep: remediation.verify,
197
- fixPrompt: remediation.fixPrompt,
198
- verify: remediation.verify,
199
- }, source);
200
- return {
201
- ...f,
202
- ...normalized,
203
- itemTitle,
204
- permalink: permalinkFor(f.itemId, opts.baseUrl),
205
- remediation,
206
- };
2172
+ }
2173
+ const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
2174
+ const reportUrl = `${trimmedBaseUrl}/report`;
2175
+ const proofCreatePath = `${trimmedBaseUrl}/scan`;
2176
+ const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON in the web scan proof flow to create a shareable launch proof result.";
2177
+ const tracked_aware = applyTrackingAwareSeverity(all, tracked);
2178
+ const source = scanSource();
2179
+ const enriched = tracked_aware.map((f) => {
2180
+ const item = CHECKLIST_ITEMS[f.itemId];
2181
+ const remediation = buildRemediationPrompt({
2182
+ finding: f,
2183
+ item,
2184
+ proofCreatePath,
2185
+ reportUrl
207
2186
  });
208
- enriched.sort((a, b) => {
209
- const sa = SEVERITY_ORDER.indexOf(a.severity);
210
- const sb = SEVERITY_ORDER.indexOf(b.severity);
211
- if (sa !== sb)
212
- return sa - sb;
213
- if (a.itemId !== b.itemId)
214
- return a.itemId.localeCompare(b.itemId);
215
- return a.checkId.localeCompare(b.checkId);
216
- });
217
- const totals = {
218
- critical: 0,
219
- high: 0,
220
- medium: 0,
221
- lower: 0,
2187
+ const itemTitle = item?.title ?? f.itemId;
2188
+ const normalized = normalizeLaunchFinding(
2189
+ {
2190
+ severity: f.severity,
2191
+ title: itemTitle,
2192
+ body: f.message,
2193
+ message: f.message,
2194
+ evidence: f.evidence,
2195
+ file: f.file,
2196
+ line: f.line,
2197
+ permalink: permalinkFor(f.itemId, opts.baseUrl),
2198
+ itemTitle,
2199
+ fixInstructions: remediation.fixPrompt,
2200
+ aiBuilderPrompt: remediation.fixPrompt,
2201
+ verificationStep: remediation.verify,
2202
+ fixPrompt: remediation.fixPrompt,
2203
+ verify: remediation.verify
2204
+ },
2205
+ source
2206
+ );
2207
+ return {
2208
+ ...f,
2209
+ ...normalized,
2210
+ itemTitle,
2211
+ permalink: permalinkFor(f.itemId, opts.baseUrl),
2212
+ remediation
222
2213
  };
223
- for (const f of enriched)
224
- totals[f.severity]++;
225
- const assessment = assessLaunchReadiness({
226
- source,
227
- findings: enriched,
228
- counts: totals,
2214
+ });
2215
+ enriched.sort((a, b) => {
2216
+ const sa = SEVERITY_ORDER.indexOf(a.severity);
2217
+ const sb = SEVERITY_ORDER.indexOf(b.severity);
2218
+ if (sa !== sb) return sa - sb;
2219
+ if (a.itemId !== b.itemId) return a.itemId.localeCompare(b.itemId);
2220
+ return a.checkId.localeCompare(b.checkId);
2221
+ });
2222
+ const totals = {
2223
+ critical: 0,
2224
+ high: 0,
2225
+ medium: 0,
2226
+ lower: 0
2227
+ };
2228
+ for (const f of enriched) totals[f.severity]++;
2229
+ const assessment = assessLaunchReadiness({
2230
+ source,
2231
+ findings: enriched,
2232
+ counts: totals
2233
+ });
2234
+ const launchReadiness = {
2235
+ score: assessment.score,
2236
+ rawScore: assessment.rawScore,
2237
+ label: assessment.label,
2238
+ decision: assessment.decision,
2239
+ decisionLabel: assessment.decisionLabel,
2240
+ goNoGoLabel: assessment.goNoGoLabel,
2241
+ confidence: assessment.confidence,
2242
+ coveragePenalty: assessment.coveragePenalty,
2243
+ coverageSummary: assessment.coverageSummary,
2244
+ topNextStep: assessment.topNextStep,
2245
+ proofCreatePath,
2246
+ proofUploadHint,
2247
+ reportRecommended: assessment.reportRecommended,
2248
+ ...assessment.reportRecommended ? { reportUrl } : {}
2249
+ };
2250
+ const report = {
2251
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2252
+ source,
2253
+ baseUrl: opts.baseUrl,
2254
+ cwd: opts.cwd,
2255
+ filesScanned: files.length,
2256
+ totals,
2257
+ launchReadiness,
2258
+ findings: enriched
2259
+ };
2260
+ let proofResult = { status: "skipped" };
2261
+ if (opts.proof) {
2262
+ proofResult = await uploadProof(report, {
2263
+ baseUrl: opts.baseUrl,
2264
+ scannerVersion: PKG_VERSION
229
2265
  });
230
- const launchReadiness = {
231
- score: assessment.score,
232
- rawScore: assessment.rawScore,
233
- label: assessment.label,
234
- decision: assessment.decision,
235
- decisionLabel: assessment.decisionLabel,
236
- goNoGoLabel: assessment.goNoGoLabel,
237
- confidence: assessment.confidence,
238
- coveragePenalty: assessment.coveragePenalty,
239
- coverageSummary: assessment.coverageSummary,
240
- topNextStep: assessment.topNextStep,
241
- proofCreatePath,
242
- proofUploadHint,
243
- reportRecommended: assessment.reportRecommended,
244
- ...(assessment.reportRecommended ? { reportUrl } : {}),
245
- };
246
- const report = {
247
- generatedAt: new Date().toISOString(),
248
- source,
249
- baseUrl: opts.baseUrl,
250
- cwd: opts.cwd,
251
- filesScanned: files.length,
252
- totals,
253
- launchReadiness,
254
- findings: enriched,
255
- };
256
- let proofResult = { status: "skipped" };
257
- if (opts.proof) {
258
- proofResult = await uploadProof(report, {
259
- baseUrl: opts.baseUrl,
260
- scannerVersion: PKG_VERSION,
261
- });
262
- if (proofResult.status === "uploaded") {
263
- report.launchReadiness.proofUrl = proofResult.proofUrl;
264
- report.launchReadiness.proofResultId = proofResult.id;
265
- report.launchReadiness.badgeMarkdown = proofResult.badgeMarkdown;
266
- report.launchReadiness.reportUrl = proofResult.reportUrl;
267
- report.launchReadiness.wallUrl = proofResult.wallUrl;
268
- report.launchReadiness.wallPublishError = proofResult.wallPublishError;
269
- }
270
- else if (proofResult.status === "failed") {
271
- report.launchReadiness.proofUploadError =
272
- proofResult.error ?? "Proof upload failed.";
273
- }
274
- }
275
- // Best-effort anonymous publish to the Wall of Launches. This is explicit
276
- // opt-in because shippingszn is a trust product; scanning must stay local
277
- // unless the user asks to publish proof.
278
- let publishResult = "skipped";
279
- try {
280
- if (opts.publish) {
281
- process.env["SHIPPINGSZN_PUBLISH"] = "1";
282
- }
283
- const proofAlreadyPublishedWall = proofResult.status === "uploaded" && !!proofResult.wallUrl;
284
- if (!proofAlreadyPublishedWall) {
285
- publishResult = await publishScan(totals, files.length, {
286
- cwd: opts.cwd,
287
- baseUrl: opts.baseUrl,
288
- scannerVersion: PKG_VERSION,
289
- });
290
- }
291
- }
292
- catch {
293
- /* never block on wall publish */
294
- }
295
- if (opts.json) {
296
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
297
- return totals.critical > 0 ? 1 : 0;
298
- }
299
- // Human-readable report
300
- const sevColor = (s) => {
301
- if (s === "critical")
302
- return c.red;
303
- if (s === "high")
304
- return c.yellow;
305
- if (s === "medium")
306
- return c.blue;
307
- return c.gray;
308
- };
309
- // Strip ASCII control characters (including ESC) so a maliciously-named
310
- // file or matched secret slice cannot inject ANSI escape sequences into
311
- // the operator's terminal.
312
- const safe = (s) => s.replace(/[\x00-\x1f\x7f]/g, "?");
313
- process.stdout.write(`\n${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}\n`);
314
- process.stdout.write(c.dim(`Scanned ${files.length} files in ${opts.cwd}\n\n`));
315
- process.stdout.write(`${c.bold("Launch Readiness Score:")} ${launchReadiness.score}/100 ${c.dim(`(${launchReadiness.label})`)}\n`);
316
- process.stdout.write(`${c.bold("Scope:")} ${safe(launchReadiness.coverageSummary)}\n`);
317
- process.stdout.write(`${c.bold("Top next step:")} ${safe(launchReadiness.topNextStep)}\n`);
318
- process.stdout.write(`${c.bold("Fix prompts:")} ${c.dim("Run with --json for copy/paste AI-builder remediation prompts.")}\n`);
319
- if (launchReadiness.reportUrl) {
320
- process.stdout.write(`${c.bold("Need paid confidence?")} ${c.dim(`Get a launch-readiness report: ${launchReadiness.reportUrl}`)}\n`);
321
- }
322
2266
  if (proofResult.status === "uploaded") {
323
- process.stdout.write(`${c.bold("Proof URL:")} ${c.dim(proofResult.proofUrl ?? "")}\n`);
324
- process.stdout.write(`${c.bold("Report URL:")} ${c.dim(proofResult.reportUrl ?? "")}\n`);
325
- if (proofResult.wallUrl) {
326
- process.stdout.write(`${c.bold("Wall URL:")} ${c.dim(proofResult.wallUrl)}\n`);
327
- }
328
- process.stdout.write(`${c.bold("Badge Markdown:")} ${c.dim(proofResult.badgeMarkdown ?? "")}\n`);
329
- if (proofResult.wallPublishError) {
330
- process.stdout.write(`${c.bold("Wall publish failed:")} ${c.dim(proofResult.wallPublishError)}\n`);
331
- }
2267
+ report.launchReadiness.proofUrl = proofResult.proofUrl;
2268
+ report.launchReadiness.proofResultId = proofResult.id;
2269
+ report.launchReadiness.badgeMarkdown = proofResult.badgeMarkdown;
2270
+ report.launchReadiness.reportUrl = proofResult.reportUrl;
2271
+ report.launchReadiness.wallUrl = proofResult.wallUrl;
2272
+ report.launchReadiness.wallPublishError = proofResult.wallPublishError;
2273
+ } else if (proofResult.status === "failed") {
2274
+ report.launchReadiness.proofUploadError = proofResult.error ?? "Proof upload failed.";
332
2275
  }
333
- else if (proofResult.status === "failed") {
334
- process.stdout.write(`${c.bold("Proof upload failed:")} ${c.dim(proofResult.error ?? "Unknown upload error.")}\n`);
2276
+ }
2277
+ let publishResult = "skipped";
2278
+ try {
2279
+ if (opts.publish) {
2280
+ process2.env["SHIPPINGSZN_PUBLISH"] = "1";
335
2281
  }
336
- else {
337
- process.stdout.write(`${c.bold("Share proof:")} ${c.dim("Run with --proof to publish this scan to a public proof URL.")}\n`);
2282
+ const proofAlreadyPublishedWall = proofResult.status === "uploaded" && !!proofResult.wallUrl;
2283
+ if (!proofAlreadyPublishedWall) {
2284
+ publishResult = await publishScan(totals, files.length, {
2285
+ cwd: opts.cwd,
2286
+ baseUrl: opts.baseUrl,
2287
+ scannerVersion: PKG_VERSION
2288
+ });
338
2289
  }
339
- process.stdout.write("\n");
340
- if (enriched.length === 0) {
341
- process.stdout.write(c.green("No findings. Attach the clean scan proof to the launch record before shipping.\n\n"));
342
- if (publishResult === "published") {
343
- process.stdout.write(c.dim(`Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n`));
344
- }
345
- return 0;
2290
+ } catch {
2291
+ }
2292
+ if (opts.json) {
2293
+ process2.stdout.write(JSON.stringify(report, null, 2) + "\n");
2294
+ return totals.critical > 0 ? 1 : 0;
2295
+ }
2296
+ const sevColor = (s) => {
2297
+ if (s === "critical") return c.red;
2298
+ if (s === "high") return c.yellow;
2299
+ if (s === "medium") return c.blue;
2300
+ return c.gray;
2301
+ };
2302
+ const safe = (s) => s.replace(/[\x00-\x1f\x7f]/g, "?");
2303
+ process2.stdout.write(
2304
+ `
2305
+ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
2306
+ `
2307
+ );
2308
+ process2.stdout.write(
2309
+ c.dim(`Scanned ${files.length} files in ${opts.cwd}
2310
+
2311
+ `)
2312
+ );
2313
+ process2.stdout.write(
2314
+ `${c.bold("Launch Readiness Score:")} ${launchReadiness.score}/100 ${c.dim(`(${launchReadiness.label})`)}
2315
+ `
2316
+ );
2317
+ process2.stdout.write(
2318
+ `${c.bold("Scope:")} ${safe(launchReadiness.coverageSummary)}
2319
+ `
2320
+ );
2321
+ process2.stdout.write(
2322
+ `${c.bold("Top next step:")} ${safe(launchReadiness.topNextStep)}
2323
+ `
2324
+ );
2325
+ process2.stdout.write(
2326
+ `${c.bold("Fix prompts:")} ${c.dim("Run with --json for copy/paste AI-builder remediation prompts.")}
2327
+ `
2328
+ );
2329
+ if (launchReadiness.reportUrl) {
2330
+ process2.stdout.write(
2331
+ `${c.bold("Need paid confidence?")} ${c.dim(`Get a launch-readiness report: ${launchReadiness.reportUrl}`)}
2332
+ `
2333
+ );
2334
+ }
2335
+ if (proofResult.status === "uploaded") {
2336
+ process2.stdout.write(
2337
+ `${c.bold("Proof URL:")} ${c.dim(proofResult.proofUrl ?? "")}
2338
+ `
2339
+ );
2340
+ process2.stdout.write(
2341
+ `${c.bold("Report URL:")} ${c.dim(proofResult.reportUrl ?? "")}
2342
+ `
2343
+ );
2344
+ if (proofResult.wallUrl) {
2345
+ process2.stdout.write(
2346
+ `${c.bold("Wall URL:")} ${c.dim(proofResult.wallUrl)}
2347
+ `
2348
+ );
346
2349
  }
347
- for (const sev of SEVERITY_ORDER) {
348
- const group = enriched.filter((f) => f.severity === sev);
349
- if (group.length === 0)
350
- continue;
351
- process.stdout.write(`${sevColor(sev)(c.bold(`${SEVERITY_LABEL[sev]} (${group.length})`))}\n`);
352
- for (const f of group) {
353
- const loc = f.file
354
- ? ` ${c.dim(`— ${safe(f.file)}${f.line ? `:${f.line}` : ""}`)}`
355
- : "";
356
- process.stdout.write(` ${c.bold("•")} ${safe(f.message)}${loc}\n`);
357
- if (f.evidence) {
358
- process.stdout.write(` ${c.dim(`evidence: ${safe(f.evidence)}`)}\n`);
359
- }
360
- process.stdout.write(` ${c.cyan(`→ ${safe(f.itemTitle)}`)} ${c.dim(f.permalink)}\n`);
361
- }
362
- process.stdout.write("\n");
2350
+ process2.stdout.write(
2351
+ `${c.bold("Badge Markdown:")} ${c.dim(proofResult.badgeMarkdown ?? "")}
2352
+ `
2353
+ );
2354
+ if (proofResult.wallPublishError) {
2355
+ process2.stdout.write(
2356
+ `${c.bold("Wall publish failed:")} ${c.dim(proofResult.wallPublishError)}
2357
+ `
2358
+ );
363
2359
  }
364
- process.stdout.write(`${c.bold("Summary:")} ${c.red(`${totals.critical} critical`)}, ${c.yellow(`${totals.high} high`)}, ${c.blue(`${totals.medium} medium`)}, ${c.gray(`${totals.lower} lower`)}\n`);
2360
+ } else if (proofResult.status === "failed") {
2361
+ process2.stdout.write(
2362
+ `${c.bold("Proof upload failed:")} ${c.dim(proofResult.error ?? "Unknown upload error.")}
2363
+ `
2364
+ );
2365
+ } else {
2366
+ process2.stdout.write(
2367
+ `${c.bold("Share proof:")} ${c.dim("Run with --proof to publish this scan to a public proof URL.")}
2368
+ `
2369
+ );
2370
+ }
2371
+ process2.stdout.write("\n");
2372
+ if (enriched.length === 0) {
2373
+ process2.stdout.write(
2374
+ c.green(
2375
+ "No findings. Attach the clean scan proof to the launch record before shipping.\n\n"
2376
+ )
2377
+ );
365
2378
  if (publishResult === "published") {
366
- process.stdout.write(c.dim(`\nPosted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n`));
367
- }
368
- if (totals.critical > 0) {
369
- process.stdout.write(c.red("\nCritical findings detected. Exiting with code 1.\n"));
370
- return 1;
2379
+ process2.stdout.write(
2380
+ c.dim(
2381
+ `Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall
2382
+ `
2383
+ )
2384
+ );
371
2385
  }
372
- process.stdout.write(c.dim("\nNo critical findings. Open the linked checklist items to dig deeper.\n"));
373
2386
  return 0;
2387
+ }
2388
+ for (const sev of SEVERITY_ORDER) {
2389
+ const group = enriched.filter((f) => f.severity === sev);
2390
+ if (group.length === 0) continue;
2391
+ process2.stdout.write(
2392
+ `${sevColor(sev)(c.bold(`${SEVERITY_LABEL[sev]} (${group.length})`))}
2393
+ `
2394
+ );
2395
+ for (const f of group) {
2396
+ const loc = f.file ? ` ${c.dim(`\u2014 ${safe(f.file)}${f.line ? `:${f.line}` : ""}`)}` : "";
2397
+ process2.stdout.write(` ${c.bold("\u2022")} ${safe(f.message)}${loc}
2398
+ `);
2399
+ if (f.evidence) {
2400
+ process2.stdout.write(` ${c.dim(`evidence: ${safe(f.evidence)}`)}
2401
+ `);
2402
+ }
2403
+ process2.stdout.write(
2404
+ ` ${c.cyan(`\u2192 ${safe(f.itemTitle)}`)} ${c.dim(f.permalink)}
2405
+ `
2406
+ );
2407
+ }
2408
+ process2.stdout.write("\n");
2409
+ }
2410
+ process2.stdout.write(
2411
+ `${c.bold("Summary:")} ${c.red(`${totals.critical} critical`)}, ${c.yellow(`${totals.high} high`)}, ${c.blue(`${totals.medium} medium`)}, ${c.gray(`${totals.lower} lower`)}
2412
+ `
2413
+ );
2414
+ if (publishResult === "published") {
2415
+ process2.stdout.write(
2416
+ c.dim(
2417
+ `
2418
+ Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall
2419
+ `
2420
+ )
2421
+ );
2422
+ }
2423
+ if (totals.critical > 0) {
2424
+ process2.stdout.write(
2425
+ c.red("\nCritical findings detected. Exiting with code 1.\n")
2426
+ );
2427
+ return 1;
2428
+ }
2429
+ process2.stdout.write(
2430
+ c.dim(
2431
+ "\nNo critical findings. Open the linked checklist items to dig deeper.\n"
2432
+ )
2433
+ );
2434
+ return 0;
374
2435
  }
375
- run().then((code) => process.exit(code), (err) => {
376
- process.stderr.write(`shippingszn failed: ${err instanceof Error ? err.message : String(err)}\n`);
377
- process.exit(2);
378
- });
2436
+ run().then(
2437
+ (code) => process2.exit(code),
2438
+ (err) => {
2439
+ process2.stderr.write(
2440
+ `shippingszn failed: ${err instanceof Error ? err.message : String(err)}
2441
+ `
2442
+ );
2443
+ process2.exit(2);
2444
+ }
2445
+ );