shippingszn 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -1
  2. package/dist/index.js +332 -73
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ fields:
36
36
  "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.",
37
37
  "proofUrl": "https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123",
38
38
  "proofResultId": "00000000-0000-4000-8000-000000000123",
39
- "badgeMarkdown": "[![Launch readiness proof: 70%](https://shippingszn.com/api/badge.svg?...)](https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123)",
39
+ "badgeMarkdown": "[![shippingszn scan proof: 70%](https://shippingszn.com/api/badge.svg?...)](https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123)",
40
40
  "wallUrl": "https://shippingszn.com/wall",
41
41
  "reportUrl": "https://shippingszn.com/report?scanResultId=00000000-0000-4000-8000-000000000123"
42
42
  },
@@ -93,6 +93,9 @@ maps back to one of the items on the checklist.
93
93
  - Missing security-header middleware in common server configs.
94
94
  - Dangerous code patterns: unsafe HTML injection in React, runtime
95
95
  code-execution calls, wildcard CORS.
96
+ - OTP/auth readiness signals: phone normalization, resend/cooldown behavior,
97
+ anti-enumeration copy, mobile one-time-code input, delivery-smoke evidence,
98
+ recovery paths, and paid report access that depends on OTP.
96
99
  - Python: common debug-mode slip-ups, hardcoded framework secrets, missing
97
100
  env-var loading.
98
101
  - Ruby: unsafe string rendering, hardcoded Rails secrets.
@@ -104,6 +107,32 @@ maps back to one of the items on the checklist.
104
107
  Each finding is tagged Critical, High, Medium, or Lower and links back to the
105
108
  relevant checklist item on shippingszn.com.
106
109
 
110
+ ## Suppressing false positives
111
+
112
+ Two opt-out mechanisms, both off by default:
113
+
114
+ - **`.gitignore` is respected.** Files your repo gitignores (build output,
115
+ generated reports, local `.env`, vendored sub-projects) are skipped.
116
+ This works automatically inside any git repo; outside a git repo the
117
+ scanner falls back to walking the full directory.
118
+ - **Inline ignore markers.** For one-off cases where a file legitimately
119
+ contains a pattern the scanner detects (a regex literal, copy that
120
+ describes a placeholder, a test fixture), add one of:
121
+
122
+ ```ts
123
+ // shippingszn:ignore — placed on the same line as the match
124
+ const x = "lorem ipsum"; // shippingszn:ignore — fixture text
125
+
126
+ // shippingszn:ignore-next-line — placed on the line above the match
127
+ // shippingszn:ignore-next-line
128
+ const greeting = "hello placeholder";
129
+ ```
130
+
131
+ Markers apply to substring/regex checks (placeholder content, dangerous
132
+ patterns, language patterns). They deliberately do **not** apply to
133
+ hardcoded-secret detection — false positives there should be addressed
134
+ by removing the secret pattern, not by allowlisting.
135
+
107
136
  ## What does NOT get checked
108
137
 
109
138
  These are deliberately out of scope for v1:
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as path11 from "node:path";
4
+ import * as path12 from "node:path";
5
5
  import * as process2 from "node:process";
6
6
  import { createRequire } from "node:module";
7
7
 
