shipwarden 0.1.0 → 0.2.1

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 CHANGED
@@ -47,6 +47,35 @@ Try it on the bundled demo: `npx shipwarden examples/vulnerable-demo`
47
47
  | Raw SQL built from string interpolation | SQL injection. |
48
48
  | `dangerouslySetInnerHTML` without sanitizing | XSS. |
49
49
 
50
+ ## Launch checklist
51
+
52
+ On a full scan, ShipWarden also checks for the launch basics AI tools usually skip. Each check only runs when it applies: password reset only matters if users log in with a password, and terms of service only matter if you take payments. These never fail a build.
53
+
54
+ | Item | Checked when | Priority |
55
+ |---|---|---|
56
+ | 404 page | always | must have for single-page apps |
57
+ | Error page / error boundary | Next.js and React apps | must have for single-page apps |
58
+ | Privacy policy | the app has accounts, payments or analytics | must have |
59
+ | Terms of service | the app takes payments | must have |
60
+ | Password reset | users sign in with a password | must have |
61
+ | Rate limits on paid AI calls | server code calls OpenAI, Anthropic, Gemini, the Lovable AI gateway and similar | must have |
62
+ | Your own title, description and favicon | always (catches Lovable, v0, Vite and Create Next App defaults) | must have |
63
+ | Account deletion | the app has accounts | nice to have |
64
+ | Social preview image | always | nice to have |
65
+ | robots.txt and sitemap | always | nice to have |
66
+ | Error monitoring | the app has accounts or payments | nice to have |
67
+
68
+ ```
69
+ Launch checklist · 3 of 10 done · 4 must-haves missing
70
+
71
+ MUST HAVE No rate limit on paid AI calls supabase/functions/chat/index.ts:2
72
+ Your server calls a paid AI API with nothing limiting how often. One script hitting that
73
+ endpoint in a loop can run up a bill of thousands overnight.
74
+ Fix: Limit requests per user or IP (e.g. @upstash/ratelimit, or a daily quota per user ...
75
+ ```
76
+
77
+ Turn an item off with `--skip-check <id>` or a `check:<id>` line in `.shipwardenignore`, or the whole checklist with `--no-checklist`. Libraries and CLIs (anything that isn't a web app) get no checklist.
78
+
50
79
  ## Add it to GitHub (runs on every pull request)
51
80
 
52
81
  Create `.github/workflows/shipwarden.yml`:
@@ -70,7 +99,7 @@ jobs:
70
99
  - uses: patrickballard/shipwarden@v0
71
100
  ```
72
101
 
73
- On a pull request, ShipWarden only reports problems in files the PR changes. It flags them inline on the diff and keeps one summary comment up to date instead of posting a new one on every push. It fails the check on critical findings.
102
+ On a pull request, ShipWarden only reports security problems in files the PR changes, and leaves out the launch checklist, which is about the whole codebase. Pushes to `main` run a full scan with the checklist in the job summary. It flags them inline on the diff and keeps one summary comment up to date instead of posting a new one on every push. It fails the check on critical findings.
74
103
 
75
104
  | Input | Default | |
76
105
  |---|---|---|
@@ -78,6 +107,8 @@ On a pull request, ShipWarden only reports problems in files the PR changes. It
78
107
  | `scope` | `changed` | `all` reports on the whole repo on pull requests too. |
79
108
  | `comment` | `true` | Post the summary comment on pull requests. |
80
109
  | `ignore` | | Extra globs to skip, comma or newline separated. |
110
+ | `checklist` | `true` | Run the launch checklist on full scans (pushes, schedules, `scope: all`). |
111
+ | `skip-checks` | | Checklist item ids to turn off, e.g. `robots-sitemap, error-monitoring`. |
81
112
  | `anthropic-api-key` | | Turns on the optional AI review (below). |
82
113
  | `ai-model` | `claude-opus-5-5` | Claude model for the AI review. |
83
114
 
@@ -102,10 +133,11 @@ In the Action, set `anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}`. A revi
102
133
  ## CLI
103
134
 
104
135
  ```
