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 +2391 -324
- package/package.json +3 -2
- package/dist/checks/dangerous.js +0 -74
- package/dist/checks/env.js +0 -59
- package/dist/checks/headers.js +0 -116
- package/dist/checks/helpers.js +0 -179
- package/dist/checks/index.js +0 -36
- package/dist/checks/language.js +0 -275
- package/dist/checks/public-assets.js +0 -145
- package/dist/checks/quality.js +0 -45
- package/dist/checks/secrets.js +0 -271
- package/dist/checks/types.js +0 -1
- package/dist/checks.js +0 -8
- package/dist/items.js +0 -56
- package/dist/proof.js +0 -161
- package/dist/publish.js +0 -125
- package/dist/remediation.js +0 -36
- package/dist/scan.js +0 -230
package/dist/index.js
CHANGED
|
@@ -1,104 +1,2114 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 `[})](${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
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
2141
|
+
return process2.env.GITHUB_ACTIONS === "true" ? "github" : "cli";
|
|
138
2142
|
}
|
|
139
2143
|
async function run() {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
2276
|
+
}
|
|
2277
|
+
let publishResult = "skipped";
|
|
2278
|
+
try {
|
|
2279
|
+
if (opts.publish) {
|
|
2280
|
+
process2.env["SHIPPINGSZN_PUBLISH"] = "1";
|
|
335
2281
|
}
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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(
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
);
|