@@ -246,6 +246,7 @@ var PATTERN_DEFINITION_FILES = /* @__PURE__ */ new Set([
246
246
  "tools/cli/src/checks/dangerous.ts",
247
247
  "tools/cli/src/checks/quality.ts",
248
248
  "tools/cli/src/checks/language.ts",
249
+ "tools/cli/src/checks/otp-auth.ts",
249
250
  "tools/cli/README.md",
250
251
  // Test fixture for the redaction module: contains intentional fake
251
252
  // secret patterns whose whole purpose is to verify the redactor scrubs
@@ -1164,6 +1165,258 @@ async function checkPlaceholderContent(ctx) {
1164
1165
  return findings.concat(placeholderHits.slice(0, 25));
1165
1166
  }
1166
1167
 
1168
+ // src/checks/otp-auth.ts
1169
+ import * as path9 from "node:path";
1170
+ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1171
+ ".ts",
1172
+ ".tsx",
1173
+ ".js",
1174
+ ".jsx",
1175
+ ".mjs",
1176
+ ".cjs",
1177
+ ".md",
1178
+ ".mdx",
1179
+ ".html",
1180
+ ".htm",
1181
+ ".vue",
1182
+ ".svelte",
1183
+ ".astro",
1184
+ ".py",
1185
+ ".rb",
1186
+ ".go",
1187
+ ".php",
1188
+ ".java",
1189
+ ".kt",
1190
+ ".swift",
1191
+ ".cs"
1192
+ ]);
1193
+ var OTP_CONTEXT = /\b(otp|one[-\s]?time(?: password| code)?|verification code|verify code|login code|magic code|passcode|2fa|mfa|two[-\s]?factor)\b/i;
1194
+ var AUTH_CONTEXT = /\b(auth|sign[-\s]?in|signin|login|session|protected route|private route)\b/i;
1195
+ var SMS_OR_PHONE_CONTEXT = /\b(sms|text message|twilio|phone|mobile number|mobile phone|PhoneInput)\b/i;
1196
+ var PAID_REPORT_CONTEXT = /\b(paid report|report access|checkout handoff|checkoutHandoff|Stripe checkout|checkout email|checkout phone|checkout mobile|retrieve your report|account\/purchases|report session)\b/i;
1197
+ var FRONTEND_PATH_CONTEXT = /(^|\/)(login|signin|sign-in|auth|account|report|checkout)[^/]*\.(tsx|jsx|html|vue|svelte|astro)$/i;
1198
+ var PHONE_NORMALIZATION = /\b(normalizePhone|libphonenumber|parsePhoneNumber|isValidPhoneNumber|PhoneInput|E\.164|e164|react-phone-number-input|AsYouType)\b/i;
1199
+ var RESEND_BEHAVIOR = /\b(resend|send another|send a new code|request another|retry-after|try again in|wait a minute|cooldown|backoff)\b/i;
1200
+ var RATE_LIMIT = /\b(rateLimit|rate limit|Too many requests|retry-after|429|throttl|attempt limit|cooldown)\b/i;
1201
+ var ANTI_ENUMERATION = /\b(anti[-\s]?enumeration|success[-\s]?shaped|generic response|do not reveal|without revealing|hasPaidPurchase|no paid purchase|paid purchases|return\s+\{?\s*ok:\s*true|res\.json\(\s*\{\s*ok:\s*true)\b/i;
1202
+ var ENUMERATION_LEAK = /\b(?:user|account|email|phone|checkout|purchase|report)\s+(?:not\s+found|does\s+not\s+exist|not\s+recognized|not\s+registered|has no paid|has no purchase|not paid)|\bno\s+(?:account|user|purchase|paid checkout)\b/i;
1203
+ var MOBILE_OTP_INPUT = /\b(inputMode|inputmode|one-time-code|autocomplete=["']one-time-code|maxLength\s*=\s*\{?\s*6|type=["']tel|pattern=["'][^"']*\\d|InputOTP|numeric)\b/i;
1204
+ var CLEAR_COPY = /\b(6[-\s]?digit|six[-\s]?digit|verification code|one[-\s]?time code|code expires|expires? in|latest code|checkout email|checkout mobile|same email|same mobile|SMS code|Email code|Stripe receipt)\b/i;
1205
+ var RECOVERY_PATH = /\b(support|receipt|fallback|email fallback|alternate channel|try email|try sms|contact us|restart checkout|fulfillment|purchase history|account\/purchases|returnTo|different contact)\b/i;
1206
+ var DELIVERY_VERIFICATION = /\b(deliverability|delivery|delivered|arrives?|smoke|gateway|Twilio Verify|Email OTP Gateway|verifyGateway|requestGateway|mail[-\s]?tester|SPF|DKIM|DMARC|branded)\b/i;
1207
+ function shouldScan(file) {
1208
+ if (!isTextFile(file)) return false;
1209
+ if (isScanExempt(file.relPath)) return false;
1210
+ const ext = path9.extname(file.relPath).toLowerCase();
1211
+ return SCANNABLE_EXTENSIONS.has(ext);
1212
+ }
1213
+ function firstHit(file, content, regex) {
1214
+ const match = regex.exec(content);
1215
+ if (!match) return void 0;
1216
+ if (lineContainsIgnoreMarker(content, match.index)) return void 0;
1217
+ return {
1218
+ file: relPosix(file.relPath),
1219
+ line: findLine(content, match.index),
1220
+ text: match[0]
1221
+ };
1222
+ }
1223
+ function pick(existing, next) {
1224
+ return existing ?? next;
1225
+ }
1226
+ function evidence(hit, fallback) {
1227
+ if (!hit) return fallback;
1228
+ return `${hit.file}:${hit.line} matched "${hit.text}".`;
1229
+ }
1230
+ function finding(input) {
1231
+ return {
1232
+ checkId: input.checkId,
1233
+ itemId: "secure-auth",
1234
+ severity: input.severity,
1235
+ message: input.message,
1236
+ evidence: input.evidence,
1237
+ ...input.hit ? { file: input.hit.file, line: input.hit.line } : {}
1238
+ };
1239
+ }
1240
+ async function collectOtpAuthSignals(ctx) {
1241
+ const signals = {
1242
+ hasOtp: false,
1243
+ hasSmsOrPhone: false,
1244
+ hasPaidReportOtp: false,
1245
+ hasFrontendOtp: false
1246
+ };
1247
+ for (const file of ctx.files) {
1248
+ if (!shouldScan(file)) continue;
1249
+ const content = await readFileSafe(file);
1250
+ if (!content) continue;
1251
+ const rel = relPosix(file.relPath);
1252
+ const otpHit = firstHit(file, content, OTP_CONTEXT);
1253
+ const authHit = firstHit(file, content, AUTH_CONTEXT);
1254
+ const smsHit = firstHit(file, content, SMS_OR_PHONE_CONTEXT);
1255
+ const paidHit = firstHit(file, content, PAID_REPORT_CONTEXT);
1256
+ const frontendContext = FRONTEND_PATH_CONTEXT.test(rel);
1257
+ signals.hasOtp = signals.hasOtp || Boolean(otpHit);
1258
+ signals.hasSmsOrPhone = signals.hasSmsOrPhone || Boolean(smsHit);
1259
+ signals.hasPaidReportOtp = signals.hasPaidReportOtp || Boolean(paidHit && (otpHit || authHit));
1260
+ signals.hasFrontendOtp = signals.hasFrontendOtp || Boolean(frontendContext && (otpHit || authHit));
1261
+ signals.contextHit = pick(
1262
+ signals.contextHit,
1263
+ otpHit ?? paidHit ?? authHit ?? smsHit
1264
+ );
1265
+ signals.phoneNormalization = pick(
1266
+ signals.phoneNormalization,
1267
+ firstHit(file, content, PHONE_NORMALIZATION)
1268
+ );
1269
+ signals.resendBehavior = pick(
1270
+ signals.resendBehavior,
1271
+ firstHit(file, content, RESEND_BEHAVIOR)
1272
+ );
1273
+ signals.rateLimit = pick(signals.rateLimit, firstHit(file, content, RATE_LIMIT));
1274
+ signals.antiEnumeration = pick(
1275
+ signals.antiEnumeration,
1276
+ firstHit(file, content, ANTI_ENUMERATION)
1277
+ );
1278
+ signals.enumerationLeak = pick(
1279
+ signals.enumerationLeak,
1280
+ firstHit(file, content, ENUMERATION_LEAK)
1281
+ );
1282
+ signals.mobileOtpInput = pick(
1283
+ signals.mobileOtpInput,
1284
+ firstHit(file, content, MOBILE_OTP_INPUT)
1285
+ );
1286
+ signals.clearCopy = pick(
1287
+ signals.clearCopy,
1288
+ firstHit(file, content, CLEAR_COPY)
1289
+ );
1290
+ signals.recoveryPath = pick(
1291
+ signals.recoveryPath,
1292
+ firstHit(file, content, RECOVERY_PATH)
1293
+ );
1294
+ signals.deliveryVerification = pick(
1295
+ signals.deliveryVerification,
1296
+ firstHit(file, content, DELIVERY_VERIFICATION)
1297
+ );
1298
+ }
1299
+ return signals;
1300
+ }
1301
+ async function checkOtpAuthReadiness(ctx) {
1302
+ const signals = await collectOtpAuthSignals(ctx);
1303
+ if (!signals.hasOtp && !signals.hasPaidReportOtp) return [];
1304
+ const findings = [];
1305
+ if (signals.hasSmsOrPhone && !signals.phoneNormalization) {
1306
+ findings.push(
1307
+ finding({
1308
+ checkId: "otp-phone-normalization-missing",
1309
+ severity: "high",
1310
+ hit: signals.contextHit,
1311
+ message: "OTP/auth flow mentions SMS or phone numbers but no phone normalization signal was found. Normalize to E.164 before lookup, delivery, and paid report access.",
1312
+ evidence: evidence(
1313
+ signals.contextHit,
1314
+ "OTP/SMS context was found without a phone normalization signal."
1315
+ )
1316
+ })
1317
+ );
1318
+ }
1319
+ if (signals.hasOtp && !signals.rateLimit) {
1320
+ findings.push(
1321
+ finding({
1322
+ checkId: "otp-rate-limit-missing",
1323
+ severity: "high",
1324
+ hit: signals.contextHit,
1325
+ message: "OTP/auth flow does not show a rate limit, retry-after, or throttling signal. Launching without this invites brute-force code guessing and SMS/email abuse.",
1326
+ evidence: evidence(
1327
+ signals.contextHit,
1328
+ "OTP context was found without a rate-limit signal."
1329
+ )
1330
+ })
1331
+ );
1332
+ }
1333
+ if (signals.enumerationLeak && !signals.antiEnumeration) {
1334
+ findings.push(
1335
+ finding({
1336
+ checkId: "otp-enumeration-leak",
1337
+ severity: "high",
1338
+ hit: signals.enumerationLeak,
1339
+ message: "OTP/auth copy appears to reveal whether a user, purchase, or report exists. Use success-shaped start responses and generic failure copy before launch.",
1340
+ evidence: evidence(
1341
+ signals.enumerationLeak,
1342
+ "Potential enumeration copy was found."
1343
+ )
1344
+ })
1345
+ );
1346
+ }
1347
+ if (signals.hasOtp && !signals.resendBehavior) {
1348
+ findings.push(
1349
+ finding({
1350
+ checkId: "otp-resend-behavior-missing",
1351
+ severity: "medium",
1352
+ hit: signals.contextHit,
1353
+ message: "OTP/auth flow does not show resend, cooldown, or retry copy. Users need a clear path when an email/SMS code is delayed.",
1354
+ evidence: evidence(
1355
+ signals.contextHit,
1356
+ "OTP context was found without resend or cooldown evidence."
1357
+ )
1358
+ })
1359
+ );
1360
+ }
1361
+ if (signals.hasFrontendOtp && !signals.mobileOtpInput) {
1362
+ findings.push(
1363
+ finding({
1364
+ checkId: "otp-mobile-input-missing",
1365
+ severity: "medium",
1366
+ hit: signals.contextHit,
1367
+ message: "Frontend OTP/auth flow does not show mobile-friendly one-time-code input behavior. Use six numeric slots or inputMode/autocomplete support before launch.",
1368
+ evidence: evidence(
1369
+ signals.contextHit,
1370
+ "Frontend OTP context was found without mobile one-time-code input evidence."
1371
+ )
1372
+ })
1373
+ );
1374
+ }
1375
+ if (signals.hasOtp && !signals.clearCopy) {
1376
+ findings.push(
1377
+ finding({
1378
+ checkId: "otp-copy-clarity-missing",
1379
+ severity: "medium",
1380
+ hit: signals.contextHit,
1381
+ message: "OTP/auth flow does not show clear code copy. Say which contact receives the code, the expected code format, and what to do if the latest code fails.",
1382
+ evidence: evidence(
1383
+ signals.contextHit,
1384
+ "OTP context was found without clear verification-code copy."
1385
+ )
1386
+ })
1387
+ );
1388
+ }
1389
+ if (signals.hasPaidReportOtp && !signals.recoveryPath) {
1390
+ findings.push(
1391
+ finding({
1392
+ checkId: "otp-paid-report-recovery-missing",
1393
+ severity: "high",
1394
+ hit: signals.contextHit,
1395
+ message: "Paid report access appears to depend on OTP, but no recovery path was found. Add alternate contact, receipt/support, or purchase-history recovery before launch.",
1396
+ evidence: evidence(
1397
+ signals.contextHit,
1398
+ "Paid report OTP context was found without recovery-path evidence."
1399
+ )
1400
+ })
1401
+ );
1402
+ }
1403
+ if (signals.hasOtp && !signals.deliveryVerification) {
1404
+ findings.push(
1405
+ finding({
1406
+ checkId: "otp-delivery-proof-missing",
1407
+ severity: "medium",
1408
+ hit: signals.contextHit,
1409
+ message: "OTP/auth flow does not show delivery verification evidence. Treat gateway success as a start signal, then smoke a real delivered email/SMS code in the target environment.",
1410
+ evidence: evidence(
1411
+ signals.contextHit,
1412
+ "OTP context was found without delivery smoke or gateway evidence."
1413
+ )
1414
+ })
1415
+ );
1416
+ }
1417
+ return findings;
1418
+ }
1419
+
1167
1420
  // src/checks/index.ts
1168
1421
  var ALL_CHECKS = [
1169
1422
  { id: "hardcoded-secrets", run: checkHardcodedSecrets },
@@ -1181,7 +1434,8 @@ var ALL_CHECKS = [
1181
1434
  { id: "language-patterns", run: checkLanguagePatterns },
1182
1435
  { id: "python-secret-key-env", run: checkPythonSecretKeyEnv },
1183
1436
  { id: "ruby-secret-key-base-env", run: checkRubySecretKeyBaseEnv },
1184
- { id: "placeholder-content", run: checkPlaceholderContent }
1437
+ { id: "placeholder-content", run: checkPlaceholderContent },
1438
+ { id: "otp-auth-readiness", run: checkOtpAuthReadiness }
1185
1439
  ];
1186
1440
 
1187
1441
  // src/items.ts
@@ -1206,6 +1460,11 @@ var CHECKLIST_ITEMS = {
1206
1460
  title: "Keep your test data away from real users",
1207
1461
  priority: "critical"
1208
1462
  },
1463
+ "secure-auth": {
1464
+ id: "secure-auth",
1465
+ title: "Prove auth, OTP, and report access before launch",
1466
+ priority: "critical"
1467
+ },
1209
1468
  github: {
1210
1469
  id: "github",
1211
1470
  title: "Get your code into GitHub safely",
@@ -1244,7 +1503,7 @@ function permalinkFor(itemId, baseUrl) {
1244
1503
 
1245
1504
  // src/publish.ts
1246
1505
  import { promises as fs2 } from "node:fs";
1247
- import * as path9 from "node:path";
1506
+ import * as path10 from "node:path";
1248
1507
  var DEFAULT_BASE_URL = "https://shippingszn.com";
1249
1508
  var PUBLISH_TIMEOUT_MS = 3e3;
1250
1509
  function shouldPublish() {
@@ -1254,7 +1513,7 @@ function shouldPublish() {
1254
1513
  async function detectStack(cwd2) {
1255
1514
  const tags = /* @__PURE__ */ new Set();
1256
1515
  try {
1257
- const raw = await fs2.readFile(path9.join(cwd2, "package.json"), "utf8");
1516
+ const raw = await fs2.readFile(path10.join(cwd2, "package.json"), "utf8");
1258
1517
  const pkg = JSON.parse(raw);
1259
1518
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1260
1519
  const has = (n) => n in deps;
@@ -1289,7 +1548,7 @@ async function detectStack(cwd2) {
1289
1548
  ];
1290
1549
  for (const [file, tag] of checks) {
1291
1550
  try {
1292
- await fs2.access(path9.join(cwd2, file));
1551
+ await fs2.access(path10.join(cwd2, file));
1293
1552
  tags.add(tag);
1294
1553
  } catch {
1295
1554
  }
@@ -1336,7 +1595,7 @@ async function publishScan(totals, filesScanned, opts) {
1336
1595
  }
1337
1596
 
1338
1597
  // src/proof.ts
1339
- import * as path10 from "node:path";
1598
+ import * as path11 from "node:path";
1340
1599
  var PROOF_TIMEOUT_MS = 5e3;
1341
1600
  var MAX_FINDINGS = 100;
1342
1601
  function shouldUploadProof() {
@@ -1347,9 +1606,9 @@ function clampText(value, max, fallback = "") {
1347
1606
  const text = value ?? fallback;
1348
1607
  return text.length > max ? text.slice(0, max - 3) + "..." : text;
1349
1608
  }
1350
- function locationFromFinding(finding) {
1351
- if (!finding.file) return void 0;
1352
- return finding.line ? `${finding.file}:${finding.line}` : finding.file;
1609
+ function locationFromFinding(finding2) {
1610
+ if (!finding2.file) return void 0;
1611
+ return finding2.line ? `${finding2.file}:${finding2.line}` : finding2.file;
1353
1612
  }
1354
1613
  function buildBadgeMarkdown(baseUrl, id, score) {
1355
1614
  const params = new URLSearchParams({
@@ -1357,7 +1616,7 @@ function buildBadgeMarkdown(baseUrl, id, score) {
1357
1616
  theme: "dark"
1358
1617
  });
1359
1618
  const proofUrl = `${baseUrl}/proof/${encodeURIComponent(id)}`;
1360
- return `[![Launch readiness proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
1619
+ return `[![shippingszn scan proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
1361
1620
  }
1362
1621
  function buildProofPayload(report, scannerVersion) {
1363
1622
  const counts = {
@@ -1370,7 +1629,7 @@ function buildProofPayload(report, scannerVersion) {
1370
1629
  version: 1,
1371
1630
  source: report.source ?? "cli",
1372
1631
  scanner: "shippingszn",
1373
- targetName: path10.basename(report.cwd) || "CLI scan",
1632
+ targetName: path11.basename(report.cwd) || "CLI scan",
1374
1633
  score: report.launchReadiness.score,
1375
1634
  label: report.launchReadiness.label,
1376
1635
  decision: report.launchReadiness.decision,
@@ -1379,22 +1638,22 @@ function buildProofPayload(report, scannerVersion) {
1379
1638
  confidence: report.launchReadiness.confidence,
1380
1639
  checkedAt: report.generatedAt,
1381
1640
  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)
1641
+ findings: report.findings.slice(0, MAX_FINDINGS).map((finding2) => ({
1642
+ severity: finding2.severity,
1643
+ title: clampText(finding2.itemTitle || finding2.checkId, 160),
1644
+ body: clampText(finding2.message, 3e3),
1645
+ whatFailed: clampText(finding2.whatFailed, 3e3),
1646
+ whyItBlocksLaunch: clampText(finding2.whyItBlocksLaunch, 3e3),
1647
+ fixInstructions: clampText(finding2.fixInstructions, 4e3),
1648
+ aiBuilderPrompt: clampText(finding2.aiBuilderPrompt, 4e3),
1649
+ evidence: clampText(finding2.evidence, 3e3),
1650
+ confidence: finding2.confidence,
1651
+ fixPrompt: clampText(finding2.aiBuilderPrompt, 4e3),
1652
+ verify: clampText(finding2.verificationStep, 3e3),
1653
+ verificationStep: clampText(finding2.verificationStep, 3e3),
1654
+ ...locationFromFinding(finding2) ? { location: clampText(locationFromFinding(finding2), 500) } : {},
1655
+ permalink: finding2.permalink,
1656
+ itemTitle: clampText(finding2.itemTitle, 160)
1398
1657
  })),
1399
1658
  filesScanned: report.filesScanned,
1400
1659
  topNextStep: clampText(report.launchReadiness.topNextStep, 1e3),
@@ -1515,10 +1774,10 @@ var ESCALATION = {
1515
1774
  medium: "Escalate only if the fix changes routing, indexing, analytics, or public launch messaging.",
1516
1775
  lower: "Escalation is usually unnecessary unless this blocks installability, branding, or a promised launch surface."
1517
1776
  };
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}.`;
1777
+ function locationForPrompt(finding2) {
1778
+ if (!finding2.file) return "No specific file was attached to the finding.";
1779
+ const line = finding2.line ? `:${finding2.line}` : "";
1780
+ return `Likely location: ${finding2.file}${line}.`;
1522
1781
  }
1523
1782
  function proofStepForSeverity(severity, proofCreatePath, reportUrl) {
1524
1783
  const proofStep = `After the fix verifies clean, run npx shippingszn --json and paste or upload the JSON at ${proofCreatePath} to create launch proof.`;
@@ -1528,20 +1787,20 @@ function proofStepForSeverity(severity, proofCreatePath, reportUrl) {
1528
1787
  return proofStep;
1529
1788
  }
1530
1789
  function buildRemediationPrompt({
1531
- finding,
1790
+ finding: finding2,
1532
1791
  item,
1533
1792
  proofCreatePath,
1534
1793
  reportUrl
1535
1794
  }) {
1536
- const itemTitle = item?.title ?? finding.itemId;
1537
- const location = locationForPrompt(finding);
1538
- const intent = SEVERITY_INTENT[finding.severity];
1795
+ const itemTitle = item?.title ?? finding2.itemId;
1796
+ const location = locationForPrompt(finding2);
1797
+ const intent = SEVERITY_INTENT[finding2.severity];
1539
1798
  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.`,
1799
+ fixPrompt: `You are fixing a shippingszn launch-readiness finding. Severity: ${finding2.severity}. Checklist area: ${itemTitle}. Finding: ${finding2.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
1800
  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],
1801
+ escalation: ESCALATION[finding2.severity],
1543
1802
  proofNextStep: proofStepForSeverity(
1544
- finding.severity,
1803
+ finding2.severity,
1545
1804
  proofCreatePath,
1546
1805
  reportUrl
1547
1806
  )
@@ -1599,7 +1858,7 @@ var COVERAGE_LABELS = {
1599
1858
  public_surface: "Public launch surface",
1600
1859
  repo_static: "Repository static scan",
1601
1860
  secrets: "Secrets and config exposure",
1602
- auth: "Auth and private surfaces",
1861
+ auth: "Auth, OTP, and private surfaces",
1603
1862
  paid_api: "Paid API and abuse risk",
1604
1863
  deployment: "Deployment and runtime config",
1605
1864
  content: "Launch content and metadata",
@@ -1644,8 +1903,8 @@ function normalizeLaunchCounts(counts) {
1644
1903
  }
1645
1904
  function countLaunchFindings(findings) {
1646
1905
  const counts = emptyLaunchReadinessCounts();
1647
- for (const finding of findings) {
1648
- counts[canonicalSeverity(finding.severity)] += 1;
1906
+ for (const finding2 of findings) {
1907
+ counts[canonicalSeverity(finding2.severity)] += 1;
1649
1908
  }
1650
1909
  return counts;
1651
1910
  }
@@ -1663,12 +1922,12 @@ function sourceLabel(source) {
1663
1922
  if (source === "url") return "public URL scan";
1664
1923
  return "manual intake";
1665
1924
  }
1666
- function coverageArea(id, status, evidence, confidence) {
1925
+ function coverageArea(id, status, evidence2, confidence) {
1667
1926
  return {
1668
1927
  id,
1669
1928
  label: COVERAGE_LABELS[id],
1670
1929
  status,
1671
- evidence,
1930
+ evidence: evidence2,
1672
1931
  confidence: confidence ?? (status === "checked" ? "high" : status === "partial" ? "medium" : "low")
1673
1932
  };
1674
1933
  }
@@ -1694,7 +1953,7 @@ function coverageForSource(source) {
1694
1953
  coverageArea(
1695
1954
  "auth",
1696
1955
  "not_checked",
1697
- "Private routes and role guards cannot be proven from a public unauthenticated fetch."
1956
+ "Private routes, role guards, OTP delivery, phone normalization, resend behavior, and recovery paths cannot be proven from a public unauthenticated fetch."
1698
1957
  ),
1699
1958
  coverageArea(
1700
1959
  "paid_api",
@@ -1741,7 +2000,7 @@ function coverageForSource(source) {
1741
2000
  coverageArea(
1742
2001
  "auth",
1743
2002
  "partial",
1744
- "Static auth-risk signals were checked; runtime role behavior still needs verification."
2003
+ "Static auth and OTP-risk signals were checked; runtime role behavior and real email/SMS delivery still need verification."
1745
2004
  ),
1746
2005
  coverageArea(
1747
2006
  "paid_api",
@@ -1787,7 +2046,7 @@ function coverageForSource(source) {
1787
2046
  coverageArea(
1788
2047
  "auth",
1789
2048
  "partial",
1790
- "Manual evidence can mention auth risk; runtime verification is still required.",
2049
+ "Manual evidence can mention auth and OTP risk; runtime verification and delivered-code smoke are still required.",
1791
2050
  "low"
1792
2051
  ),
1793
2052
  coverageArea(
@@ -1837,10 +2096,10 @@ function defaultWhy(severity, source) {
1837
2096
  }
1838
2097
  return `This is lower-priority readiness polish, but it still belongs in the fix queue before the launch proof is treated as clean.`;
1839
2098
  }
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();
2099
+ function locationLine(finding2) {
2100
+ if (finding2.location?.trim()) return finding2.location.trim();
2101
+ if (!finding2.file?.trim()) return "";
2102
+ return finding2.line ? `${finding2.file.trim()}:${finding2.line}` : finding2.file.trim();
1844
2103
  }
1845
2104
  function defaultPrompt(input) {
1846
2105
  const where = input.location ? `Likely location: ${input.location}.` : "Inspect the relevant app, deploy, and configuration files before editing.";
@@ -1856,37 +2115,37 @@ function defaultPrompt(input) {
1856
2115
  "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
2116
  ].join(" ");
1858
2117
  }
1859
- function normalizeLaunchFinding(finding, source = "manual") {
1860
- const severity = canonicalSeverity(finding.severity);
2118
+ function normalizeLaunchFinding(finding2, source = "manual") {
2119
+ const severity = canonicalSeverity(finding2.severity);
1861
2120
  const title = clampText2(
1862
- finding.title ?? finding.itemTitle,
2121
+ finding2.title ?? finding2.itemTitle,
1863
2122
  "Launch-readiness finding",
1864
2123
  160
1865
2124
  );
1866
2125
  const body = clampText2(
1867
- finding.body ?? finding.message,
2126
+ finding2.body ?? finding2.message,
1868
2127
  "The scan flagged this as a launch-readiness risk.",
1869
2128
  3e3
1870
2129
  );
1871
- const whatFailed = clampText2(finding.whatFailed, body, 3e3);
2130
+ const whatFailed = clampText2(finding2.whatFailed, body, 3e3);
1872
2131
  const whyItBlocksLaunch = clampText2(
1873
- finding.whyItBlocksLaunch,
2132
+ finding2.whyItBlocksLaunch,
1874
2133
  body || defaultWhy(severity, source),
1875
2134
  3e3
1876
2135
  );
1877
2136
  const fixInstructions = clampText2(
1878
- finding.fixInstructions ?? finding.fixPrompt,
2137
+ finding2.fixInstructions ?? finding2.fixPrompt,
1879
2138
  `Fix the underlying ${severity} launch-readiness risk and keep the app behavior intact.`,
1880
2139
  4e3
1881
2140
  );
1882
2141
  const verificationStep = clampText2(
1883
- finding.verificationStep ?? finding.verify,
2142
+ finding2.verificationStep ?? finding2.verify,
1884
2143
  "Run the same scan again and confirm this finding is gone before launch.",
1885
2144
  3e3
1886
2145
  );
1887
- const location = locationLine(finding);
2146
+ const location = locationLine(finding2);
1888
2147
  const aiBuilderPrompt = clampText2(
1889
- finding.aiBuilderPrompt,
2148
+ finding2.aiBuilderPrompt,
1890
2149
  defaultPrompt({
1891
2150
  severity,
1892
2151
  title,
@@ -1898,13 +2157,13 @@ function normalizeLaunchFinding(finding, source = "manual") {
1898
2157
  }),
1899
2158
  4e3
1900
2159
  );
1901
- const evidence = clampText2(
1902
- finding.evidence,
2160
+ const evidence2 = clampText2(
2161
+ finding2.evidence,
1903
2162
  location ? `Observed at ${location}.` : body,
1904
2163
  3e3
1905
2164
  );
1906
2165
  const confidence = normalizeConfidence(
1907
- finding.confidence,
2166
+ finding2.confidence,
1908
2167
  confidenceForSource(source)
1909
2168
  );
1910
2169
  return {
@@ -1915,21 +2174,21 @@ function normalizeLaunchFinding(finding, source = "manual") {
1915
2174
  fixInstructions,
1916
2175
  aiBuilderPrompt,
1917
2176
  verificationStep,
1918
- evidence,
2177
+ evidence: evidence2,
1919
2178
  confidence,
1920
2179
  body,
1921
2180
  message: body,
1922
2181
  fixPrompt: aiBuilderPrompt,
1923
2182
  verify: verificationStep,
1924
2183
  ...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 } : {}
2184
+ ...finding2.file ? { file: finding2.file } : {},
2185
+ ...finding2.line ? { line: finding2.line } : {},
2186
+ ...finding2.permalink ? { permalink: finding2.permalink } : {},
2187
+ ...finding2.itemTitle ? { itemTitle: finding2.itemTitle } : {}
1929
2188
  };
1930
2189
  }
1931
2190
  function prioritizeLaunchFindings(findings, source = "manual") {
1932
- return findings.map((finding) => normalizeLaunchFinding(finding, source)).sort((a, b) => {
2191
+ return findings.map((finding2) => normalizeLaunchFinding(finding2, source)).sort((a, b) => {
1933
2192
  const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
1934
2193
  if (bySeverity !== 0) return bySeverity;
1935
2194
  return a.title.localeCompare(b.title);
@@ -1986,7 +2245,7 @@ function topNextStep(blockers, counts, source) {
1986
2245
  return "Attach this clean scan proof to the launch record and keep monitoring the first production traffic.";
1987
2246
  }
1988
2247
  function aggregateConfidence(source, findings) {
1989
- if (findings.some((finding) => finding.confidence === "low")) return "low";
2248
+ if (findings.some((finding2) => finding2.confidence === "low")) return "low";
1990
2249
  if (source === "url") return "medium";
1991
2250
  return "high";
1992
2251
  }
@@ -2003,7 +2262,7 @@ function assessLaunchReadiness(input) {
2003
2262
  const checkedAreas = coverage.filter((area) => area.status === "checked");
2004
2263
  const uncheckedAreas = coverage.filter((area) => area.status !== "checked");
2005
2264
  const blockers = findings.filter(
2006
- (finding) => finding.severity === "critical" || finding.severity === "high"
2265
+ (finding2) => finding2.severity === "critical" || finding2.severity === "high"
2007
2266
  );
2008
2267
  const decision = decisionText(band, counts, source);
2009
2268
  const reportRecommended = counts.critical > 0 || counts.high > 0 || coveragePenalty > 0;
@@ -2080,8 +2339,8 @@ function parseArgs(argv2) {
2080
2339
  else if (a === "--proof") opts.proof = true;
2081
2340
  else if (a === "--no-color") opts.noColor = true;
2082
2341
  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);
2342
+ else if (a === "--cwd") opts.cwd = path12.resolve(argv2[++i] ?? opts.cwd);
2343
+ else if (!a.startsWith("-")) opts.cwd = path12.resolve(a);
2085
2344
  }
2086
2345
  return opts;
2087
2346
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shippingszn",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Read-only CLI scanner that checks a project for common pre-launch issues from the shippingszn.com launch checklist.",
5
5
  "license": "MIT",
6
6
  "type": "module",