105
- shipwarden [path] [--format text|json|markdown] [--fail-on <severity|none>] [--ignore <glob>] [--ai] [--model <id>]
136
+ shipwarden [path] [--format text|json|markdown] [--fail-on <severity|none>] [--ignore <glob>]
137
+ [--no-checklist] [--skip-check <id>] [--ai] [--model <id>]
106
138
  ```
107
139
 
108
- Exit code 1 means a finding is at or above `--fail-on` (default `critical`).
140
+ Exit code 1 means a security finding is at or above `--fail-on` (default `critical`). The checklist never changes the exit code.
109
141
 
110
142
  ## Limitations
111
143
 
package/action.yml CHANGED
@@ -20,6 +20,12 @@ inputs:
20
20
  ignore:
21
21
  description: Extra glob patterns to skip, comma or newline separated (also reads .shipwardenignore)
22
22
  default: ''
23
+ checklist:
24
+ description: On full scans (not PR-scoped ones), also run the launch checklist (404 page, privacy policy, rate limits on AI calls, ...)
25
+ default: 'true'
26
+ skip-checks:
27
+ description: Checklist item ids to turn off, comma or newline separated
28
+ default: ''
23
29
  anthropic-api-key:
24
30
  description: Optional. Lets Claude double-check uncertain findings and drop false positives
25
31
  default: ''
@@ -55,5 +61,7 @@ runs:
55
61
  INPUT_SCOPE: ${{ inputs.scope }}
56
62
  INPUT_COMMENT: ${{ inputs.comment }}
57
63
  INPUT_IGNORE: ${{ inputs.ignore }}
64
+ INPUT_CHECKLIST: ${{ inputs.checklist }}
65
+ INPUT_SKIP_CHECKS: ${{ inputs.skip-checks }}
58
66
  INPUT_ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
59
67
  INPUT_AI_MODEL: ${{ inputs.ai-model }}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shipwarden",
3
- "version": "0.1.0",
4
- "description": "Free security scanner for apps built with Lovable, Bolt, Cursor and v0: Supabase RLS, Firebase rules, leaked keys, unprotected endpoints.",
3
+ "version": "0.2.1",
4
+ "description": "Free security scanner and launch checklist for apps built with Lovable, Bolt, Cursor and v0: Supabase RLS, Firebase rules, leaked keys, unprotected endpoints, missing privacy policy and 404 pages.",
5
5
  "homepage": "https://github.com/patrickballard/shipwarden#readme",
6
6
  "repository": {
7
7
  "type": "git",
package/src/action.js CHANGED
@@ -35,9 +35,16 @@ async function main() {
35
35
  const pr = readEvent().pull_request;
36
36
  const sha = pr?.head?.sha ?? process.env.GITHUB_SHA;
37
37
 
38
- const result = await scan(root, { ignore });
38
+ // The launch checklist is about the whole codebase, so it only runs on full scans
39
+ // (pushes, schedules, or scope: all), never on a PR that reports on changed files.
40
+ const prScoped = !!pr && scope === 'changed';
41
+ const result = await scan(root, {
42
+ ignore,
43
+ checklist: !prScoped && input('CHECKLIST', 'true') !== 'false',
44
+ skipChecks: input('SKIP_CHECKS').split(/[\n,]/).map((s) => s.trim()).filter(Boolean),
45
+ });
39
46
  let scopeNote = 'the whole repository';
40
- if (pr && scope === 'changed') {
47
+ if (prScoped) {
41
48
  try {
42
49
  const changed = await listPullFiles({ token, owner, repo, number: pr.number });
43
50
  result.findings = result.findings.filter((f) => changed.has(f.file));
@@ -0,0 +1,255 @@
1
+ // Launch checklist: things AI-built apps commonly ship without. These are not security
2
+ // findings: they never fail a build, and each check only runs when it applies to the app.
3
+ import { isServerEndpoint, lineAt } from './util.js';
4
+
5
+ const CODE = /\.[cm]?[jt]sx?$|\.(vue|svelte|astro)$/;
6
+ const PAGES = /\.[cm]?[jt]sx?$|\.(vue|svelte|astro|html|mdx?)$/;
7
+ const HEAD_FILES = /(^|\/)(index\.html|(src\/)?app\/layout\.[jt]sx?|pages\/_(document|app)\.[jt]sx?|app\/root\.[jt]sx?|src\/app\.html|nuxt\.config\.[jt]s|src\/layouts\/\w+\.astro|(public\/)?(manifest|site)\.(json|webmanifest))$/;
8
+
9
+ /** First match of `re` in files whose path matches `filter`, as a location. */
10
+ function locate(files, re, filter = CODE) {
11
+ for (const f of files) {
12
+ if (!filter.test(f.path)) continue;
13
+ const m = re.exec(f.content);
14
+ if (m) return { file: f.path, line: lineAt(f.content, m.index) };
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function signals(files, ctx) {
20
+ const dep = (re) => [...ctx.deps].some((d) => re.test(d));
21
+ // A sign-in that only exists on an admin page doesn't mean the app has user accounts.
22
+ const userFacing = files.filter((f) => !/(^|\/)[^/]*admin[^/]*(\/|$)/i.test(f.path));
23
+ const inCode = (re) => !!locate(userFacing, re);
24
+ const hostedAuthUi = dep(/^@clerk\/|^@auth0\/|^@kinde-oss\/|^@workos-inc\//);
25
+ const accounts =
26
+ hostedAuthUi ||
27
+ dep(/^next-auth$|^@auth\/|^better-auth$|^lucia$/) ||
28
+ inCode(/\.auth\.(signUp|signIn\w*)\s*\(|createUserWithEmailAndPassword|signInWithEmailAndPassword|signInWithPopup|signInWithRedirect/);
29
+ return {
30
+ hostedAuthUi,
31
+ accounts,
32
+ passwordAuth: inCode(/signInWithPassword|createUserWithEmailAndPassword|signInWithEmailAndPassword|CredentialsProvider|Credentials\(\s*\{|\.auth\.signUp\s*\(\s*\{[^}]*password/),
33
+ payments: dep(/^stripe$|^@stripe\/|^@lemonsqueezy\/|^@paddle\/|^@polar-sh\/|^razorpay$|^@paypal\//),
34
+ analytics: dep(/posthog|^@vercel\/analytics$|mixpanel|amplitude|^react-ga|^@next\/third-parties$|plausible|^@segment\/|umami|hotjar/i) || inCode(/googletagmanager\.com|\bgtag\s*\(/),
35
+ };
36
+ }
37
+
38
+ const exists = (ctx, re) => ctx.paths.some((p) => re.test(p));
39
+
40
+ const AI_CALL =
41
+ /(?:from\s+|import\s*\(\s*|require\(\s*)['"](?:npm:|jsr:|https?:\/\/esm\.sh\/)?(?:openai|@anthropic-ai\/sdk|ai|@ai-sdk\/[\w-]+|groq-sdk|replicate|@google\/genai|@google\/generative-ai|@mistralai\/mistralai|cohere-ai)(?:@[^'"/]*)?(?:\/[^'"]*)?['"]|api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|openrouter\.ai\/api|ai\.gateway\.lovable\.dev/;
42
+ const RATE_LIMIT = /rate.?limit|ratelimit|limiter|throttl|arcjet|botid|turnstile|recaptcha|hcaptcha|usage.?limit|quota|credits?\b|max.?requests|requests?.?per/i;
43
+
44
+ const BOILERPLATE = [
45
+ { re: /<title>\s*(?:Vite \+ React(?: \+ TS)?|Vite App|React App)\s*<\/title>|"(?:short_)?name"\s*:\s*"(?:React App|Create React App Sample)"/, label: 'the starter template title' },
46
+ { re: /Create Next App|Generated by create next app/, label: 'the "Create Next App" title' },
47
+ { re: /Lovable App|Lovable Generated Project|lovable\.dev\/opengraph-image|@lovable_dev/, label: "Lovable's default title, description or preview image" },
48
+ { re: /['"`]v0 App['"`]|Created with v0/, label: 'the default v0 title' },
49
+ { re: /href=["']\/vite\.svg["']/, label: "Vite's logo as the favicon" },
50
+ ];
51
+
52
+ export const CHECKS = [
53
+ {
54
+ id: 'not-found-page',
55
+ title: '404 page',
56
+ missing: 'No 404 page',
57
+ applies: (s, ctx) => ctx.app,
58
+ priority: (s, ctx) => (ctx.kind === 'spa' ? 'must' : 'should'),
59
+ check(files, ctx) {
60
+ const byKind = {
61
+ next: /(^|\/)(src\/)?app\/not-found\.[jt]sx?$|(^|\/)(src\/)?pages\/404\.[jt]sx?$/,
62
+ sveltekit: /(^|\/)src\/routes\/\+error\.svelte$/,
63
+ nuxt: /(^|\/)error\.vue$/,
64
+ astro: /(^|\/)src\/pages\/404\.(astro|mdx?|html)$/,
65
+ remix: /(^|\/)app\/routes\/(\$|\[\$\]|404)\.[jt]sx?$/,
66
+ };
67
+ if (byKind[ctx.kind]) return exists(ctx, byKind[ctx.kind]);
68
+ return (
69
+ exists(ctx, /(^|\/)404\.html$|(^|\/)(NotFound|not-found|404)\.[a-z]+$/i) ||
70
+ !!locate(files, /path\s*[:=]\s*\{?\s*["'`]\/?\*["'`]|notFoundComponent|:pathMatch\(\.\*\)/)
71
+ );
72
+ },
73
+ why: (s, ctx) =>
74
+ ctx.kind === 'spa'
75
+ ? 'Anyone who follows a broken or mistyped link gets a blank page, with no way back into your app.'
76
+ : 'Broken links fall back to the framework\'s bare default 404, which looks like your site is down.',
77
+ fix: (s, ctx) =>
78
+ ({
79
+ next: 'Add `app/not-found.tsx` with your header and a link home.',
80
+ sveltekit: 'Add `src/routes/+error.svelte`.',
81
+ nuxt: 'Add `error.vue` at the project root.',
82
+ astro: 'Add `src/pages/404.astro`.',
83
+ remix: 'Add a splat route `app/routes/$.tsx` that renders a not-found page.',
84
+ })[ctx.kind] ?? 'Add a catch-all route last in your router, e.g. `<Route path="*" element={<NotFound />} />`, with a link back home.',
85
+ },
86
+ {
87
+ id: 'error-page',
88
+ title: 'Error page',
89
+ missing: 'No error page',
90
+ applies: (s, ctx) => ctx.kind === 'next' || (ctx.kind === 'spa' && ctx.deps.has('react')),
91
+ priority: (s, ctx) => (ctx.kind === 'spa' ? 'must' : 'should'),
92
+ check(files, ctx) {
93
+ if (ctx.kind === 'next') return exists(ctx, /(^|\/)(src\/)?app\/(?:.*\/)?(global-)?error\.[jt]sx?$|(^|\/)(src\/)?pages\/(_error|500)\.[jt]sx?$/);
94
+ return !!locate(files, /ErrorBoundary|componentDidCatch|errorElement\s*[:=]|errorComponent/);
95
+ },
96
+ why: (s, ctx) =>
97
+ ctx.kind === 'spa'
98
+ ? 'If any component throws, React unmounts the whole app and every visitor sees a white screen.'
99
+ : 'An unexpected error shows Next.js\'s generic "Application error" screen instead of a friendly message and a way to retry.',
100
+ fix: (s, ctx) =>
101
+ ctx.kind === 'next'
102
+ ? 'Add `app/error.tsx` (and `app/global-error.tsx`) with a message and a "Try again" button that calls `reset()`.'
103
+ : 'Wrap your app in an error boundary, e.g. `react-error-boundary`: `<ErrorBoundary fallback={<CrashPage />}><App /></ErrorBoundary>`.',
104
+ },
105
+ {
106
+ id: 'privacy-policy',
107
+ title: 'Privacy policy',
108
+ missing: 'No privacy policy',
109
+ applies: (s, ctx) => ctx.app && (s.accounts || s.payments || s.analytics),
110
+ priority: () => 'must',
111
+ check: (files, ctx) =>
112
+ exists(ctx, /privacy/i) || !!locate(files, /["'`]\/(?:legal\/)?privacy(?:[-_]policy)?["'`/#?]|href\s*=\s*\{?["'`]https?:[^"'`]*privacy/i, PAGES),
113
+ why: (s) => {
114
+ const what = [s.accounts && 'user accounts', s.payments && 'payments', s.analytics && 'analytics'].filter(Boolean).join(', ');
115
+ return `Your app has ${what}, so it collects personal data. Privacy laws (GDPR, CCPA) require a privacy policy, and Google sign-in, Stripe and the app stores ask for its URL.`;
116
+ },
117
+ fix: () =>
118
+ 'Add a `/privacy` page covering what you collect, why, which services process it (e.g. Supabase, Stripe) and how users can get their data deleted. Link it in the footer and on the sign-up form.',
119
+ },
120
+ {
121
+ id: 'terms-of-service',
122
+ title: 'Terms of service',
123
+ missing: 'No terms of service',
124
+ applies: (s, ctx) => ctx.app && s.payments,
125
+ priority: () => 'must',
126
+ check: (files, ctx) =>
127
+ exists(ctx, /(^|\/|[-_])(terms|tos)([-_.\/]|$)/i) || !!locate(files, /["'`]\/(?:legal\/)?(?:terms|tos)(?:[-_]of[-_](?:service|use))?["'`/#?]|href\s*=\s*\{?["'`]https?:[^"'`]*terms/i, PAGES),
128
+ why: () => 'You take payments, so you need terms covering refunds, cancellations and acceptable use. Stripe expects them on your site before it activates live payments.',
129
+ fix: () => 'Add a `/terms` page with your refund and cancellation policy, and link it at checkout and in the footer.',
130
+ },
131
+ {
132
+ id: 'password-reset',
133
+ title: 'Password reset',
134
+ missing: 'No way to reset a forgotten password',
135
+ applies: (s) => s.passwordAuth && !s.hostedAuthUi,
136
+ priority: () => 'must',
137
+ check: (files) => !!locate(files, /resetPasswordForEmail|sendPasswordResetEmail|forgot[-_ ]?password|reset[-_ ]?password|ForgotPassword|ResetPassword|updateUser\(\s*\{\s*password/i),
138
+ why: () => "Users sign in with a password, but there's no way to reset it. Anyone who forgets theirs is locked out for good.",
139
+ fix: () =>
140
+ 'Add a "Forgot password?" link. Supabase: `supabase.auth.resetPasswordForEmail(email, { redirectTo })` plus a page that calls `supabase.auth.updateUser({ password })`. Firebase: `sendPasswordResetEmail(auth, email)`.',
141
+ },
142
+ {
143
+ id: 'account-deletion',
144
+ title: 'Account deletion',
145
+ missing: 'No way for users to delete their account',
146
+ applies: (s) => s.accounts && !s.hostedAuthUi,
147
+ priority: () => 'should',
148
+ check: (files) => !!locate(files, /deleteUser|delete[-_ ]?account|deleteMyAccount|close[-_ ]?account|delete_user|user\.delete\(\)/i),
149
+ why: () => 'GDPR gives users the right to have their data deleted, and Apple rejects apps with sign-up but no in-app account deletion.',
150
+ fix: () => 'Add a "Delete account" button that calls a server function: confirm who the user is, delete their rows, then `supabase.auth.admin.deleteUser(user.id)` (or your auth provider\'s equivalent).',
151
+ },
152
+ {
153
+ id: 'ai-rate-limit',
154
+ title: 'Rate limits on AI calls',
155
+ missing: 'No rate limit on paid AI calls',
156
+ applies: (s, ctx, files) => !!aiServerCall(files),
157
+ priority: () => 'must',
158
+ check: (files, ctx) => [...ctx.deps].some((d) => RATE_LIMIT.test(d)) || !!locate(files, RATE_LIMIT),
159
+ locate: (files) => aiServerCall(files),
160
+ why: () => 'Your server calls a paid AI API with nothing limiting how often. One script hitting that endpoint in a loop can run up a bill of thousands overnight.',
161
+ fix: () =>
162
+ 'Limit requests per user or IP (e.g. `@upstash/ratelimit`, or a daily quota per user in your database), and set a monthly spending cap in your AI provider\'s dashboard.',
163
+ },
164
+ {
165
+ id: 'default-branding',
166
+ title: 'Your own title, description and favicon',
167
+ missing: 'Template title, description or favicon still in place',
168
+ applies: (s, ctx, files) => ctx.app && files.some((f) => HEAD_FILES.test(f.path)),
169
+ priority: () => 'must',
170
+ check: (files) => !boilerplate(files).length,
171
+ locate: (files) => boilerplate(files)[0],
172
+ why: (s, ctx, files) =>
173
+ `Browser tabs and shared links still show ${[...new Set(boilerplate(files).map((b) => b.label))].join(', ')}. It's the first thing people see when you share your link.`,
174
+ fix: (s, ctx, files) => `Set your own \`<title>\`, meta description, \`og:image\` and favicon in \`${boilerplate(files)[0]?.file}\`.`,
175
+ },
176
+ {
177
+ id: 'social-preview',
178
+ title: 'Social preview image',
179
+ missing: 'No social preview image',
180
+ applies: (s, ctx) => ctx.app,
181
+ priority: () => 'should',
182
+ check: (files, ctx) =>
183
+ exists(ctx, /(^|\/)(opengraph|twitter)-image\.\w+$/) || !!locate(files, /og:image|twitter:image|openGraph\s*:\s*\{[\s\S]{0,800}?images/, HEAD_FILES),
184
+ why: () => 'Without a preview image, links to your app on X, LinkedIn, Slack and iMessage show up as a plain text box.',
185
+ fix: (s, ctx) =>
186
+ ctx.kind === 'next'
187
+ ? 'Add a 1200×630 `app/opengraph-image.png`.'
188
+ : 'Add a 1200×630 image and `<meta property="og:image" content="https://yourdomain.com/og.png">` to your `<head>`.',
189
+ },
190
+ {
191
+ id: 'robots-sitemap',
192
+ title: 'robots.txt and sitemap',
193
+ missing: 'No robots.txt or sitemap',
194
+ applies: (s, ctx) => ctx.app,
195
+ priority: () => 'should',
196
+ check: (files, ctx) =>
197
+ exists(ctx, /(^|\/)robots\.txt$|(^|\/)(src\/)?app\/robots\.[jt]s$/) &&
198
+ (exists(ctx, /sitemap[^/]*\.xml$|(^|\/)(src\/)?app\/sitemap\.[jt]s$/) || [...ctx.deps].some((d) => /sitemap/.test(d))),
199
+ why: () => "Search engines find and index your pages faster with a robots.txt and sitemap. Without them you're harder to find on Google.",
200
+ fix: (s, ctx) =>
201
+ ctx.kind === 'next' ? 'Add `app/robots.ts` and `app/sitemap.ts` (Next.js generates the files from them).' : 'Add `public/robots.txt` and a `public/sitemap.xml` listing your public pages.',
202
+ },
203
+ {
204
+ id: 'error-monitoring',
205
+ title: 'Error monitoring',
206
+ missing: 'No error monitoring',
207
+ applies: (s, ctx) => ctx.app && (s.accounts || s.payments),
208
+ priority: () => 'should',
209
+ check: (files, ctx) => [...ctx.deps].some((d) => /^@sentry\/|logrocket|^@bugsnag\/|^rollbar$|^@highlight-run\/|^@datadog\/browser|^posthog-(js|node)$|^@axiomhq\/|^@logtail\//.test(d)),
210
+ why: () => "When something breaks for a user, you'll only hear about it if they bother to tell you, and most just leave.",
211
+ fix: (s, ctx) => `Add Sentry (free tier): \`npx @sentry/wizard@latest -i ${ctx.kind === 'next' ? 'nextjs' : 'react'}\`, or PostHog's error tracking.`,
212
+ },
213
+ ];
214
+
215
+ const boilerplateCache = new WeakMap();
216
+ function boilerplate(files) {
217
+ if (!boilerplateCache.has(files)) {
218
+ const hits = [];
219
+ for (const f of files) {
220
+ if (!HEAD_FILES.test(f.path)) continue;
221
+ for (const b of BOILERPLATE) {
222
+ const m = b.re.exec(f.content);
223
+ if (m) hits.push({ file: f.path, line: lineAt(f.content, m.index), label: b.label });
224
+ }
225
+ }
226
+ boilerplateCache.set(files, hits);
227
+ }
228
+ return boilerplateCache.get(files);
229
+ }
230
+
231
+ const aiCache = new WeakMap();
232
+ function aiServerCall(files) {
233
+ if (!aiCache.has(files)) {
234
+ const server = files.filter((f) => isServerEndpoint(f) || /(^|\/)(server|api|functions|lib\/server)\//.test(f.path));
235
+ aiCache.set(files, locate(server, AI_CALL));
236
+ }
237
+ return aiCache.get(files);
238
+ }
239
+
240
+ /**
241
+ * @returns {{id: string, title: string, missing: string, priority: 'must'|'should', ok: boolean, why: string, fix: string, file?: string, line?: number}[]}
242
+ */
243
+ export function runChecklist(files, ctx, skip = new Set()) {
244
+ if (!ctx.app) return [];
245
+ const s = signals(files, ctx);
246
+ const items = [];
247
+ for (const c of CHECKS) {
248
+ if (skip.has(c.id) || !c.applies(s, ctx, files)) continue;
249
+ const ok = c.check(files, ctx);
250
+ const item = { id: c.id, title: c.title, missing: c.missing, priority: c.priority(s, ctx), ok };
251
+ if (!ok) Object.assign(item, { why: c.why(s, ctx, files), fix: c.fix(s, ctx, files) }, c.locate?.(files) ?? {});
252
+ items.push(item);
253
+ }
254
+ return items.sort((a, b) => a.ok - b.ok || (a.priority === 'must' ? 0 : 1) - (b.priority === 'must' ? 0 : 1));
255
+ }
package/src/cli.js CHANGED
@@ -18,6 +18,8 @@ Options:
18
18
  --format <text|json|markdown> Output format (default: text)
19
19
  --fail-on <severity|none> Exit 1 if any finding is at least this severe (default: critical)
20
20
  --ignore <glob> Skip matching files (repeatable; also reads .shipwardenignore)
21
+ --no-checklist Skip the launch checklist (404 page, privacy policy, ...)
22
+ --skip-check <id> Turn off one checklist item (repeatable; or add check:<id> to .shipwardenignore)
21
23
  --ai Ask Claude to double-check uncertain findings (needs ANTHROPIC_API_KEY)
22
24
  --model <id> Claude model for --ai (default: ${DEFAULT_MODEL})
23
25
  --no-color Disable colors
@@ -28,7 +30,7 @@ Severities: ${SEVERITIES.join(', ')}
28
30
  `;
29
31
 
30
32
  function parseArgs(argv) {
31
- const opts = { target: '.', format: 'text', failOn: 'critical', ignore: [], ai: false, model: DEFAULT_MODEL, color: process.stdout.isTTY && !process.env.NO_COLOR };
33
+ const opts = { target: '.', format: 'text', failOn: 'critical', ignore: [], skipChecks: [], checklist: true, ai: false, model: DEFAULT_MODEL, color: process.stdout.isTTY && !process.env.NO_COLOR };
32
34
  for (let i = 0; i < argv.length; i++) {
33
35
  const arg = argv[i];
34
36
  const value = () => {
@@ -43,6 +45,8 @@ function parseArgs(argv) {
43
45
  else if (arg === '--fail-on') opts.failOn = value();
44
46
  else if (arg === '--ignore') opts.ignore.push(value());
45
47
  else if (arg === '--ai') opts.ai = true;
48
+ else if (arg === '--no-checklist') opts.checklist = false;
49
+ else if (arg === '--skip-check') opts.skipChecks.push(value());
46
50
  else if (arg === '--model') opts.model = value();
47
51
  else if (arg === '--no-color') opts.color = false;
48
52
  else if (arg.startsWith('-')) throw new Error(`Unknown option ${arg}`);
@@ -60,7 +64,7 @@ async function main() {
60
64
 
61
65
  const root = path.resolve(opts.target);
62
66
  if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`Not a directory: ${opts.target}`);
63
- const result = await scan(root, { ignore: opts.ignore });
67
+ const result = await scan(root, { ignore: opts.ignore, checklist: opts.checklist, skipChecks: opts.skipChecks });
64
68
 
65
69
  let aiNote = '';
66
70
  let ai;
package/src/report.js CHANGED
@@ -26,7 +26,7 @@ export function toMarkdown(result, { link, scopeNote, aiNote, maxItems = 30 } =
26
26
  const scanned = `Scanned ${plural(result.filesScanned, 'file')}${scopeNote ? ` · reporting on ${scopeNote}` : ''}${result.stack.length ? ` · ${result.stack.join(', ')}` : ''}`;
27
27
  const footer = [`<sub>${scanned}${aiNote ? ` · ${aiNote}` : ''}. To silence a finding, put a <code>shipwarden-ignore</code> comment on the line above it.</sub>`];
28
28
 
29
- if (!findings.length) return [MARKER, '### ✅ ShipWarden: no security issues found', '', ...footer].join('\n');
29
+ if (!findings.length) return [MARKER, '### ✅ ShipWarden: no security issues found', '', ...checklistMarkdown(result.checklist), ...footer].join('\n');
30
30
 
31
31
  const shown = findings.slice(0, maxItems);
32
32
  const where = (f) => (link ? `[\`${f.file}:${f.line}\`](${link(f.file, f.line)})` : `\`${f.file}:${f.line}\``);
@@ -54,7 +54,25 @@ export function toMarkdown(result, { link, scopeNote, aiNote, maxItems = 30 } =
54
54
  if (f.ai) out.push('', `> 🤖 AI review (${f.ai.verdict === 'real' ? 'confirmed' : 'unsure'}): ${f.ai.reason}`);
55
55
  out.push('', '</details>', '');
56
56
  }
57
- return [...out, ...footer].join('\n');
57
+ return [...out, ...checklistMarkdown(result.checklist), ...footer].join('\n');
58
+ }
59
+
60
+ function checklistSummary(items) {
61
+ const done = items.filter((i) => i.ok).length;
62
+ const mustMissing = items.filter((i) => !i.ok && i.priority === 'must').length;
63
+ return `${done} of ${items.length} done${mustMissing ? ` · ${mustMissing} must-have${mustMissing === 1 ? '' : 's'} missing` : ''}`;
64
+ }
65
+
66
+ function checklistMarkdown(items = []) {
67
+ if (!items.length) return [];
68
+ const out = [`<details${items.some((i) => !i.ok && i.priority === 'must') ? ' open' : ''}><summary>📋 <b>Launch checklist</b>: ${checklistSummary(items)}</summary>`, ''];
69
+ for (const i of items.filter((i) => !i.ok)) {
70
+ const where = i.file ? ` (\`${i.file}:${i.line}\`)` : '';
71
+ out.push(`- ${i.priority === 'must' ? '❌' : '⚠️'} **${i.missing}**${i.priority === 'must' ? ' · must have' : ''}${where}: ${i.why} **Fix:** ${i.fix}`);
72
+ }
73
+ const done = items.filter((i) => i.ok);
74
+ if (done.length) out.push(`- ✅ ${done.map((i) => i.title).join(', ')}`);
75
+ return [...out, '', '</details>', ''];
58
76
  }
59
77
 
60
78
  const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
@@ -74,9 +92,8 @@ export function toText(result, { color = false, aiNote } = {}) {
74
92
  const c = (code, s) => (color ? `${ANSI[code]}${s}${ANSI.reset}` : s);
75
93
  const strip = (s) => s.replace(/`/g, '');
76
94
  const head = `ShipWarden · scanned ${plural(result.filesScanned, 'file')}${result.stack.length ? ` (${result.stack.join(', ')})` : ''} in ${result.durationMs}ms${aiNote ? ` · ${aiNote}` : ''}`;
77
- if (!result.findings.length) return `${c('dim', head)}\n\n${c('green', '✔ No security issues found.')}\n`;
78
-
79
95
  const lines = [c('dim', head), ''];
96
+ if (!result.findings.length) lines.push(c('green', '✔ No security issues found.'), '');
80
97
  for (const f of result.findings) {
81
98
  lines.push(
82
99
  `${c(f.severity, ` ${f.severity.toUpperCase()} `)} ${c('bold', strip(f.title))}`,
@@ -87,8 +104,23 @@ export function toText(result, { color = false, aiNote } = {}) {
87
104
  if (f.ai) lines.push(` ${c('dim', `AI review (${f.ai.verdict}): ${f.ai.reason}`)}`);
88
105
  lines.push('');
89
106
  }
90
- lines.push(`${c('bold', `${plural(result.findings.length, 'issue')}:`)} ${summaryLine(result.findings)}`);
91
- return `${lines.join('\n')}\n`;
107
+ if (result.findings.length) lines.push(`${c('bold', `${plural(result.findings.length, 'issue')}:`)} ${summaryLine(result.findings)}`, '');
108
+
109
+ const items = result.checklist ?? [];
110
+ if (items.length) {
111
+ lines.push(c('bold', `Launch checklist · ${checklistSummary(items)}`), '');
112
+ for (const i of items.filter((i) => !i.ok)) {
113
+ lines.push(
114
+ ` ${i.priority === 'must' ? c('high', ' MUST HAVE ') : c('medium', 'NICE TO HAVE')} ${c('bold', i.missing)}${i.file ? c('dim', ` ${i.file}:${i.line}`) : ''}`,
115
+ ` ${strip(i.why)}`,
116
+ ` ${c('bold', 'Fix:')} ${strip(i.fix)}`,
117
+ '',
118
+ );
119
+ }
120
+ const done = items.filter((i) => i.ok);
121
+ if (done.length) lines.push(`${c('green', ' ✔ Done:')} ${done.map((i) => i.title).join(' · ')}`, '');
122
+ }
123
+ return `${lines.join('\n')}`;
92
124
  }
93
125
 
94
126
  export function toJson(result, extra = {}) {
@@ -87,6 +87,8 @@ export const publicEnvSecret = {
87
87
  for (const m of file.content.matchAll(PUBLIC_VAR)) {
88
88
  const [name, prefix, rest] = m;
89
89
  if (seen.has(name) || /PUBLISHABLE|ANON/.test(rest)) continue;
90
+ const lineStart = file.content.lastIndexOf('\n', m.index) + 1;
91
+ if (/^\s*(#|\/\/|\*)/.test(file.content.slice(lineStart, m.index))) continue; // commented out
90
92
  const critical = CRITICAL_NAME.test(rest);
91
93
  if (!critical && !AI_KEY_NAME.test(rest)) continue;
92
94
  // The ai-key-in-browser finding already covers an AI key used from browser code.
package/src/rules/web.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EDGE_FUNCTION, ROUTE_HANDLER, hasDirective, isServerEndpoint, lineAt, makeFinding } from '../util.js';
1
+ import { EDGE_FUNCTION, ROUTE_HANDLER, hasDirective, isServerEndpoint, lineAt, looksLikePlaceholder, makeFinding } from '../util.js';
2
2
 
3
3
  const MUTATING_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b|export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=|\bas\s+(POST|PUT|PATCH|DELETE)\b/;
4
4
 
@@ -177,8 +177,11 @@ export const committedEnvFile = {
177
177
  severity: 'high',
178
178
  check(file, ctx) {
179
179
  if (!ctx.tracked || !ENV_FILE.test(file.path) || ENV_TEMPLATE.test(file.path)) return [];
180
- const names = [...file.content.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]?[^\s'"#]/gm)].map((m) => m[1]);
181
- const secrets = names.filter((n) => !PUBLIC_PREFIX.test(n));
180
+ const vars = [...file.content.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]?([^\s'"#][^\n'"]*)/gm)];
181
+ // Public values and local-only dev settings (localhost databases, "password") aren't leaks.
182
+ const secrets = vars
183
+ .filter(([, name, value]) => !PUBLIC_PREFIX.test(name) && !/localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal/.test(value) && !looksLikePlaceholder(value) && !/^(password|secret|changeme|true|false|\d+|development|production|test)$/i.test(value))
184
+ .map(([, name]) => name);
182
185
  if (!secrets.length) return [];
183
186
  const shown = secrets.slice(0, 5).map((n) => `\`${n}\``).join(', ');
184
187
  return [
package/src/scanner.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { RULES, RULE_IDS } from './rules/index.js';
5
- import { globToRegExp, severityRank } from './util.js';
5
+ import { runChecklist } from './checklist.js';
6
+ import { TEST_FILE, globToRegExp, severityRank } from './util.js';
6
7
 
7
8
  const DEFAULT_IGNORES = [
8
9
  'node_modules', '.git', '.next', '.nuxt', '.output', '.svelte-kit', '.vercel', '.netlify', '.turbo', '.cache',
@@ -16,8 +17,6 @@ const TEXT_EXT = new Set([
16
17
  ]);
17
18
  const MAX_BYTES = 1024 * 1024;
18
19
  const MAX_SQL_BYTES = 32 * 1024 * 1024; // schema dumps get big
19
- // Tests and fixtures are full of fake keys and deliberately unsafe code.
20
- const TEST_FILE = /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|fixtures?|e2e|cypress|spec)\/|\.(test|spec|stories)\.[cm]?[jt]sx?$/;
21
20
 
22
21
  function walk(root, rel = '', out = []) {
23
22
  for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
@@ -45,6 +44,7 @@ function listFiles(root) {
45
44
  }
46
45
  }
47
46
 
47
+ /** `.shipwardenignore`: path globs, plus `check:<id>` lines that turn off a checklist item. */
48
48
  function readIgnoreFile(root) {
49
49
  try {
50
50
  return fs
@@ -78,7 +78,17 @@ function loadFiles(root, paths, ignores) {
78
78
  return files;
79
79
  }
80
80
 
81
- function buildContext(files, tracked) {
81
+ function detectKind(deps) {
82
+ if (deps.has('next')) return 'next';
83
+ if (deps.has('@sveltejs/kit')) return 'sveltekit';
84
+ if (deps.has('nuxt')) return 'nuxt';
85
+ if (deps.has('astro')) return 'astro';
86
+ if ([...deps].some((d) => d.startsWith('@remix-run/'))) return 'remix';
87
+ if (deps.has('vite') || deps.has('react-scripts')) return 'spa';
88
+ return 'other';
89
+ }
90
+
91
+ function buildContext(files, tracked, paths) {
82
92
  const deps = new Set();
83
93
  for (const f of files) {
84
94
  if (!f.path.endsWith('package.json')) continue;
@@ -94,7 +104,10 @@ function buildContext(files, tracked) {
94
104
  const anyPath = (re) => files.some((f) => re.test(f.path));
95
105
  const usesSupabase = [...deps].some((d) => d.startsWith('@supabase/')) || anyPath(/(^|\/)supabase\/(config\.toml|migrations\/|functions\/)/);
96
106
  const usesFirebase = deps.has('firebase') || deps.has('firebase-admin') || anyPath(/(^|\/)(firebase\.json|firestore\.rules)$/);
97
- const framework = deps.has('next') ? 'next' : deps.has('vite') || deps.has('react-scripts') ? 'spa' : 'other';
107
+ const kind = detectKind(deps);
108
+ // Only a plain SPA ships everything under src/ to the browser.
109
+ const framework = kind === 'next' || kind === 'spa' ? kind : 'other';
110
+ const app = kind !== 'other' || paths.some((p) => /(^|\/)index\.html$/.test(p));
98
111
 
99
112
  const stack = [];
100
113
  if (framework === 'next') stack.push('Next.js');
@@ -103,7 +116,7 @@ function buildContext(files, tracked) {
103
116
  if (usesFirebase) stack.push('Firebase');
104
117
  if (deps.has('stripe') || deps.has('@stripe/stripe-js')) stack.push('Stripe');
105
118
  if (deps.has('@prisma/client') || deps.has('prisma')) stack.push('Prisma');
106
- return { tracked, deps, usesSupabase, usesFirebase, framework, stack };
119
+ return { tracked, deps, usesSupabase, usesFirebase, framework, kind, app, paths, stack };
107
120
  }
108
121
 
109
122
  const IGNORE_COMMENT = /shipwarden-ignore(?:[\s:]+([\w-]+(?:\s*,\s*[\w-]+)*))?/;
@@ -120,12 +133,20 @@ function isSuppressed(finding, fileContents) {
120
133
  return false;
121
134
  }
122
135
 
123
- export async function scan(root, { ignore = [] } = {}) {
136
+ /**
137
+ * @param {string} root
138
+ * @param {{ignore?: string[], checklist?: boolean, skipChecks?: string[]}} [opts]
139
+ * checklist: also run the whole-codebase launch checklist (skip it for PR-scoped scans).
140
+ */
141
+ export async function scan(root, { ignore = [], checklist = true, skipChecks = [] } = {}) {
124
142
  const started = Date.now();
125
143
  const { paths, tracked } = listFiles(root);
126
- const ignores = [...DEFAULT_IGNORES, ...readIgnoreFile(root), ...ignore].map(globToRegExp);
144
+ const ignoreLines = readIgnoreFile(root);
145
+ const skip = new Set([...skipChecks, ...ignoreLines.filter((l) => l.startsWith('check:')).map((l) => l.slice(6).trim())]);
146
+ const ignores = [...DEFAULT_IGNORES, ...ignoreLines.filter((l) => !l.startsWith('check:')), ...ignore].map(globToRegExp);
127
147
  const files = loadFiles(root, paths, ignores);
128
- const ctx = buildContext(files, tracked);
148
+ const visiblePaths = paths.filter((p) => !ignores.some((re) => re.test(p)) && !TEST_FILE.test(p));
149
+ const ctx = buildContext(files, tracked, visiblePaths);
129
150
  const fileContents = new Map(files.map((f) => [f.path, f.content]));
130
151
 
131
152
  const raw = [];
@@ -149,6 +170,7 @@ export async function scan(root, { ignore = [] } = {}) {
149
170
  filesScanned: files.length,
150
171
  stack: ctx.stack,
151
172
  findings,
173
+ checklist: checklist ? runChecklist(files.filter((f) => !TEST_FILE.test(f.path)), ctx, skip) : [],
152
174
  fileContents,
153
175
  durationMs: Date.now() - started,
154
176
  };
package/src/util.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
2
2
 
3
+ // Tests and fixtures are full of fake keys and deliberately unsafe code.
4
+ export const TEST_FILE = /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|fixtures?|e2e|cypress|spec)\/|\.(test|spec|stories)\.[cm]?[jt]sx?$/;
5
+
3
6
  export function severityRank(severity) {
4
7
  const i = SEVERITIES.indexOf(severity);
5
8
  return i === -1 ? SEVERITIES.length : i;