shipwarden 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +154 -2
- package/action.yml +67 -0
- package/package.json +44 -4
- package/src/action.js +94 -0
- package/src/ai-judge.js +138 -0
- package/src/checklist.js +255 -0
- package/src/cli.js +88 -0
- package/src/github.js +52 -0
- package/src/report.js +129 -0
- package/src/rules/firebase.js +116 -0
- package/src/rules/index.js +7 -0
- package/src/rules/secrets.js +121 -0
- package/src/rules/supabase.js +293 -0
- package/src/rules/web.js +193 -0
- package/src/scanner.js +177 -0
- package/src/util.js +118 -0
package/src/checklist.js
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { scan } from './scanner.js';
|
|
6
|
+
import { DEFAULT_MODEL, judgeFindings } from './ai-judge.js';
|
|
7
|
+
import { toJson, toMarkdown, toText } from './report.js';
|
|
8
|
+
import { SEVERITIES, atLeast } from './util.js';
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const HELP = `ShipWarden ${version} · security checks for apps built with Lovable, Bolt, Cursor, v0 & co.
|
|
14
|
+
|
|
15
|
+
Usage: shipwarden [path] [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--format <text|json|markdown> Output format (default: text)
|
|
19
|
+
--fail-on <severity|none> Exit 1 if any finding is at least this severe (default: critical)
|
|
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)
|
|
23
|
+
--ai Ask Claude to double-check uncertain findings (needs ANTHROPIC_API_KEY)
|
|
24
|
+
--model <id> Claude model for --ai (default: ${DEFAULT_MODEL})
|
|
25
|
+
--no-color Disable colors
|
|
26
|
+
-v, --version Print version
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
Severities: ${SEVERITIES.join(', ')}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
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 };
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
const value = () => {
|
|
37
|
+
const v = argv[++i];
|
|
38
|
+
if (v === undefined) throw new Error(`${arg} needs a value`);
|
|
39
|
+
return v;
|
|
40
|
+
};
|
|
41
|
+
if (arg === '-h' || arg === '--help') opts.help = true;
|
|
42
|
+
else if (arg === '-v' || arg === '--version') opts.version = true;
|
|
43
|
+
else if (arg === '--format') opts.format = value();
|
|
44
|
+
else if (arg === '--json') opts.format = 'json';
|
|
45
|
+
else if (arg === '--fail-on') opts.failOn = value();
|
|
46
|
+
else if (arg === '--ignore') opts.ignore.push(value());
|
|
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());
|
|
50
|
+
else if (arg === '--model') opts.model = value();
|
|
51
|
+
else if (arg === '--no-color') opts.color = false;
|
|
52
|
+
else if (arg.startsWith('-')) throw new Error(`Unknown option ${arg}`);
|
|
53
|
+
else opts.target = arg;
|
|
54
|
+
}
|
|
55
|
+
if (!['text', 'json', 'markdown'].includes(opts.format)) throw new Error(`Unknown format "${opts.format}"`);
|
|
56
|
+
if (opts.failOn !== 'none' && !SEVERITIES.includes(opts.failOn)) throw new Error(`Unknown severity "${opts.failOn}"`);
|
|
57
|
+
return opts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
62
|
+
if (opts.help) return void process.stdout.write(HELP);
|
|
63
|
+
if (opts.version) return void console.log(version);
|
|
64
|
+
|
|
65
|
+
const root = path.resolve(opts.target);
|
|
66
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`Not a directory: ${opts.target}`);
|
|
67
|
+
const result = await scan(root, { ignore: opts.ignore, checklist: opts.checklist, skipChecks: opts.skipChecks });
|
|
68
|
+
|
|
69
|
+
let aiNote = '';
|
|
70
|
+
let ai;
|
|
71
|
+
if (opts.ai) {
|
|
72
|
+
ai = await judgeFindings(result.findings, { model: opts.model, fileContents: result.fileContents });
|
|
73
|
+
if (ai.error) console.error(`AI review skipped: ${ai.error}.`);
|
|
74
|
+
result.findings = ai.findings;
|
|
75
|
+
if (ai.judged) aiNote = `AI reviewed ${ai.judged} uncertain finding(s), dismissed ${ai.dismissed}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (opts.format === 'json') process.stdout.write(`${toJson(result, ai ? { ai: { judged: ai.judged, dismissed: ai.dismissed } } : {})}\n`);
|
|
79
|
+
else if (opts.format === 'markdown') process.stdout.write(`${toMarkdown(result, { aiNote })}\n`);
|
|
80
|
+
else process.stdout.write(toText(result, { color: opts.color, aiNote }));
|
|
81
|
+
|
|
82
|
+
if (result.findings.some((f) => atLeast(f.severity, opts.failOn))) process.exitCode = 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch((error) => {
|
|
86
|
+
console.error(`shipwarden: ${error.message}`);
|
|
87
|
+
process.exitCode = 2;
|
|
88
|
+
});
|
package/src/github.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const API = process.env.GITHUB_API_URL || 'https://api.github.com';
|
|
2
|
+
|
|
3
|
+
async function request(token, method, url, body) {
|
|
4
|
+
const res = await fetch(url.startsWith('http') ? url : `${API}${url}`, {
|
|
5
|
+
method,
|
|
6
|
+
headers: {
|
|
7
|
+
authorization: `Bearer ${token}`,
|
|
8
|
+
accept: 'application/vnd.github+json',
|
|
9
|
+
'x-github-api-version': '2022-11-28',
|
|
10
|
+
'user-agent': 'shipwarden',
|
|
11
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
12
|
+
},
|
|
13
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => '');
|
|
17
|
+
const error = new Error(`GitHub API ${method} ${url} returned ${res.status}: ${text.slice(0, 200)}`);
|
|
18
|
+
error.status = res.status;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
return { data: res.status === 204 ? null : await res.json(), link: res.headers.get('link') };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function paginate(token, url) {
|
|
25
|
+
const items = [];
|
|
26
|
+
for (let next = url; next; ) {
|
|
27
|
+
const { data, link } = await request(token, 'GET', next);
|
|
28
|
+
items.push(...data);
|
|
29
|
+
next = link?.match(/<([^>]+)>;\s*rel="next"/)?.[1];
|
|
30
|
+
}
|
|
31
|
+
return items;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Paths added or modified by the pull request. */
|
|
35
|
+
export async function listPullFiles({ token, owner, repo, number }) {
|
|
36
|
+
const files = await paginate(token, `/repos/${owner}/${repo}/pulls/${number}/files?per_page=100`);
|
|
37
|
+
return new Set(files.filter((f) => f.status !== 'removed').map((f) => f.filename));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Edit our previous comment in place instead of posting a new one on every push. */
|
|
41
|
+
export async function upsertComment({ token, owner, repo, number, marker, body, createIfMissing }) {
|
|
42
|
+
const comments = await paginate(token, `/repos/${owner}/${repo}/issues/${number}/comments?per_page=100`);
|
|
43
|
+
const existing = comments.find((c) => c.body?.includes(marker));
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (existing.body === body) return 'unchanged';
|
|
46
|
+
await request(token, 'PATCH', `/repos/${owner}/${repo}/issues/comments/${existing.id}`, { body });
|
|
47
|
+
return 'updated';
|
|
48
|
+
}
|
|
49
|
+
if (!createIfMissing) return 'skipped';
|
|
50
|
+
await request(token, 'POST', `/repos/${owner}/${repo}/issues/${number}/comments`, { body });
|
|
51
|
+
return 'created';
|
|
52
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { SEVERITIES } from './util.js';
|
|
2
|
+
|
|
3
|
+
export const MARKER = '<!-- shipwarden -->';
|
|
4
|
+
const ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '⚪' };
|
|
5
|
+
|
|
6
|
+
export function countBySeverity(findings) {
|
|
7
|
+
const counts = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
|
8
|
+
for (const f of findings) counts[f.severity]++;
|
|
9
|
+
return counts;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summaryLine(findings) {
|
|
13
|
+
const counts = countBySeverity(findings);
|
|
14
|
+
return SEVERITIES.filter((s) => counts[s]).map((s) => `${counts[s]} ${s}`).join(' · ');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
18
|
+
const cell = (text) => text.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{findings: object[], filesScanned: number, stack: string[]}} result
|
|
22
|
+
* @param {{link?: (file: string, line: number) => string, scopeNote?: string, aiNote?: string, maxItems?: number}} opts
|
|
23
|
+
*/
|
|
24
|
+
export function toMarkdown(result, { link, scopeNote, aiNote, maxItems = 30 } = {}) {
|
|
25
|
+
const { findings } = result;
|
|
26
|
+
const scanned = `Scanned ${plural(result.filesScanned, 'file')}${scopeNote ? ` · reporting on ${scopeNote}` : ''}${result.stack.length ? ` · ${result.stack.join(', ')}` : ''}`;
|
|
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
|
+
|
|
29
|
+
if (!findings.length) return [MARKER, '### ✅ ShipWarden: no security issues found', '', ...checklistMarkdown(result.checklist), ...footer].join('\n');
|
|
30
|
+
|
|
31
|
+
const shown = findings.slice(0, maxItems);
|
|
32
|
+
const where = (f) => (link ? `[\`${f.file}:${f.line}\`](${link(f.file, f.line)})` : `\`${f.file}:${f.line}\``);
|
|
33
|
+
const out = [
|
|
34
|
+
MARKER,
|
|
35
|
+
`### 🛡️ ShipWarden found ${plural(findings.length, 'security issue')}`,
|
|
36
|
+
'',
|
|
37
|
+
`**${summaryLine(findings)}**`,
|
|
38
|
+
'',
|
|
39
|
+
'| | Issue | Where |',
|
|
40
|
+
'|---|---|---|',
|
|
41
|
+
...shown.map((f) => `| ${ICON[f.severity]} | ${cell(f.title)} | ${where(f)} |`),
|
|
42
|
+
'',
|
|
43
|
+
];
|
|
44
|
+
if (findings.length > shown.length) out.push(`_…and ${findings.length - shown.length} more. Run \`npx shipwarden\` locally for the full list._`, '');
|
|
45
|
+
|
|
46
|
+
for (const f of shown) {
|
|
47
|
+
out.push(
|
|
48
|
+
`<details><summary>${ICON[f.severity]} <b>${f.title.replace(/`([^`]+)`/g, '<code>$1</code>')}</b> · <code>${f.file}:${f.line}</code></summary>`,
|
|
49
|
+
'',
|
|
50
|
+
f.message,
|
|
51
|
+
'',
|
|
52
|
+
`**How to fix:** ${f.ai?.fix || f.fix}`,
|
|
53
|
+
);
|
|
54
|
+
if (f.ai) out.push('', `> 🤖 AI review (${f.ai.verdict === 'real' ? 'confirmed' : 'unsure'}): ${f.ai.reason}`);
|
|
55
|
+
out.push('', '</details>', '');
|
|
56
|
+
}
|
|
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>', ''];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
79
|
+
const escapeProp = (s) => escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
80
|
+
const LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'notice' };
|
|
81
|
+
|
|
82
|
+
/** GitHub workflow commands that show each finding inline on the PR diff. */
|
|
83
|
+
export function toAnnotations(findings) {
|
|
84
|
+
return findings.map(
|
|
85
|
+
(f) => `::${LEVEL[f.severity]} file=${escapeProp(f.file)},line=${f.line},title=${escapeProp(`ShipWarden: ${f.title.replace(/`/g, '')}`)}::${escapeData(`${f.message}\n\nFix: ${f.ai?.fix || f.fix}`)}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ANSI = { critical: '\x1b[41;97;1m', high: '\x1b[43;30;1m', medium: '\x1b[33;1m', low: '\x1b[37m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[32;1m', reset: '\x1b[0m' };
|
|
90
|
+
|
|
91
|
+
export function toText(result, { color = false, aiNote } = {}) {
|
|
92
|
+
const c = (code, s) => (color ? `${ANSI[code]}${s}${ANSI.reset}` : s);
|
|
93
|
+
const strip = (s) => s.replace(/`/g, '');
|
|
94
|
+
const head = `ShipWarden · scanned ${plural(result.filesScanned, 'file')}${result.stack.length ? ` (${result.stack.join(', ')})` : ''} in ${result.durationMs}ms${aiNote ? ` · ${aiNote}` : ''}`;
|
|
95
|
+
const lines = [c('dim', head), ''];
|
|
96
|
+
if (!result.findings.length) lines.push(c('green', '✔ No security issues found.'), '');
|
|
97
|
+
for (const f of result.findings) {
|
|
98
|
+
lines.push(
|
|
99
|
+
`${c(f.severity, ` ${f.severity.toUpperCase()} `)} ${c('bold', strip(f.title))}`,
|
|
100
|
+
` ${c('dim', `${f.file}:${f.line}`)}`,
|
|
101
|
+
` ${strip(f.message)}`,
|
|
102
|
+
` ${c('bold', 'Fix:')} ${strip(f.ai?.fix || f.fix)}`,
|
|
103
|
+
);
|
|
104
|
+
if (f.ai) lines.push(` ${c('dim', `AI review (${f.ai.verdict}): ${f.ai.reason}`)}`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
}
|
|
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')}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function toJson(result, extra = {}) {
|
|
127
|
+
const { fileContents, ...rest } = result;
|
|
128
|
+
return JSON.stringify({ ...rest, ...extra }, null, 2);
|
|
129
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { blank, lineAt, makeFinding } from '../util.js';
|
|
2
|
+
|
|
3
|
+
const WRITE_OPS = new Set(['write', 'create', 'update', 'delete']);
|
|
4
|
+
const READ_OPS = new Set(['read', 'get', 'list']);
|
|
5
|
+
|
|
6
|
+
function access(ops) {
|
|
7
|
+
const reads = ops.some((op) => READ_OPS.has(op));
|
|
8
|
+
const writes = ops.some((op) => WRITE_OPS.has(op));
|
|
9
|
+
return reads && writes ? 'read and write' : writes ? 'write to' : 'read';
|
|
10
|
+
}
|
|
11
|
+
const ALLOW = /\ballow\s+(?<ops>[a-z]+(?:\s*,\s*[a-z]+)*)\s*(?<cond>:\s*if\b[^;]*)?;/g;
|
|
12
|
+
const MATCH_TOKEN = /match\s+((?:[^\s{]|\{[^}]*\})+)\s*\{|\{|\}/g;
|
|
13
|
+
|
|
14
|
+
function stripComments(rules) {
|
|
15
|
+
return rules.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/\/\/[^\n]*/g, blank);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The `match` paths enclosing a position, outermost first. */
|
|
19
|
+
function matchPathsAt(rules, index) {
|
|
20
|
+
const stack = [];
|
|
21
|
+
for (const m of rules.matchAll(MATCH_TOKEN)) {
|
|
22
|
+
if (m.index >= index) break;
|
|
23
|
+
if (m[1]) stack.push(m[1]);
|
|
24
|
+
else if (m[0] === '{') stack.push(null);
|
|
25
|
+
else stack.pop();
|
|
26
|
+
}
|
|
27
|
+
return stack.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const openRules = {
|
|
31
|
+
id: 'firebase-open-rules',
|
|
32
|
+
title: 'Firebase security rules are open',
|
|
33
|
+
severity: 'critical',
|
|
34
|
+
check(file) {
|
|
35
|
+
if (!/(^|\/)(firestore|storage)\.rules$|\.rules$/.test(file.path)) return [];
|
|
36
|
+
const rules = stripComments(file.content);
|
|
37
|
+
const service = /service\s+firebase\.storage/.test(rules) ? 'Storage bucket' : 'database';
|
|
38
|
+
const findings = [];
|
|
39
|
+
for (const m of rules.matchAll(ALLOW)) {
|
|
40
|
+
const ops = m.groups.ops.split(/\s*,\s*/);
|
|
41
|
+
const writes = ops.some((op) => WRITE_OPS.has(op));
|
|
42
|
+
const cond = (m.groups.cond ?? '').replace(/^:\s*if\s*/, '').trim();
|
|
43
|
+
const paths = matchPathsAt(rules, m.index);
|
|
44
|
+
const inner = paths.at(-1) ?? '';
|
|
45
|
+
const catchAll = /\{\w+=\*\*\}/.test(inner);
|
|
46
|
+
const topLevelCatchAll = catchAll && paths.length <= 2;
|
|
47
|
+
const line = lineAt(file.content, m.index);
|
|
48
|
+
const testMode = /request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)/.exec(cond);
|
|
49
|
+
|
|
50
|
+
if (cond === '' || cond === 'true') {
|
|
51
|
+
if (!writes && (!catchAll || service !== 'database')) {
|
|
52
|
+
findings.push(
|
|
53
|
+
makeFinding(this, file.path, line, {
|
|
54
|
+
severity: catchAll ? 'medium' : 'low',
|
|
55
|
+
confidence: 'medium',
|
|
56
|
+
title: catchAll ? `Anyone can read every file in your ${service}` : `Anyone can read \`${inner}\``,
|
|
57
|
+
message: `These rules let anyone on the internet read ${catchAll ? `every file in your ${service}` : `\`${inner}\``} without signing in. Fine for truly public data (like product images), a leak for anything personal.`,
|
|
58
|
+
fix: 'If this is user data, require ownership: `allow read: if request.auth != null && request.auth.uid == userId;`',
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
findings.push(
|
|
64
|
+
makeFinding(this, file.path, line, {
|
|
65
|
+
title: `Anyone can ${access(ops)} your ${service}`,
|
|
66
|
+
message: `\`allow ${m.groups.ops}${cond ? ': if true' : ''}\` on \`${inner || '/'}\` lets anyone on the internet ${ops.join('/')} without signing in.${writes ? ' They can wipe or rewrite your data.' : ''}`,
|
|
67
|
+
fix: 'Lock rules down per collection, e.g. `match /users/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; }`',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
} else if (testMode) {
|
|
71
|
+
const [, y, mo, d] = testMode;
|
|
72
|
+
findings.push(
|
|
73
|
+
makeFinding(this, file.path, line, {
|
|
74
|
+
title: `Firebase "test mode" rules are still in place`,
|
|
75
|
+
message: `Until ${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')} anyone can ${ops.join('/')} your entire ${service}. After that date every request is denied and the app stops working.`,
|
|
76
|
+
fix: 'Replace the `request.time < timestamp.date(...)` rule with real per-collection rules before launch.',
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
} else if (topLevelCatchAll && /^request\.auth(\.uid)?\s*!=\s*null$/.test(cond)) {
|
|
80
|
+
findings.push(
|
|
81
|
+
makeFinding(this, file.path, line, {
|
|
82
|
+
severity: 'high',
|
|
83
|
+
title: `Any signed-in user can ${access(ops)} your whole ${service}`,
|
|
84
|
+
message: `This catch-all rule only checks that the caller is signed in. Anyone can create a free account in your app and then ${ops.join('/')} every other user's data.`,
|
|
85
|
+
fix: 'Replace the catch-all with per-collection rules that check ownership, e.g. `request.auth.uid == resource.data.ownerId`.',
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const openRealtimeDb = {
|
|
95
|
+
id: 'firebase-rtdb-open',
|
|
96
|
+
title: 'Realtime Database rules are open',
|
|
97
|
+
severity: 'critical',
|
|
98
|
+
check(file) {
|
|
99
|
+
if (!/(^|\/)database\.rules\.json$/.test(file.path)) return [];
|
|
100
|
+
const findings = [];
|
|
101
|
+
for (const m of file.content.matchAll(/"\.(read|write)"\s*:\s*(?:true|"true")/g)) {
|
|
102
|
+
const write = m[1] === 'write';
|
|
103
|
+
findings.push(
|
|
104
|
+
makeFinding(this, file.path, lineAt(file.content, m.index), {
|
|
105
|
+
severity: write ? 'critical' : 'high',
|
|
106
|
+
title: `Anyone can ${m[1]} your Realtime Database`,
|
|
107
|
+
message: `\`".${m[1]}": true\` lets anyone on the internet ${m[1]} this part of your database without signing in.`,
|
|
108
|
+
fix: 'Use `"auth != null && auth.uid === $uid"` style rules scoped to each user\'s data.',
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default [openRules, openRealtimeDb];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import secrets from './secrets.js';
|
|
2
|
+
import supabase from './supabase.js';
|
|
3
|
+
import firebase from './firebase.js';
|
|
4
|
+
import web from './web.js';
|
|
5
|
+
|
|
6
|
+
export const RULES = [...secrets, ...supabase, ...firebase, ...web];
|
|
7
|
+
export const RULE_IDS = new Set(RULES.map((r) => r.id));
|