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.
- package/README.md +30 -1
- package/dist/index.js +332 -73
- 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": "[](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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
1351
|
-
if (!
|
|
1352
|
-
return
|
|
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 `[})](${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:
|
|
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((
|
|
1383
|
-
severity:
|
|
1384
|
-
title: clampText(
|
|
1385
|
-
body: clampText(
|
|
1386
|
-
whatFailed: clampText(
|
|
1387
|
-
whyItBlocksLaunch: clampText(
|
|
1388
|
-
fixInstructions: clampText(
|
|
1389
|
-
aiBuilderPrompt: clampText(
|
|
1390
|
-
evidence: clampText(
|
|
1391
|
-
confidence:
|
|
1392
|
-
fixPrompt: clampText(
|
|
1393
|
-
verify: clampText(
|
|
1394
|
-
verificationStep: clampText(
|
|
1395
|
-
...locationFromFinding(
|
|
1396
|
-
permalink:
|
|
1397
|
-
itemTitle: clampText(
|
|
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(
|
|
1519
|
-
if (!
|
|
1520
|
-
const line =
|
|
1521
|
-
return `Likely location: ${
|
|
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 ??
|
|
1537
|
-
const location = locationForPrompt(
|
|
1538
|
-
const intent = SEVERITY_INTENT[
|
|
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: ${
|
|
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[
|
|
1801
|
+
escalation: ESCALATION[finding2.severity],
|
|
1543
1802
|
proofNextStep: proofStepForSeverity(
|
|
1544
|
-
|
|
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
|
|
1648
|
-
counts[canonicalSeverity(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1841
|
-
if (
|
|
1842
|
-
if (!
|
|
1843
|
-
return
|
|
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(
|
|
1860
|
-
const severity = canonicalSeverity(
|
|
2118
|
+
function normalizeLaunchFinding(finding2, source = "manual") {
|
|
2119
|
+
const severity = canonicalSeverity(finding2.severity);
|
|
1861
2120
|
const title = clampText2(
|
|
1862
|
-
|
|
2121
|
+
finding2.title ?? finding2.itemTitle,
|
|
1863
2122
|
"Launch-readiness finding",
|
|
1864
2123
|
160
|
|
1865
2124
|
);
|
|
1866
2125
|
const body = clampText2(
|
|
1867
|
-
|
|
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(
|
|
2130
|
+
const whatFailed = clampText2(finding2.whatFailed, body, 3e3);
|
|
1872
2131
|
const whyItBlocksLaunch = clampText2(
|
|
1873
|
-
|
|
2132
|
+
finding2.whyItBlocksLaunch,
|
|
1874
2133
|
body || defaultWhy(severity, source),
|
|
1875
2134
|
3e3
|
|
1876
2135
|
);
|
|
1877
2136
|
const fixInstructions = clampText2(
|
|
1878
|
-
|
|
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
|
-
|
|
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(
|
|
2146
|
+
const location = locationLine(finding2);
|
|
1888
2147
|
const aiBuilderPrompt = clampText2(
|
|
1889
|
-
|
|
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
|
|
1902
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
1926
|
-
...
|
|
1927
|
-
...
|
|
1928
|
-
...
|
|
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((
|
|
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((
|
|
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
|
-
(
|
|
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 =
|
|
2084
|
-
else if (!a.startsWith("-")) opts.cwd =
|
|
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