shippingszn 0.8.3 → 0.8.5
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 +59 -58
- package/dist/index.js +378 -155
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,13 +12,14 @@ pnpm dlx shippingszn
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
The CLI **never writes, modifies, or deletes** any files - it only reads.
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
By default it posts one anonymous Wall of Launches entry with score, severity
|
|
16
|
+
counts, files scanned, scanner version, and safe stack tags. It never uploads
|
|
17
|
+
source code, paths, filenames, repo URLs, project names, secrets, handles, or
|
|
18
|
+
emails. Use `--no-publish` for a private local run.
|
|
17
19
|
|
|
18
20
|
Human output starts with a Launch Readiness Score, scope covered, the top next
|
|
19
21
|
step to fix, and severity counts. Run with `--json` to get machine-readable
|
|
20
|
-
findings
|
|
21
|
-
fields:
|
|
22
|
+
findings plus the Wall/report handoff fields:
|
|
22
23
|
|
|
23
24
|
```json
|
|
24
25
|
{
|
|
@@ -32,24 +33,15 @@ fields:
|
|
|
32
33
|
"coveragePenalty": 0,
|
|
33
34
|
"coverageSummary": "CLI scan covered 4 readiness areas with 4 areas requiring owner verification.",
|
|
34
35
|
"topNextStep": "Fix Lock up your API keys and passwords: Possible OpenAI API key hardcoded in source.",
|
|
35
|
-
"proofCreatePath": "https://shippingszn.com/scan",
|
|
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
|
-
"proofUrl": "https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123",
|
|
38
|
-
"proofResultId": "00000000-0000-4000-8000-000000000123",
|
|
39
|
-
"badgeMarkdown": "[](https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123)",
|
|
40
36
|
"wallUrl": "https://shippingszn.com/wall",
|
|
41
|
-
"reportUrl": "https://shippingszn.com/report
|
|
37
|
+
"reportUrl": "https://shippingszn.com/report"
|
|
42
38
|
},
|
|
43
39
|
"findings": [
|
|
44
40
|
{
|
|
45
41
|
"severity": "critical",
|
|
46
42
|
"message": "Possible OpenAI API key hardcoded in source.",
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
"verify": "Re-run npx shippingszn --json and confirm this exact finding is gone...",
|
|
50
|
-
"escalation": "Escalate before launch if this touches secrets, auth, payments...",
|
|
51
|
-
"proofNextStep": "After the fix verifies clean, run npx shippingszn --json..."
|
|
52
|
-
}
|
|
43
|
+
"whatFailed": "A secret-like token appears in a scanned file.",
|
|
44
|
+
"whyItBlocksLaunch": "A leaked key can spend money or expose customer data before you notice."
|
|
53
45
|
}
|
|
54
46
|
]
|
|
55
47
|
}
|
|
@@ -59,26 +51,20 @@ When Critical or High findings make paid confidence relevant, the summary also
|
|
|
59
51
|
includes a `/report` link. Clean scans and lower-risk findings do not push the
|
|
60
52
|
paid report CTA.
|
|
61
53
|
|
|
62
|
-
##
|
|
54
|
+
## Wall publishing
|
|
63
55
|
|
|
64
|
-
|
|
56
|
+
Plain `npx shippingszn` feeds the Wall automatically. The public row is
|
|
57
|
+
anonymous and intentionally small: score, launch label, files scanned, finding
|
|
58
|
+
counts, detected stack tags, scanner version, and timestamp.
|
|
59
|
+
|
|
60
|
+
Use this when you want a completely private local run:
|
|
65
61
|
|
|
66
62
|
```bash
|
|
67
|
-
npx shippingszn --
|
|
63
|
+
npx shippingszn --no-publish
|
|
68
64
|
```
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`launchReadiness.proofResultId`, `launchReadiness.badgeMarkdown`,
|
|
73
|
-
`launchReadiness.wallUrl`, and `launchReadiness.reportUrl` to the JSON. It
|
|
74
|
-
also posts a proof-linked severity summary to the Wall of Launches. If proof
|
|
75
|
-
upload fails, findings still print and JSON includes
|
|
76
|
-
`launchReadiness.proofUploadError`; if the Wall publish fails, JSON includes
|
|
77
|
-
`launchReadiness.wallPublishError`.
|
|
78
|
-
|
|
79
|
-
For a manual handoff, you can still run `npx shippingszn --json >
|
|
80
|
-
shippingszn-scan.json` and paste or upload the file at
|
|
81
|
-
`https://shippingszn.com/scan`.
|
|
66
|
+
For a paid-report handoff, run `npx shippingszn --json > shippingszn-scan.json`
|
|
67
|
+
and paste or upload the file at `https://shippingszn.com/scan`.
|
|
82
68
|
|
|
83
69
|
## What gets checked
|
|
84
70
|
|
|
@@ -93,6 +79,9 @@ maps back to one of the items on the checklist.
|
|
|
93
79
|
- Missing security-header middleware in common server configs.
|
|
94
80
|
- Dangerous code patterns: unsafe HTML injection in React, runtime
|
|
95
81
|
code-execution calls, wildcard CORS.
|
|
82
|
+
- OTP/auth readiness signals: phone normalization, resend/cooldown behavior,
|
|
83
|
+
anti-enumeration copy, mobile one-time-code input, delivery-smoke evidence,
|
|
84
|
+
recovery paths, and paid report access that depends on OTP.
|
|
96
85
|
- Python: common debug-mode slip-ups, hardcoded framework secrets, missing
|
|
97
86
|
env-var loading.
|
|
98
87
|
- Ruby: unsafe string rendering, hardcoded Rails secrets.
|
|
@@ -104,6 +93,32 @@ maps back to one of the items on the checklist.
|
|
|
104
93
|
Each finding is tagged Critical, High, Medium, or Lower and links back to the
|
|
105
94
|
relevant checklist item on shippingszn.com.
|
|
106
95
|
|
|
96
|
+
## Suppressing false positives
|
|
97
|
+
|
|
98
|
+
Two opt-out mechanisms, both off by default:
|
|
99
|
+
|
|
100
|
+
- **`.gitignore` is respected.** Files your repo gitignores (build output,
|
|
101
|
+
generated reports, local `.env`, vendored sub-projects) are skipped.
|
|
102
|
+
This works automatically inside any git repo; outside a git repo the
|
|
103
|
+
scanner falls back to walking the full directory.
|
|
104
|
+
- **Inline ignore markers.** For one-off cases where a file legitimately
|
|
105
|
+
contains a pattern the scanner detects (a regex literal, copy that
|
|
106
|
+
describes a placeholder, a test fixture), add one of:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// shippingszn:ignore — placed on the same line as the match
|
|
110
|
+
const x = "lorem ipsum"; // shippingszn:ignore — fixture text
|
|
111
|
+
|
|
112
|
+
// shippingszn:ignore-next-line — placed on the line above the match
|
|
113
|
+
// shippingszn:ignore-next-line
|
|
114
|
+
const greeting = "hello placeholder";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Markers apply to substring/regex checks (placeholder content, dangerous
|
|
118
|
+
patterns, language patterns). They deliberately do **not** apply to
|
|
119
|
+
hardcoded-secret detection — false positives there should be addressed
|
|
120
|
+
by removing the secret pattern, not by allowlisting.
|
|
121
|
+
|
|
107
122
|
## What does NOT get checked
|
|
108
123
|
|
|
109
124
|
These are deliberately out of scope for v1:
|
|
@@ -127,12 +142,8 @@ shippingszn [path] [options]
|
|
|
127
142
|
|
|
128
143
|
Options:
|
|
129
144
|
--json Output a machine-readable JSON report.
|
|
130
|
-
--publish
|
|
131
|
-
|
|
132
|
-
filenames, or project names.
|
|
133
|
-
--proof Opt in to posting this scan to /api/scan-results and
|
|
134
|
-
printing a proof URL, Wall URL, report URL, and badge.
|
|
135
|
-
Can also be enabled with SHIPPINGSZN_PROOF=1.
|
|
145
|
+
--publish Legacy alias. Anonymous Wall stats publish by default.
|
|
146
|
+
--no-publish Disable the anonymous Wall post for this run.
|
|
136
147
|
--base-url <url> Base URL used to build links back to checklist items.
|
|
137
148
|
--cwd <path> Directory to scan. Default: current working directory.
|
|
138
149
|
--no-color Disable ANSI colors in the human-readable report.
|
|
@@ -150,32 +161,22 @@ This makes the CLI suitable for CI:
|
|
|
150
161
|
|
|
151
162
|
```yaml
|
|
152
163
|
# .github/workflows/launch-check.yml
|
|
153
|
-
- run: npx shippingszn --json
|
|
164
|
+
- run: npx shippingszn --json > launch-check.json
|
|
154
165
|
```
|
|
155
166
|
|
|
156
|
-
For PR scan
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`--publish` out of the workflow unless you explicitly want to publish an
|
|
160
|
-
anonymous Wall summary when proof upload fails.
|
|
167
|
+
For PR scan signal, have GitHub Actions run the scanner and post the JSON
|
|
168
|
+
summary as a comment: score, severity counts, top next step, Wall URL, report
|
|
169
|
+
URL, and worst Critical/High findings.
|
|
161
170
|
|
|
162
171
|
## Privacy
|
|
163
172
|
|
|
164
|
-
`shippingszn` reads files on your machine. It never uploads source code
|
|
165
|
-
makes
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
messages, remediation prompts, verification text, checklist permalinks,
|
|
172
|
-
severity counts, score, file count, target folder name, and scanner version. It
|
|
173
|
-
does not upload source code or secret values.
|
|
174
|
-
|
|
175
|
-
If you only want an anonymous Wall of Launches summary, pass `--publish`.
|
|
176
|
-
That opt-in posts files scanned count, finding counts by severity, detected
|
|
177
|
-
stack tags, and scanner version. It never uploads source code, file paths,
|
|
178
|
-
filenames, project names, secrets, or report contents.
|
|
173
|
+
`shippingszn` reads files on your machine. It never uploads source code. By
|
|
174
|
+
default it makes one best-effort outbound request to post anonymous Wall stats:
|
|
175
|
+
score, launch label, files scanned count, finding counts by severity, detected
|
|
176
|
+
stack tags, scanner version, and timestamp. It never uploads source code, file
|
|
177
|
+
paths, filenames, project names, repo URLs, secrets, emails, handles, or report
|
|
178
|
+
contents. Use `--no-publish` or `SHIPPINGSZN_DISABLE_PUBLISH=1` to disable that
|
|
179
|
+
request.
|
|
179
180
|
|
|
180
181
|
## License
|
|
181
182
|
|
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,17 +1503,13 @@ 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
|
-
function shouldPublish() {
|
|
1251
|
-
const v = process.env["SHIPPINGSZN_PUBLISH"] ?? "";
|
|
1252
|
-
return v === "1" || v.toLowerCase() === "true" || v === "yes";
|
|
1253
|
-
}
|
|
1254
1509
|
async function detectStack(cwd2) {
|
|
1255
1510
|
const tags = /* @__PURE__ */ new Set();
|
|
1256
1511
|
try {
|
|
1257
|
-
const raw = await fs2.readFile(
|
|
1512
|
+
const raw = await fs2.readFile(path10.join(cwd2, "package.json"), "utf8");
|
|
1258
1513
|
const pkg = JSON.parse(raw);
|
|
1259
1514
|
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
1260
1515
|
const has = (n) => n in deps;
|
|
@@ -1289,15 +1544,18 @@ async function detectStack(cwd2) {
|
|
|
1289
1544
|
];
|
|
1290
1545
|
for (const [file, tag] of checks) {
|
|
1291
1546
|
try {
|
|
1292
|
-
await fs2.access(
|
|
1547
|
+
await fs2.access(path10.join(cwd2, file));
|
|
1293
1548
|
tags.add(tag);
|
|
1294
1549
|
} catch {
|
|
1295
1550
|
}
|
|
1296
1551
|
}
|
|
1297
1552
|
return [...tags].slice(0, 12);
|
|
1298
1553
|
}
|
|
1299
|
-
function buildPayload(totals, filesScanned, stack, scannerVersion) {
|
|
1554
|
+
function buildPayload(totals, filesScanned, stack, scannerVersion, score, label) {
|
|
1300
1555
|
const out = {
|
|
1556
|
+
source: "cli",
|
|
1557
|
+
score,
|
|
1558
|
+
label,
|
|
1301
1559
|
filesScanned,
|
|
1302
1560
|
findingsCritical: totals.critical ?? 0,
|
|
1303
1561
|
findingsHigh: totals.high ?? 0,
|
|
@@ -1309,14 +1567,15 @@ function buildPayload(totals, filesScanned, stack, scannerVersion) {
|
|
|
1309
1567
|
return out;
|
|
1310
1568
|
}
|
|
1311
1569
|
async function publishScan(totals, filesScanned, opts) {
|
|
1312
|
-
if (!shouldPublish()) return "skipped";
|
|
1313
1570
|
const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
|
|
1314
1571
|
const stack = await detectStack(opts.cwd);
|
|
1315
1572
|
const payload = buildPayload(
|
|
1316
1573
|
totals,
|
|
1317
1574
|
filesScanned,
|
|
1318
1575
|
stack,
|
|
1319
|
-
opts.scannerVersion
|
|
1576
|
+
opts.scannerVersion,
|
|
1577
|
+
opts.score,
|
|
1578
|
+
opts.label
|
|
1320
1579
|
);
|
|
1321
1580
|
const controller = new AbortController();
|
|
1322
1581
|
const timer = setTimeout(() => controller.abort(), PUBLISH_TIMEOUT_MS);
|
|
@@ -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,17 @@ 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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
+
evidence: clampText(finding2.evidence, 3e3),
|
|
1648
|
+
confidence: finding2.confidence,
|
|
1649
|
+
...locationFromFinding(finding2) ? { location: clampText(locationFromFinding(finding2), 500) } : {},
|
|
1650
|
+
permalink: finding2.permalink,
|
|
1651
|
+
itemTitle: clampText(finding2.itemTitle, 160)
|
|
1398
1652
|
})),
|
|
1399
1653
|
filesScanned: report.filesScanned,
|
|
1400
1654
|
topNextStep: clampText(report.launchReadiness.topNextStep, 1e3),
|
|
@@ -1502,52 +1756,6 @@ async function uploadProof(report, opts) {
|
|
|
1502
1756
|
}
|
|
1503
1757
|
}
|
|
1504
1758
|
|
|
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
1759
|
// ../../lib/launch-readiness/src/index.ts
|
|
1552
1760
|
var SEVERITY_WEIGHTS = {
|
|
1553
1761
|
critical: 35,
|
|
@@ -1584,7 +1792,7 @@ var SCORE_BANDS = [
|
|
|
1584
1792
|
id: "launchable",
|
|
1585
1793
|
minScore: 90,
|
|
1586
1794
|
maxScore: 100,
|
|
1587
|
-
label: "Launchable
|
|
1795
|
+
label: "Launchable",
|
|
1588
1796
|
decision: "go",
|
|
1589
1797
|
goNoGoLabel: "Go"
|
|
1590
1798
|
}
|
|
@@ -1599,7 +1807,7 @@ var COVERAGE_LABELS = {
|
|
|
1599
1807
|
public_surface: "Public launch surface",
|
|
1600
1808
|
repo_static: "Repository static scan",
|
|
1601
1809
|
secrets: "Secrets and config exposure",
|
|
1602
|
-
auth: "Auth and private surfaces",
|
|
1810
|
+
auth: "Auth, OTP, and private surfaces",
|
|
1603
1811
|
paid_api: "Paid API and abuse risk",
|
|
1604
1812
|
deployment: "Deployment and runtime config",
|
|
1605
1813
|
content: "Launch content and metadata",
|
|
@@ -1644,8 +1852,8 @@ function normalizeLaunchCounts(counts) {
|
|
|
1644
1852
|
}
|
|
1645
1853
|
function countLaunchFindings(findings) {
|
|
1646
1854
|
const counts = emptyLaunchReadinessCounts();
|
|
1647
|
-
for (const
|
|
1648
|
-
counts[canonicalSeverity(
|
|
1855
|
+
for (const finding2 of findings) {
|
|
1856
|
+
counts[canonicalSeverity(finding2.severity)] += 1;
|
|
1649
1857
|
}
|
|
1650
1858
|
return counts;
|
|
1651
1859
|
}
|
|
@@ -1663,12 +1871,12 @@ function sourceLabel(source) {
|
|
|
1663
1871
|
if (source === "url") return "public URL scan";
|
|
1664
1872
|
return "manual intake";
|
|
1665
1873
|
}
|
|
1666
|
-
function coverageArea(id, status,
|
|
1874
|
+
function coverageArea(id, status, evidence2, confidence) {
|
|
1667
1875
|
return {
|
|
1668
1876
|
id,
|
|
1669
1877
|
label: COVERAGE_LABELS[id],
|
|
1670
1878
|
status,
|
|
1671
|
-
evidence,
|
|
1879
|
+
evidence: evidence2,
|
|
1672
1880
|
confidence: confidence ?? (status === "checked" ? "high" : status === "partial" ? "medium" : "low")
|
|
1673
1881
|
};
|
|
1674
1882
|
}
|
|
@@ -1694,7 +1902,7 @@ function coverageForSource(source) {
|
|
|
1694
1902
|
coverageArea(
|
|
1695
1903
|
"auth",
|
|
1696
1904
|
"not_checked",
|
|
1697
|
-
"Private routes
|
|
1905
|
+
"Private routes, role guards, OTP delivery, phone normalization, resend behavior, and recovery paths cannot be proven from a public unauthenticated fetch."
|
|
1698
1906
|
),
|
|
1699
1907
|
coverageArea(
|
|
1700
1908
|
"paid_api",
|
|
@@ -1741,7 +1949,7 @@ function coverageForSource(source) {
|
|
|
1741
1949
|
coverageArea(
|
|
1742
1950
|
"auth",
|
|
1743
1951
|
"partial",
|
|
1744
|
-
"Static auth-risk signals were checked; runtime role behavior still
|
|
1952
|
+
"Static auth and OTP-risk signals were checked; runtime role behavior and real email/SMS delivery still need verification."
|
|
1745
1953
|
),
|
|
1746
1954
|
coverageArea(
|
|
1747
1955
|
"paid_api",
|
|
@@ -1787,7 +1995,7 @@ function coverageForSource(source) {
|
|
|
1787
1995
|
coverageArea(
|
|
1788
1996
|
"auth",
|
|
1789
1997
|
"partial",
|
|
1790
|
-
"Manual evidence can mention auth risk; runtime verification
|
|
1998
|
+
"Manual evidence can mention auth and OTP risk; runtime verification and delivered-code smoke are still required.",
|
|
1791
1999
|
"low"
|
|
1792
2000
|
),
|
|
1793
2001
|
coverageArea(
|
|
@@ -1837,10 +2045,10 @@ function defaultWhy(severity, source) {
|
|
|
1837
2045
|
}
|
|
1838
2046
|
return `This is lower-priority readiness polish, but it still belongs in the fix queue before the launch proof is treated as clean.`;
|
|
1839
2047
|
}
|
|
1840
|
-
function locationLine(
|
|
1841
|
-
if (
|
|
1842
|
-
if (!
|
|
1843
|
-
return
|
|
2048
|
+
function locationLine(finding2) {
|
|
2049
|
+
if (finding2.location?.trim()) return finding2.location.trim();
|
|
2050
|
+
if (!finding2.file?.trim()) return "";
|
|
2051
|
+
return finding2.line ? `${finding2.file.trim()}:${finding2.line}` : finding2.file.trim();
|
|
1844
2052
|
}
|
|
1845
2053
|
function defaultPrompt(input) {
|
|
1846
2054
|
const where = input.location ? `Likely location: ${input.location}.` : "Inspect the relevant app, deploy, and configuration files before editing.";
|
|
@@ -1856,37 +2064,37 @@ function defaultPrompt(input) {
|
|
|
1856
2064
|
"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
2065
|
].join(" ");
|
|
1858
2066
|
}
|
|
1859
|
-
function normalizeLaunchFinding(
|
|
1860
|
-
const severity = canonicalSeverity(
|
|
2067
|
+
function normalizeLaunchFinding(finding2, source = "manual") {
|
|
2068
|
+
const severity = canonicalSeverity(finding2.severity);
|
|
1861
2069
|
const title = clampText2(
|
|
1862
|
-
|
|
2070
|
+
finding2.title ?? finding2.itemTitle,
|
|
1863
2071
|
"Launch-readiness finding",
|
|
1864
2072
|
160
|
|
1865
2073
|
);
|
|
1866
2074
|
const body = clampText2(
|
|
1867
|
-
|
|
2075
|
+
finding2.body ?? finding2.message,
|
|
1868
2076
|
"The scan flagged this as a launch-readiness risk.",
|
|
1869
2077
|
3e3
|
|
1870
2078
|
);
|
|
1871
|
-
const whatFailed = clampText2(
|
|
2079
|
+
const whatFailed = clampText2(finding2.whatFailed, body, 3e3);
|
|
1872
2080
|
const whyItBlocksLaunch = clampText2(
|
|
1873
|
-
|
|
2081
|
+
finding2.whyItBlocksLaunch,
|
|
1874
2082
|
body || defaultWhy(severity, source),
|
|
1875
2083
|
3e3
|
|
1876
2084
|
);
|
|
1877
2085
|
const fixInstructions = clampText2(
|
|
1878
|
-
|
|
2086
|
+
finding2.fixInstructions ?? finding2.fixPrompt,
|
|
1879
2087
|
`Fix the underlying ${severity} launch-readiness risk and keep the app behavior intact.`,
|
|
1880
2088
|
4e3
|
|
1881
2089
|
);
|
|
1882
2090
|
const verificationStep = clampText2(
|
|
1883
|
-
|
|
2091
|
+
finding2.verificationStep ?? finding2.verify,
|
|
1884
2092
|
"Run the same scan again and confirm this finding is gone before launch.",
|
|
1885
2093
|
3e3
|
|
1886
2094
|
);
|
|
1887
|
-
const location = locationLine(
|
|
2095
|
+
const location = locationLine(finding2);
|
|
1888
2096
|
const aiBuilderPrompt = clampText2(
|
|
1889
|
-
|
|
2097
|
+
finding2.aiBuilderPrompt,
|
|
1890
2098
|
defaultPrompt({
|
|
1891
2099
|
severity,
|
|
1892
2100
|
title,
|
|
@@ -1898,13 +2106,13 @@ function normalizeLaunchFinding(finding, source = "manual") {
|
|
|
1898
2106
|
}),
|
|
1899
2107
|
4e3
|
|
1900
2108
|
);
|
|
1901
|
-
const
|
|
1902
|
-
|
|
2109
|
+
const evidence2 = clampText2(
|
|
2110
|
+
finding2.evidence,
|
|
1903
2111
|
location ? `Observed at ${location}.` : body,
|
|
1904
2112
|
3e3
|
|
1905
2113
|
);
|
|
1906
2114
|
const confidence = normalizeConfidence(
|
|
1907
|
-
|
|
2115
|
+
finding2.confidence,
|
|
1908
2116
|
confidenceForSource(source)
|
|
1909
2117
|
);
|
|
1910
2118
|
return {
|
|
@@ -1915,21 +2123,21 @@ function normalizeLaunchFinding(finding, source = "manual") {
|
|
|
1915
2123
|
fixInstructions,
|
|
1916
2124
|
aiBuilderPrompt,
|
|
1917
2125
|
verificationStep,
|
|
1918
|
-
evidence,
|
|
2126
|
+
evidence: evidence2,
|
|
1919
2127
|
confidence,
|
|
1920
2128
|
body,
|
|
1921
2129
|
message: body,
|
|
1922
2130
|
fixPrompt: aiBuilderPrompt,
|
|
1923
2131
|
verify: verificationStep,
|
|
1924
2132
|
...location ? { location } : {},
|
|
1925
|
-
...
|
|
1926
|
-
...
|
|
1927
|
-
...
|
|
1928
|
-
...
|
|
2133
|
+
...finding2.file ? { file: finding2.file } : {},
|
|
2134
|
+
...finding2.line ? { line: finding2.line } : {},
|
|
2135
|
+
...finding2.permalink ? { permalink: finding2.permalink } : {},
|
|
2136
|
+
...finding2.itemTitle ? { itemTitle: finding2.itemTitle } : {}
|
|
1929
2137
|
};
|
|
1930
2138
|
}
|
|
1931
2139
|
function prioritizeLaunchFindings(findings, source = "manual") {
|
|
1932
|
-
return findings.map((
|
|
2140
|
+
return findings.map((finding2) => normalizeLaunchFinding(finding2, source)).sort((a, b) => {
|
|
1933
2141
|
const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
1934
2142
|
if (bySeverity !== 0) return bySeverity;
|
|
1935
2143
|
return a.title.localeCompare(b.title);
|
|
@@ -1965,12 +2173,12 @@ function decisionText(band, counts, source) {
|
|
|
1965
2173
|
return `Fix the high-risk launch gaps before a real campaign, customer demo, or paid traffic.`;
|
|
1966
2174
|
}
|
|
1967
2175
|
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
|
|
2176
|
+
return `No critical or high blocker was detected, but the remaining readiness gaps should be fixed and verified before treating the ${sourceLabel(source)} as launchable.`;
|
|
1969
2177
|
}
|
|
1970
2178
|
if (source === "url") {
|
|
1971
2179
|
return "No public-page blockers were found. Run the CLI or GitHub Action before calling the app launch-ready.";
|
|
1972
2180
|
}
|
|
1973
|
-
return `${band.goNoGoLabel}: no scan-detected blockers were found.
|
|
2181
|
+
return `${band.goNoGoLabel}: no scan-detected blockers were found. Monitor first production traffic.`;
|
|
1974
2182
|
}
|
|
1975
2183
|
function topNextStep(blockers, counts, source) {
|
|
1976
2184
|
const top = blockers[0];
|
|
@@ -1981,12 +2189,12 @@ function topNextStep(blockers, counts, source) {
|
|
|
1981
2189
|
return "Run the repo scan next so secrets, auth, API routes, and generated-code risks are checked before launch.";
|
|
1982
2190
|
}
|
|
1983
2191
|
if (counts.medium > 0 || counts.lower > 0) {
|
|
1984
|
-
return "Fix the remaining readiness gaps and rescan before
|
|
2192
|
+
return "Fix the remaining readiness gaps and rescan before launch.";
|
|
1985
2193
|
}
|
|
1986
|
-
return "
|
|
2194
|
+
return "No obvious launch blockers caught. Monitor first production traffic and keep the paid report for higher-stakes launches.";
|
|
1987
2195
|
}
|
|
1988
2196
|
function aggregateConfidence(source, findings) {
|
|
1989
|
-
if (findings.some((
|
|
2197
|
+
if (findings.some((finding2) => finding2.confidence === "low")) return "low";
|
|
1990
2198
|
if (source === "url") return "medium";
|
|
1991
2199
|
return "high";
|
|
1992
2200
|
}
|
|
@@ -2003,7 +2211,7 @@ function assessLaunchReadiness(input) {
|
|
|
2003
2211
|
const checkedAreas = coverage.filter((area) => area.status === "checked");
|
|
2004
2212
|
const uncheckedAreas = coverage.filter((area) => area.status !== "checked");
|
|
2005
2213
|
const blockers = findings.filter(
|
|
2006
|
-
(
|
|
2214
|
+
(finding2) => finding2.severity === "critical" || finding2.severity === "high"
|
|
2007
2215
|
);
|
|
2008
2216
|
const decision = decisionText(band, counts, source);
|
|
2009
2217
|
const reportRecommended = counts.critical > 0 || counts.high > 0 || coveragePenalty > 0;
|
|
@@ -2068,7 +2276,7 @@ function parseArgs(argv2) {
|
|
|
2068
2276
|
help: false,
|
|
2069
2277
|
version: false,
|
|
2070
2278
|
noColor: !!process2.env.NO_COLOR,
|
|
2071
|
-
publish:
|
|
2279
|
+
publish: !publishDisabledByEnv(),
|
|
2072
2280
|
proof: shouldUploadProof()
|
|
2073
2281
|
};
|
|
2074
2282
|
for (let i = 0; i < argv2.length; i++) {
|
|
@@ -2077,14 +2285,30 @@ function parseArgs(argv2) {
|
|
|
2077
2285
|
else if (a === "--version" || a === "-v") opts.version = true;
|
|
2078
2286
|
else if (a === "--json") opts.json = true;
|
|
2079
2287
|
else if (a === "--publish") opts.publish = true;
|
|
2288
|
+
else if (a === "--no-publish") opts.publish = false;
|
|
2080
2289
|
else if (a === "--proof") opts.proof = true;
|
|
2081
2290
|
else if (a === "--no-color") opts.noColor = true;
|
|
2082
2291
|
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 =
|
|
2292
|
+
else if (a === "--cwd") opts.cwd = path12.resolve(argv2[++i] ?? opts.cwd);
|
|
2293
|
+
else if (!a.startsWith("-")) opts.cwd = path12.resolve(a);
|
|
2085
2294
|
}
|
|
2086
2295
|
return opts;
|
|
2087
2296
|
}
|
|
2297
|
+
function envTruthy(value) {
|
|
2298
|
+
if (!value) return false;
|
|
2299
|
+
const normalized = value.trim().toLowerCase();
|
|
2300
|
+
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
2301
|
+
}
|
|
2302
|
+
function envFalsey(value) {
|
|
2303
|
+
if (!value) return false;
|
|
2304
|
+
const normalized = value.trim().toLowerCase();
|
|
2305
|
+
return normalized === "0" || normalized === "false" || normalized === "no";
|
|
2306
|
+
}
|
|
2307
|
+
function publishDisabledByEnv() {
|
|
2308
|
+
if (envTruthy(process2.env.SHIPPINGSZN_DISABLE_PUBLISH)) return true;
|
|
2309
|
+
if (envFalsey(process2.env.SHIPPINGSZN_PUBLISH)) return true;
|
|
2310
|
+
return false;
|
|
2311
|
+
}
|
|
2088
2312
|
function color(enabled) {
|
|
2089
2313
|
const wrap = (codes) => (s) => enabled ? `\x1B[${codes}m${s}\x1B[0m` : s;
|
|
2090
2314
|
return {
|
|
@@ -2118,12 +2342,8 @@ Usage:
|
|
|
2118
2342
|
|
|
2119
2343
|
Options:
|
|
2120
2344
|
--json Output a machine-readable JSON report.
|
|
2121
|
-
--publish
|
|
2122
|
-
|
|
2123
|
-
filenames, or project names.
|
|
2124
|
-
--proof Opt in to posting this scan to /api/scan-results and
|
|
2125
|
-
printing a proof URL, Wall URL, report URL, and badge.
|
|
2126
|
-
Can also be enabled with SHIPPINGSZN_PROOF=1.
|
|
2345
|
+
--publish Legacy alias. Anonymous Wall stats publish by default.
|
|
2346
|
+
--no-publish Disable the anonymous Wall post for this run.
|
|
2127
2347
|
--base-url <url> Base URL used to build links back to checklist items.
|
|
2128
2348
|
(default: ${DEFAULT_BASE_URL2})
|
|
2129
2349
|
--cwd <path> Directory to scan. Default: current working directory.
|
|
@@ -2131,8 +2351,9 @@ Options:
|
|
|
2131
2351
|
-h, --help Show this help.
|
|
2132
2352
|
-v, --version Print version.
|
|
2133
2353
|
|
|
2134
|
-
The scanner only reads files. It never writes, modifies, deletes
|
|
2135
|
-
|
|
2354
|
+
The scanner only reads files. It never writes, modifies, or deletes. It posts
|
|
2355
|
+
anonymous aggregate Wall stats by default: no source code, paths, filenames,
|
|
2356
|
+
project names, secrets, handles, emails, or repo URLs.
|
|
2136
2357
|
Exit code is non-zero if any Critical findings are detected.
|
|
2137
2358
|
`
|
|
2138
2359
|
);
|
|
@@ -2173,18 +2394,13 @@ async function run() {
|
|
|
2173
2394
|
const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
2174
2395
|
const reportUrl = `${trimmedBaseUrl}/report`;
|
|
2175
2396
|
const proofCreatePath = `${trimmedBaseUrl}/scan`;
|
|
2176
|
-
const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON
|
|
2397
|
+
const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON on shippingszn.com/scan if you want to attach the local scan to a paid report handoff.";
|
|
2177
2398
|
const tracked_aware = applyTrackingAwareSeverity(all, tracked);
|
|
2178
2399
|
const source = scanSource();
|
|
2179
2400
|
const enriched = tracked_aware.map((f) => {
|
|
2180
2401
|
const item = CHECKLIST_ITEMS[f.itemId];
|
|
2181
|
-
const remediation = buildRemediationPrompt({
|
|
2182
|
-
finding: f,
|
|
2183
|
-
item,
|
|
2184
|
-
proofCreatePath,
|
|
2185
|
-
reportUrl
|
|
2186
|
-
});
|
|
2187
2402
|
const itemTitle = item?.title ?? f.itemId;
|
|
2403
|
+
const permalink = permalinkFor(f.itemId, opts.baseUrl);
|
|
2188
2404
|
const normalized = normalizeLaunchFinding(
|
|
2189
2405
|
{
|
|
2190
2406
|
severity: f.severity,
|
|
@@ -2194,22 +2410,26 @@ async function run() {
|
|
|
2194
2410
|
evidence: f.evidence,
|
|
2195
2411
|
file: f.file,
|
|
2196
2412
|
line: f.line,
|
|
2197
|
-
permalink
|
|
2198
|
-
itemTitle
|
|
2199
|
-
fixInstructions: remediation.fixPrompt,
|
|
2200
|
-
aiBuilderPrompt: remediation.fixPrompt,
|
|
2201
|
-
verificationStep: remediation.verify,
|
|
2202
|
-
fixPrompt: remediation.fixPrompt,
|
|
2203
|
-
verify: remediation.verify
|
|
2413
|
+
permalink,
|
|
2414
|
+
itemTitle
|
|
2204
2415
|
},
|
|
2205
2416
|
source
|
|
2206
2417
|
);
|
|
2207
2418
|
return {
|
|
2208
2419
|
...f,
|
|
2209
|
-
|
|
2420
|
+
severity: normalized.severity,
|
|
2421
|
+
title: normalized.title,
|
|
2422
|
+
body: normalized.body,
|
|
2423
|
+
message: normalized.message,
|
|
2424
|
+
whatFailed: normalized.whatFailed,
|
|
2425
|
+
whyItBlocksLaunch: normalized.whyItBlocksLaunch,
|
|
2426
|
+
evidence: normalized.evidence,
|
|
2427
|
+
confidence: normalized.confidence,
|
|
2428
|
+
...normalized.location ? { location: normalized.location } : {},
|
|
2429
|
+
...normalized.file ? { file: normalized.file } : {},
|
|
2430
|
+
...normalized.line ? { line: normalized.line } : {},
|
|
2210
2431
|
itemTitle,
|
|
2211
|
-
permalink
|
|
2212
|
-
remediation
|
|
2432
|
+
permalink
|
|
2213
2433
|
};
|
|
2214
2434
|
});
|
|
2215
2435
|
enriched.sort((a, b) => {
|
|
@@ -2276,16 +2496,18 @@ async function run() {
|
|
|
2276
2496
|
}
|
|
2277
2497
|
let publishResult = "skipped";
|
|
2278
2498
|
try {
|
|
2279
|
-
if (opts.publish) {
|
|
2280
|
-
process2.env["SHIPPINGSZN_PUBLISH"] = "1";
|
|
2281
|
-
}
|
|
2282
2499
|
const proofAlreadyPublishedWall = proofResult.status === "uploaded" && !!proofResult.wallUrl;
|
|
2283
|
-
if (!proofAlreadyPublishedWall) {
|
|
2500
|
+
if (opts.publish && !proofAlreadyPublishedWall) {
|
|
2284
2501
|
publishResult = await publishScan(totals, files.length, {
|
|
2285
2502
|
cwd: opts.cwd,
|
|
2286
2503
|
baseUrl: opts.baseUrl,
|
|
2287
|
-
scannerVersion: PKG_VERSION
|
|
2504
|
+
scannerVersion: PKG_VERSION,
|
|
2505
|
+
score: launchReadiness.score,
|
|
2506
|
+
label: launchReadiness.label
|
|
2288
2507
|
});
|
|
2508
|
+
if (publishResult === "published") {
|
|
2509
|
+
report.launchReadiness.wallUrl = `${opts.baseUrl.replace(/\/$/, "")}/wall`;
|
|
2510
|
+
}
|
|
2289
2511
|
}
|
|
2290
2512
|
} catch {
|
|
2291
2513
|
}
|
|
@@ -2323,12 +2545,12 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
|
|
|
2323
2545
|
`
|
|
2324
2546
|
);
|
|
2325
2547
|
process2.stdout.write(
|
|
2326
|
-
`${c.bold("
|
|
2548
|
+
`${c.bold("Action plan:")} ${c.dim("Exact AI-builder prompts and verification steps are in the paid report.")}
|
|
2327
2549
|
`
|
|
2328
2550
|
);
|
|
2329
2551
|
if (launchReadiness.reportUrl) {
|
|
2330
2552
|
process2.stdout.write(
|
|
2331
|
-
`${c.bold("Need
|
|
2553
|
+
`${c.bold("Need a written launch decision?")} ${c.dim(`Get the $49 report: ${launchReadiness.reportUrl}`)}
|
|
2332
2554
|
`
|
|
2333
2555
|
);
|
|
2334
2556
|
}
|
|
@@ -2363,8 +2585,9 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
|
|
|
2363
2585
|
`
|
|
2364
2586
|
);
|
|
2365
2587
|
} else {
|
|
2588
|
+
const wallLine = publishResult === "published" ? `Posted anonymous scan stats to ${opts.baseUrl.replace(/\/$/, "")}/wall.` : opts.publish ? "Anonymous Wall stats post by default. If the network is blocked, the local scan still finishes." : "Private local run: Wall publish disabled.";
|
|
2366
2589
|
process2.stdout.write(
|
|
2367
|
-
`${c.bold("
|
|
2590
|
+
`${c.bold("Wall signal:")} ${c.dim(wallLine)}
|
|
2368
2591
|
`
|
|
2369
2592
|
);
|
|
2370
2593
|
}
|
|
@@ -2372,7 +2595,7 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
|
|
|
2372
2595
|
if (enriched.length === 0) {
|
|
2373
2596
|
process2.stdout.write(
|
|
2374
2597
|
c.green(
|
|
2375
|
-
"No findings.
|
|
2598
|
+
"No findings. The scanner did not catch obvious launch blockers in this pass.\n\n"
|
|
2376
2599
|
)
|
|
2377
2600
|
);
|
|
2378
2601
|
if (publishResult === "published") {
|
package/package.json
CHANGED