launchprep 0.0.1 → 0.1.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/README.md +76 -12
- package/bin/launchprep.mjs +24 -0
- package/net/client.mjs +40 -0
- package/net/commands.mjs +156 -0
- package/net/consent.mjs +57 -0
- package/net/credentials.mjs +33 -0
- package/package.json +27 -12
- package/scripts/verify-readonly.mjs +80 -0
- package/src/brand.mjs +13 -0
- package/src/checks-ai.mjs +255 -0
- package/src/checks-auth.mjs +263 -0
- package/src/checks-authz.mjs +179 -0
- package/src/checks-batch2.mjs +385 -0
- package/src/checks-batch3.mjs +327 -0
- package/src/checks-batch4.mjs +529 -0
- package/src/checks-deploy.mjs +272 -0
- package/src/checks-frameworks.mjs +337 -0
- package/src/checks.mjs +209 -0
- package/src/detect.mjs +304 -0
- package/src/digest.mjs +169 -0
- package/src/fs-scan.mjs +118 -0
- package/src/gate.mjs +103 -0
- package/src/index.mjs +71 -0
- package/src/report.mjs +101 -0
- package/src/rules.json +3651 -0
- package/src/workspace.mjs +0 -0
- package/bin/cli.js +0 -11
package/src/checks.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Tier-1 checks: deterministic, no LLM, no network. Each returns findings with
|
|
2
|
+
// a file:line so the user can go straight to it.
|
|
3
|
+
import { AUTHZ_CHECKS } from './checks-authz.mjs';
|
|
4
|
+
import { AI_CHECKS } from './checks-ai.mjs';
|
|
5
|
+
import { DEPLOY_CHECKS } from './checks-deploy.mjs';
|
|
6
|
+
import { AUTH_CHECKS } from './checks-auth.mjs';
|
|
7
|
+
import { FRAMEWORK_CHECKS } from './checks-frameworks.mjs';
|
|
8
|
+
import { BATCH2_CHECKS } from './checks-batch2.mjs';
|
|
9
|
+
import { BATCH3_CHECKS } from './checks-batch3.mjs';
|
|
10
|
+
import { BATCH4_CHECKS } from './checks-batch4.mjs';
|
|
11
|
+
|
|
12
|
+
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
13
|
+
({ id, title, severity, file, line, detail, fix });
|
|
14
|
+
|
|
15
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
16
|
+
|
|
17
|
+
const BASE_CHECKS = [
|
|
18
|
+
|
|
19
|
+
{ id:'SEC-002', run(repo){
|
|
20
|
+
const gi = repo.read('.gitignore');
|
|
21
|
+
if (gi === null) return [];
|
|
22
|
+
if (/^\s*\.env/m.test(gi)) return [];
|
|
23
|
+
return [finding('SEC-002','.env is not in .gitignore','high','.gitignore',1,
|
|
24
|
+
'Nothing stops a future commit from publishing your secrets.',
|
|
25
|
+
'Add a line containing .env* to .gitignore')];
|
|
26
|
+
}},
|
|
27
|
+
|
|
28
|
+
{ id:'SEC-004', run(repo){
|
|
29
|
+
// Many NEXT_PUBLIC_* keys are public BY DESIGN. Stripe's publishable key and
|
|
30
|
+
// Supabase's anon key belong in the browser; flagging them as CRITICAL is the
|
|
31
|
+
// fastest way to lose a user's trust in every other finding.
|
|
32
|
+
const PUBLIC_BY_DESIGN = /(PUBLISHABLE|ANON_KEY|PUBLIC_KEY|SITE_KEY|VAPID|POSTHOG|SENTRY_DSN|MEASUREMENT_ID|^(GA|GTM)_|TURNSTILE|RECAPTCHA_SITE|HCAPTCHA|MAPBOX|GOOGLE_MAPS|ALGOLIA_SEARCH|AMPLITUDE|MIXPANEL_TOKEN)/;
|
|
33
|
+
// names that are settings, not credentials
|
|
34
|
+
const NOT_A_CREDENTIAL = /(ENABLE|DISABLE|REQUIRE|ALLOW|SHOW|USE)_/;
|
|
35
|
+
// unambiguous: these must never be public
|
|
36
|
+
const DEFINITELY_SECRET = /(SECRET|SERVICE_ROLE|PRIVATE_KEY|_SK_|OPENAI|ANTHROPIC|CLAUDE|GROQ|SENDGRID|RESEND|TWILIO|AWS_SECRET|DATABASE_URL|CONNECTION_STRING)/;
|
|
37
|
+
|
|
38
|
+
const out=[];
|
|
39
|
+
for (const f of repo.files){
|
|
40
|
+
if (!f.text || !/\.(ts|tsx|js|jsx|mjs|env)$/.test(f.path)) continue;
|
|
41
|
+
if (/\.(example|sample|template)$/i.test(f.path)) continue;
|
|
42
|
+
const re=/\b(NEXT_PUBLIC_|VITE_|REACT_APP_|PUBLIC_)([A-Z0-9_]*(KEY|SECRET|TOKEN|PASSWORD)[A-Z0-9_]*)/g;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m=re.exec(f.text))){
|
|
45
|
+
const full = m[1]+m[2];
|
|
46
|
+
if (PUBLIC_BY_DESIGN.test(m[2]) || NOT_A_CREDENTIAL.test(m[2])) continue;
|
|
47
|
+
|
|
48
|
+
const certain = DEFINITELY_SECRET.test(m[2]);
|
|
49
|
+
out.push(finding('SEC-004',
|
|
50
|
+
certain ? 'Server secret exposed to the browser' : 'Possible secret exposed to the browser',
|
|
51
|
+
certain ? 'critical' : 'medium',
|
|
52
|
+
f.path, lineOf(f.text,m.index),
|
|
53
|
+
certain
|
|
54
|
+
? `${full} — the ${m[1]} prefix ships this value to every visitor's browser, and this is a credential that must stay on your server.`
|
|
55
|
+
: `${full} — the ${m[1]} prefix makes this readable by anyone who opens your site. If it is a provider's publishable key that is fine; if it grants write access it is not.`,
|
|
56
|
+
certain
|
|
57
|
+
? 'Remove the public prefix, read it only on the server, and rotate the key.'
|
|
58
|
+
: 'Confirm this key is meant to be public. If it is not, drop the prefix and rotate it.'));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}},
|
|
63
|
+
|
|
64
|
+
{ id:'SEC-003', run(repo){
|
|
65
|
+
const out=[];
|
|
66
|
+
const PATTERNS=[
|
|
67
|
+
[/\bsk-ant-[A-Za-z0-9_-]{20,}/g,'Anthropic API key'],
|
|
68
|
+
[/\bsk-[A-Za-z0-9]{32,}/g,'OpenAI API key'],
|
|
69
|
+
[/\bAKIA[0-9A-Z]{16}\b/g,'AWS access key'],
|
|
70
|
+
[/\bghp_[A-Za-z0-9]{30,}/g,'GitHub token'],
|
|
71
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,'Slack token'],
|
|
72
|
+
];
|
|
73
|
+
for (const f of repo.files){
|
|
74
|
+
// placeholder/sample env files are meant to be committed - never a secret finding
|
|
75
|
+
if (!f.text || /\.(md|lock)$/.test(f.path)) continue;
|
|
76
|
+
if (/\.(example|sample|template|dist)$/i.test(f.path) ||
|
|
77
|
+
/\.env\.(example|sample|template)/i.test(f.path)) continue;
|
|
78
|
+
for (const [re,label] of PATTERNS){
|
|
79
|
+
let m; re.lastIndex=0;
|
|
80
|
+
while ((m=re.exec(f.text))){
|
|
81
|
+
out.push(finding('SEC-003',`Hardcoded ${label}`,'critical',
|
|
82
|
+
f.path, lineOf(f.text,m.index),
|
|
83
|
+
`A live ${label} is written directly into this file.`,
|
|
84
|
+
'Move it to an environment variable and rotate the key — it must be treated as leaked.'));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}},
|
|
90
|
+
|
|
91
|
+
{ id:'AUTH-001', run(repo,profile){
|
|
92
|
+
if (!profile.has_accounts) return [];
|
|
93
|
+
const out=[];
|
|
94
|
+
for (const f of repo.files){
|
|
95
|
+
if (!f.text || !/\.(ts|tsx|js|jsx)$/.test(f.path)) continue;
|
|
96
|
+
const re=/(localStorage|sessionStorage)\.setItem\(\s*['"`][^'"`]*(token|jwt|auth|session)/gi;
|
|
97
|
+
let m;
|
|
98
|
+
while ((m=re.exec(f.text))){
|
|
99
|
+
out.push(finding('AUTH-001','Session token stored in browser storage','high',
|
|
100
|
+
f.path, lineOf(f.text,m.index),
|
|
101
|
+
'Anything in localStorage is readable by any script on the page, so one XSS steals the login.',
|
|
102
|
+
'Store the session in an httpOnly, Secure, SameSite cookie instead.'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}},
|
|
107
|
+
|
|
108
|
+
{ id:'AI-003', run(repo,profile){
|
|
109
|
+
if (!profile.calls_llm) return [];
|
|
110
|
+
const out=[];
|
|
111
|
+
for (const f of repo.files){
|
|
112
|
+
if (!f.text || !/\.(ts|tsx|js|mjs|py)$/.test(f.path)) continue;
|
|
113
|
+
const re=/\.messages\.create\s*\(|\.chat\.completions\.create\s*\(/g;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m=re.exec(f.text))){
|
|
116
|
+
const window_=f.text.slice(m.index, m.index+600);
|
|
117
|
+
if (/max_tokens|maxTokens|max_output_tokens/.test(window_)) continue;
|
|
118
|
+
out.push(finding('AI-003','AI call with no token ceiling','high',
|
|
119
|
+
f.path, lineOf(f.text,m.index),
|
|
120
|
+
'Without max_tokens a single request can generate — and bill — without limit.',
|
|
121
|
+
'Set max_tokens on every completion call.'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}},
|
|
126
|
+
|
|
127
|
+
{ id:'DEP-010', run(repo){
|
|
128
|
+
if (!repo.exists('package.json')) return [];
|
|
129
|
+
const has = ['package-lock.json','pnpm-lock.yaml','yarn.lock','bun.lockb'].some(l=>repo.exists(l));
|
|
130
|
+
return has ? [] : [finding('DEP-010','No lockfile committed','medium','package.json',1,
|
|
131
|
+
'Without a lockfile your production build can install different versions than you tested.',
|
|
132
|
+
'Commit the lockfile your package manager generates.')];
|
|
133
|
+
}},
|
|
134
|
+
|
|
135
|
+
{ id:'API-002', run(repo,profile){
|
|
136
|
+
if (!profile.is_public) return [];
|
|
137
|
+
const out=[];
|
|
138
|
+
for (const f of repo.files){
|
|
139
|
+
if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
|
|
140
|
+
const re=/catch\s*\(\s*(\w+)\s*\)\s*\{[^}]{0,200}?(?:json|send)\s*\(\s*\{[^}]{0,120}?\1(?:\.message|\.stack)?/gs;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m=re.exec(f.text))){
|
|
143
|
+
out.push(finding('API-002','Internal error details returned to the client','high',
|
|
144
|
+
f.path, lineOf(f.text,m.index),
|
|
145
|
+
'Raw error objects leak file paths, query fragments and library versions to anyone who can trigger them.',
|
|
146
|
+
'Log the real error server-side; return a generic message and a reference id.'));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}},
|
|
151
|
+
|
|
152
|
+
{ id:'UP-002', run(repo,profile){
|
|
153
|
+
if (!profile.has_file_uploads) return [];
|
|
154
|
+
const hasLimit = repo.grep(/limits\s*:\s*\{[^}]*fileSize|maxFileSize|MAX_FILE_SIZE/).length;
|
|
155
|
+
if (hasLimit) return [];
|
|
156
|
+
const site = repo.grep(/multer\s*\(|formidable\s*\(|\.upload\s*\(/)[0];
|
|
157
|
+
return site ? [finding('UP-002','File uploads have no size limit','high',
|
|
158
|
+
site.path, 1,
|
|
159
|
+
'One user can upload a file large enough to fill your disk or exhaust memory.',
|
|
160
|
+
'Set an explicit maximum file size on the upload handler.')] : [];
|
|
161
|
+
}},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
// AI checks supersede the earlier inline AI-003
|
|
165
|
+
const BASE = BASE_CHECKS.filter(c => c.id !== 'AI-003');
|
|
166
|
+
// the fuller AUTH_CHECKS version supersedes the early inline SEC-001
|
|
167
|
+
const B2 = BASE.filter(c => c.id !== 'SEC-001');
|
|
168
|
+
export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS];
|
|
169
|
+
|
|
170
|
+
// Test and fixture files are not deployed. A "vulnerability" in a spec file is
|
|
171
|
+
// noise, and noise is what makes people stop reading findings.
|
|
172
|
+
const IS_TEST = /(^|\/)(tests?|__tests__|__mocks__|spec|e2e|fixtures?|examples?)\/|\.(test|spec)\.[jt]sx?$|\.stories\.[jt]sx?$/;
|
|
173
|
+
|
|
174
|
+
function withoutTests(repo){
|
|
175
|
+
const files = repo.files.filter(f => !IS_TEST.test(f.path));
|
|
176
|
+
return { ...repo, files,
|
|
177
|
+
has: (re) => files.some(f => re.test(f.path)),
|
|
178
|
+
find: (re) => files.filter(f => re.test(f.path)),
|
|
179
|
+
grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|sql|prisma)$/) =>
|
|
180
|
+
files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Some facts live at the repo root and nowhere else - the lockfile, the CI
|
|
185
|
+
// config, .gitignore. In a monorepo every workspace correctly lacks them, so a
|
|
186
|
+
// check that looks for one from inside apps/web fires on every package and is
|
|
187
|
+
// wrong every time. Those declare scope:'root' and run once, against the root.
|
|
188
|
+
export function runRootChecks(repo, profile, applicableIds){
|
|
189
|
+
repo = withoutTests(repo);
|
|
190
|
+
const out=[];
|
|
191
|
+
for (const c of CHECKS){
|
|
192
|
+
if (c.scope !== 'root') continue;
|
|
193
|
+
if (applicableIds && !applicableIds.has(c.id)) continue;
|
|
194
|
+
try { out.push(...c.run(repo, profile)); } catch {}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function runChecks(repo, profile, applicableIds){
|
|
200
|
+
repo = withoutTests(repo);
|
|
201
|
+
const out=[];
|
|
202
|
+
for (const c of CHECKS){
|
|
203
|
+
if (c.scope === 'root') continue; // runRootChecks owns these
|
|
204
|
+
if (applicableIds && !applicableIds.has(c.id)) continue;
|
|
205
|
+
try { out.push(...c.run(repo, profile)); } catch {}
|
|
206
|
+
}
|
|
207
|
+
const rank={critical:0,high:1,medium:2,low:3};
|
|
208
|
+
return out.sort((a,b)=>rank[a.severity]-rank[b.severity]);
|
|
209
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Profile detection. Every fact carries evidence and a confidence level.
|
|
2
|
+
// LOW confidence never produces a `fail` — the gate turns it into a question.
|
|
3
|
+
import { readPackageJson, allDeps, stripComments, isWorkspaceRoot } from './fs-scan.mjs';
|
|
4
|
+
|
|
5
|
+
const fact = (value, confidence, evidence = []) => ({ value, confidence, evidence });
|
|
6
|
+
const dep = (deps, ...names) => names.find(n => deps[n] !== undefined);
|
|
7
|
+
|
|
8
|
+
// ---------- stack ----------
|
|
9
|
+
function detectFramework(repo, deps) {
|
|
10
|
+
// ---- python ----
|
|
11
|
+
if (repo.exists('manage.py') || repo.has(/(^|\/)manage\.py$/))
|
|
12
|
+
return fact('django', 'high', ['manage.py']);
|
|
13
|
+
if (repo.grep(/^\s*(DJANGO_SETTINGS_MODULE|INSTALLED_APPS)\s*=/m, /\.py$/).length ||
|
|
14
|
+
repo.grep(/^\s*[Dd]jango[><=~]/m, /requirements.*\.txt$|Pipfile$|pyproject\.toml$/).length)
|
|
15
|
+
return fact('django', 'high', ['django settings']);
|
|
16
|
+
if (repo.grep(/from fastapi|FastAPI\s*\(/, /\.py$/).length)
|
|
17
|
+
return fact('fastapi', 'high', ['FastAPI import']);
|
|
18
|
+
if (repo.grep(/from flask import|Flask\s*\(__name__/, /\.py$/).length)
|
|
19
|
+
return fact('flask', 'high', ['Flask import']);
|
|
20
|
+
|
|
21
|
+
// ---- ruby ----
|
|
22
|
+
const gemfile = repo.read('Gemfile') || repo.find(/(^|\/)Gemfile$/)[0]?.text || '';
|
|
23
|
+
if (/gem\s+['"]rails['"]/.test(gemfile) || repo.exists('config/application.rb') ||
|
|
24
|
+
repo.has(/config\/application\.rb$/))
|
|
25
|
+
return fact('rails', 'high', ['rails in Gemfile']);
|
|
26
|
+
if (/gem\s+['"]sinatra['"]/.test(gemfile)) return fact('sinatra', 'high', ['sinatra gem']);
|
|
27
|
+
|
|
28
|
+
// ---- php ----
|
|
29
|
+
const composer = repo.read('composer.json') || '';
|
|
30
|
+
if (repo.exists('artisan') || /laravel\/framework/.test(composer))
|
|
31
|
+
return fact('laravel', 'high', ['artisan / laravel framework']);
|
|
32
|
+
if (/symfony\//.test(composer)) return fact('symfony', 'high', ['symfony packages']);
|
|
33
|
+
|
|
34
|
+
// ---- javascript ----
|
|
35
|
+
const byDep = [
|
|
36
|
+
['next','next'], ['remix','@remix-run/react'], ['nuxt','nuxt'],
|
|
37
|
+
['sveltekit','@sveltejs/kit'], ['nest','@nestjs/core'],
|
|
38
|
+
['express','express'], ['fastify','fastify'], ['react-native','react-native'],
|
|
39
|
+
];
|
|
40
|
+
for (const [name, d] of byDep) if (deps[d]) return fact(name, 'high', [`dependency ${d}`]);
|
|
41
|
+
|
|
42
|
+
// ---- mobile ----
|
|
43
|
+
if (repo.exists('pubspec.yaml')) return fact('flutter', 'high', ['pubspec.yaml']);
|
|
44
|
+
if (repo.has(/Package\.swift$|\.xcodeproj/)) return fact('swiftui', 'high', ['xcode project']);
|
|
45
|
+
return fact('other', 'low', []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `root` is the whole repo when profiling one package of a monorepo: the app
|
|
49
|
+
// package imports the db package, so its schema lives somewhere else entirely.
|
|
50
|
+
function detectDatabase(repo, deps, root) {
|
|
51
|
+
if (deps['@supabase/supabase-js'] || repo.exists('supabase/config.toml') ||
|
|
52
|
+
root?.exists('supabase/config.toml'))
|
|
53
|
+
return fact('supabase', 'high', ['@supabase/supabase-js']);
|
|
54
|
+
|
|
55
|
+
const prisma = repo.find(/schema\.prisma$/)[0] || root?.find(/schema\.prisma$/)[0];
|
|
56
|
+
if (prisma?.text) {
|
|
57
|
+
const m = prisma.text.match(/provider\s*=\s*"(\w+)"/);
|
|
58
|
+
if (m) return fact(m[1] === 'postgresql' ? 'postgres' : m[1], 'high', ['prisma schema provider']);
|
|
59
|
+
}
|
|
60
|
+
// ORM present but schema elsewhere - still tells us a database exists
|
|
61
|
+
if (deps['@prisma/client'] || deps['prisma'])
|
|
62
|
+
return fact('postgres', 'low', ['prisma client, schema not in this package']);
|
|
63
|
+
if (deps['drizzle-orm']) {
|
|
64
|
+
const d = (root || repo).grep(/pgTable\s*\(/) .length ? 'postgres'
|
|
65
|
+
: (root || repo).grep(/mysqlTable\s*\(/).length ? 'mysql'
|
|
66
|
+
: (root || repo).grep(/sqliteTable\s*\(/).length ? 'sqlite' : 'postgres';
|
|
67
|
+
return fact(d, 'high', ['drizzle schema']);
|
|
68
|
+
}
|
|
69
|
+
if (deps['mongoose']) return fact('mongodb','high',['mongoose']);
|
|
70
|
+
if (deps['typeorm'] || deps['sequelize']) return fact('postgres','low',['orm present']);
|
|
71
|
+
if (deps['sqlalchemy'] || deps['SQLAlchemy'] || deps['psycopg2'] || deps['psycopg2-binary'])
|
|
72
|
+
return fact('postgres','high',['python postgres driver']);
|
|
73
|
+
|
|
74
|
+
// django settings.py -> DATABASES ENGINE
|
|
75
|
+
const dj = repo.grep(/ENGINE['"]?\s*:\s*['"]django\.db\.backends\.(\w+)/, /\.py$/)[0]
|
|
76
|
+
|| root?.grep(/ENGINE['"]?\s*:\s*['"]django\.db\.backends\.(\w+)/, /\.py$/)[0];
|
|
77
|
+
if (dj) {
|
|
78
|
+
const m = dj.text.match(/django\.db\.backends\.(\w+)/);
|
|
79
|
+
const map = { postgresql: 'postgres', postgresql_psycopg2: 'postgres', mysql: 'mysql', sqlite3: 'sqlite', oracle: 'other' };
|
|
80
|
+
if (m) return fact(map[m[1]] || m[1], 'high', ['django DATABASES engine']);
|
|
81
|
+
}
|
|
82
|
+
// rails config/database.yml -> adapter
|
|
83
|
+
const ry = repo.read('config/database.yml') || root?.read('config/database.yml');
|
|
84
|
+
if (ry) {
|
|
85
|
+
const m = ry.match(/adapter:\s*(\w+)/);
|
|
86
|
+
const map = { postgresql: 'postgres', postgres: 'postgres', mysql2: 'mysql', sqlite3: 'sqlite' };
|
|
87
|
+
if (m) return fact(map[m[1]] || m[1], 'high', ['rails database.yml adapter']);
|
|
88
|
+
}
|
|
89
|
+
// requirements.txt / Gemfile drivers
|
|
90
|
+
if (repo.grep(/^\s*psycopg2|^\s*asyncpg/m, /requirements.*\.txt$|Pipfile$/).length)
|
|
91
|
+
return fact('postgres','high',['python postgres driver']);
|
|
92
|
+
const gem = repo.read('Gemfile') || '';
|
|
93
|
+
if (/gem\s+['"]pg['"]/.test(gem)) return fact('postgres','high',['pg gem']);
|
|
94
|
+
if (/gem\s+['"]mysql2['"]/.test(gem)) return fact('mysql','high',['mysql2 gem']);
|
|
95
|
+
if (dep(deps,'pg','postgres','@neondatabase/serverless')) return fact('postgres','high',['pg driver']);
|
|
96
|
+
if (dep(deps,'mysql2','mysql')) return fact('mysql','high',['mysql driver']);
|
|
97
|
+
if (dep(deps,'mongodb','mongoose')) return fact('mongodb','high',['mongo driver']);
|
|
98
|
+
if (dep(deps,'better-sqlite3','sqlite3')) return fact('sqlite','high',['sqlite driver']);
|
|
99
|
+
if (dep(deps,'firebase','firebase-admin')) return fact('firebase','high',['firebase sdk']);
|
|
100
|
+
return fact('none','low',[]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectAuth(repo, deps) {
|
|
104
|
+
if (dep(deps,'@clerk/nextjs','@clerk/clerk-react')) return fact('clerk','high',['clerk sdk']);
|
|
105
|
+
if (dep(deps,'@auth0/nextjs-auth0')) return fact('auth0','high',['auth0 sdk']);
|
|
106
|
+
if (dep(deps,'next-auth','@auth/core')) return fact('nextauth','high',['next-auth']);
|
|
107
|
+
if (deps['@supabase/supabase-js'] && repo.grep(/auth\.(signIn|signUp|getUser|getSession)/).length)
|
|
108
|
+
return fact('supabase-auth','high',['supabase auth calls']);
|
|
109
|
+
const gemf = repo.read('Gemfile') || '';
|
|
110
|
+
if (/gem\s+['"]devise['"]/.test(gemf)) return fact('devise','high',['devise gem']);
|
|
111
|
+
if (/has_secure_password/.test((repo.grep(/has_secure_password/, /\.rb$/)[0]||{}).text || ''))
|
|
112
|
+
return fact('custom','high',['has_secure_password']);
|
|
113
|
+
if (repo.grep(/django\.contrib\.auth/, /\.py$/).length)
|
|
114
|
+
return fact('django-auth','high',['django.contrib.auth']);
|
|
115
|
+
if (dep(deps,'bcrypt','bcryptjs','argon2') ||
|
|
116
|
+
repo.grep(/(?:from|require\()\s*['"](?:bcrypt|bcryptjs|argon2)/).length)
|
|
117
|
+
return fact('custom','high',['password hashing import']);
|
|
118
|
+
return fact('none','low',[]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function detectHost(repo, deps) {
|
|
122
|
+
if (repo.exists('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
|
|
123
|
+
if (repo.exists('netlify.toml')) return fact('netlify','high',['netlify.toml']);
|
|
124
|
+
if (repo.exists('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
|
|
125
|
+
if (repo.exists('fly.toml')) return fact('fly','high',['fly.toml']);
|
|
126
|
+
if (repo.exists('railway.json')) return fact('railway','high',['railway.json']);
|
|
127
|
+
if (repo.exists('render.yaml')) return fact('render','high',['render.yaml']);
|
|
128
|
+
if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/)) return fact('vps','low',['Dockerfile — host inferred']);
|
|
129
|
+
return fact(null,'low',[]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- capability facts ----------
|
|
133
|
+
const LLM_DEPS = {
|
|
134
|
+
'@anthropic-ai/sdk':'anthropic', 'openai':'openai', 'groq-sdk':'groq',
|
|
135
|
+
'@google/generative-ai':'google', '@mistralai/mistralai':'mistral', 'ollama':'other',
|
|
136
|
+
'ai':'other', '@ai-sdk/anthropic':'anthropic', '@ai-sdk/openai':'openai',
|
|
137
|
+
};
|
|
138
|
+
function detectLlm(repo, deps) {
|
|
139
|
+
const providers = [...new Set(Object.entries(LLM_DEPS).filter(([d]) => deps[d]).map(([,p]) => p))];
|
|
140
|
+
const hits = repo.grep(/api\.(openai|anthropic)\.com|generativelanguage\.googleapis|api\.groq\.com/);
|
|
141
|
+
if (!providers.length && !hits.length) return { calls_llm: fact(false,'high',[]), providers: fact([], 'high', []) };
|
|
142
|
+
const ev = providers.length ? [`sdk: ${providers.join(', ')}`] : [`direct call in ${hits[0].path}`];
|
|
143
|
+
return { calls_llm: fact(true,'high',ev), providers: fact(providers,'high',ev) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function detectTenancy(repo, hasAccounts) {
|
|
147
|
+
const RE = /\b(tenant_id|org_id|organization_id|workspace_id|team_id|account_id)\b/;
|
|
148
|
+
const schema = repo.grep(RE, /\.(sql|prisma)$/);
|
|
149
|
+
const code = repo.grep(RE);
|
|
150
|
+
if (schema.length >= 2 || (schema.length && code.length >= 3))
|
|
151
|
+
return fact('multi-tenant-shared-db','high',[`tenant key in ${schema.length} schema file(s)`]);
|
|
152
|
+
if (schema.length || code.length >= 3)
|
|
153
|
+
return fact('multi-tenant-shared-db','low',['tenant key present but sparse']);
|
|
154
|
+
if (!hasAccounts) return fact('none','high',['no accounts']);
|
|
155
|
+
return fact('single-user','low',['accounts present, no tenant key found']);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const PII = /\b(email|phone|first_?name|last_?name|address|date_?of_?birth|dob|postal|zip_?code)\b/i;
|
|
159
|
+
const FIN = /\b(card_?number|iban|invoice|amount_cents|stripe_customer|tax_id|vat)\b/i;
|
|
160
|
+
const HEALTH = /\b(diagnosis|patient|medical_record|prescription|icd_?10)\b/i;
|
|
161
|
+
const CHILD = /\b(parental_consent|guardian|is_minor|age_gate)\b/i;
|
|
162
|
+
// only count matches inside actual schema declarations, with comments removed
|
|
163
|
+
function schemaHits(repo, re) {
|
|
164
|
+
const files = repo.files.filter(f => f.text && /\.(sql|prisma|ts|py|rb)$/.test(f.path));
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
const body = stripComments(f.text);
|
|
168
|
+
const looksLikeSchema =
|
|
169
|
+
/pgTable\s*\(|mysqlTable\s*\(|sqliteTable\s*\(|CREATE TABLE|^model\s+\w+\s*\{|class\s+\w+\(.*Model\)/mi.test(body)
|
|
170
|
+
|| /(^|\/)(schema|models?|migrations?|entities)(\/|\.)/i.test(f.path);
|
|
171
|
+
if (!looksLikeSchema) continue;
|
|
172
|
+
const m = body.match(new RegExp(re.source, 'gi'));
|
|
173
|
+
if (m) hits.push({ path: f.path, terms: [...new Set(m.map(x => x.toLowerCase()))] });
|
|
174
|
+
}
|
|
175
|
+
return hits;
|
|
176
|
+
}
|
|
177
|
+
function detectSensitivity(repo) {
|
|
178
|
+
const terms = (hits) => [...new Set(hits.flatMap(h => h.terms))];
|
|
179
|
+
// high-consequence classifications require corroboration: 2+ distinct terms
|
|
180
|
+
const health = schemaHits(repo, HEALTH), child = schemaHits(repo, CHILD);
|
|
181
|
+
if (terms(health).length >= 2) return fact('health','high',[`clinical fields: ${terms(health).join(', ')}`]);
|
|
182
|
+
if (terms(child).length >= 2) return fact('children','high',[`minor/guardian fields: ${terms(child).join(', ')}`]);
|
|
183
|
+
const fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
|
|
184
|
+
if (terms(fin).length >= 1) return fact('financial','high',[`financial fields: ${terms(fin).slice(0,4).join(', ')}`]);
|
|
185
|
+
if (terms(pii).length >= 2) return fact('pii','high',[`personal fields: ${terms(pii).slice(0,4).join(', ')}`]);
|
|
186
|
+
if (terms(pii).length === 1) return fact('pii','low',[`single personal field: ${terms(pii)[0]}`]);
|
|
187
|
+
return fact('none','low',[]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detectUploads(repo, deps) {
|
|
191
|
+
if (dep(deps,'multer','formidable','busboy','uploadthing','@aws-sdk/client-s3','@uploadcare/upload-client'))
|
|
192
|
+
return fact(true,'high',['upload library']);
|
|
193
|
+
if (repo.grep(/storage\.from\([^)]*\)\.upload|multipart\/form-data|new FormData\(\)/).length)
|
|
194
|
+
return fact(true,'high',['upload call site']);
|
|
195
|
+
return fact(false,'low',[]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function detectAccounts(repo, deps, auth) {
|
|
199
|
+
if (auth.value && auth.value !== 'none') return fact(true,'high',[`auth: ${auth.value}`]);
|
|
200
|
+
if (repo.grep(/(?:CREATE TABLE\s+(?:IF NOT EXISTS\s+)?["`]?(?:public\.)?(?:users|accounts)|^model\s+(?:User|Account)\b)/mi,
|
|
201
|
+
/\.(sql|prisma)$/).length) return fact(true,'high',['users table in schema']);
|
|
202
|
+
if (repo.has(/(login|signin|sign-in|register|signup)/i)) return fact(true,'low',['auth route naming']);
|
|
203
|
+
return fact(false,'low',[]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function looksLikeLibrary(repo, framework, deps) {
|
|
207
|
+
// an app has an entrypoint users reach; a library only has exports
|
|
208
|
+
const pkg = readPackageJson(repo);
|
|
209
|
+
const hasAppRoutes =
|
|
210
|
+
repo.has(/^(src\/)?(app|pages|routes|views)\//) ||
|
|
211
|
+
repo.has(/^(src\/)?(server|api)\//) ||
|
|
212
|
+
repo.grep(/createServer\(|app\.listen\(|export const (GET|POST|PUT|DELETE)\b/).length > 0;
|
|
213
|
+
const isFrameworkApp = ['next','remix','nuxt','sveltekit','express','fastify','nest','django','fastapi','rails','flutter','swiftui','react-native'].includes(framework.value);
|
|
214
|
+
const exportsOnly = !!(pkg && (pkg.main || pkg.module || pkg.exports || pkg.types) && !pkg.bin);
|
|
215
|
+
if (isFrameworkApp || hasAppRoutes) return false;
|
|
216
|
+
if (exportsOnly) return true;
|
|
217
|
+
// config-only packages (eslint/prettier/tsconfig/tailwind presets)
|
|
218
|
+
if (pkg && Object.keys(deps).length <= 3 && repo.files.length < 30) return true;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function detectSurface(repo, framework, deps) {
|
|
223
|
+
const f = framework.value;
|
|
224
|
+
if (looksLikeLibrary(repo, framework, deps))
|
|
225
|
+
return fact('library','high',['exports only, no app entrypoint']);
|
|
226
|
+
if (['swiftui'].includes(f)) return fact('mobile-ios','high',['xcode project']);
|
|
227
|
+
if (['flutter','react-native'].includes(f)) return fact('mobile-android','low',['cross-platform mobile']);
|
|
228
|
+
if (['django','rails','laravel','symfony','flask'].includes(f))
|
|
229
|
+
return fact('web-app','high',[`${f} application`]);
|
|
230
|
+
if (['express','fastify','nest','fastapi','sinatra'].includes(f)) return fact('api-only','low',['server framework, no UI framework']);
|
|
231
|
+
if (['next','remix','nuxt','sveltekit'].includes(f)) {
|
|
232
|
+
const dynamic = repo.has(/\/(api|actions)\//) || repo.grep(/'use server'/).length;
|
|
233
|
+
return fact(dynamic ? 'web-app' : 'web-site','low',[dynamic?'server routes present':'no server routes found']);
|
|
234
|
+
}
|
|
235
|
+
const pkg = readPackageJson(repo);
|
|
236
|
+
if (pkg?.bin) return fact('cli','high',['package.json bin']);
|
|
237
|
+
return fact('web-app','low',[]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function detectStage(repo, host) {
|
|
241
|
+
const signals = [];
|
|
242
|
+
if (host.value) signals.push(`host config (${host.value})`);
|
|
243
|
+
if (repo.has(/^\.github\/workflows\//)) signals.push('CI workflows');
|
|
244
|
+
if (repo.exists('.env.production') || repo.exists('.env.prod')) signals.push('production env file');
|
|
245
|
+
if (signals.length >= 2) return fact('production','low',signals);
|
|
246
|
+
if (signals.length === 1) return fact('pre-launch','low',signals);
|
|
247
|
+
return fact('prototype','low',['no deploy signals']);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------- entry ----------
|
|
251
|
+
export function detectProfile(repo, opts = {}) {
|
|
252
|
+
const root = opts.root || null;
|
|
253
|
+
const pkg = readPackageJson(repo);
|
|
254
|
+
const deps = allDeps(repo); // monorepo-aware: unions every workspace manifest
|
|
255
|
+
|
|
256
|
+
const framework = detectFramework(repo, deps);
|
|
257
|
+
const database = detectDatabase(repo, deps, root);
|
|
258
|
+
const auth = detectAuth(repo, deps);
|
|
259
|
+
const host = detectHost(repo, deps);
|
|
260
|
+
const llm = detectLlm(repo, deps);
|
|
261
|
+
const accounts = detectAccounts(repo, deps, auth);
|
|
262
|
+
|
|
263
|
+
const f = {
|
|
264
|
+
surface: detectSurface(repo, framework, deps),
|
|
265
|
+
has_accounts: accounts,
|
|
266
|
+
calls_llm: llm.calls_llm,
|
|
267
|
+
llm_providers: llm.providers,
|
|
268
|
+
is_public: host.value ? fact(true,'low',['deployed to a public host']) : fact(false,'low',[]),
|
|
269
|
+
data_sensitivity: (() => { const l = detectSensitivity(repo);
|
|
270
|
+
return (l.value === 'none' && root) ? detectSensitivity(root) : l; })(),
|
|
271
|
+
has_file_uploads: detectUploads(repo, deps),
|
|
272
|
+
tenancy: (() => { const l = detectTenancy(repo, accounts.value);
|
|
273
|
+
return (['none','single-user'].includes(l.value) && root)
|
|
274
|
+
? detectTenancy(root, accounts.value) : l; })(),
|
|
275
|
+
stage: detectStage(repo, host),
|
|
276
|
+
// cheap extras that gate real rules
|
|
277
|
+
has_migrations: repo.has(/migrations?\//) ? fact(true,'high',['migrations dir']) : fact(false,'low',[]),
|
|
278
|
+
has_ci: repo.has(/^\.github\/workflows\//) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
|
|
279
|
+
sends_email: dep(deps,'resend','@sendgrid/mail','nodemailer','postmark','@aws-sdk/client-ses')
|
|
280
|
+
? fact(true,'high',['email sdk']) : fact(false,'low',[]),
|
|
281
|
+
handles_payments: dep(deps,'stripe','@stripe/stripe-js') ? fact('stripe','high',['stripe sdk'])
|
|
282
|
+
: dep(deps,'@paddle/paddle-js') ? fact('paddle','high',['paddle sdk'])
|
|
283
|
+
: fact('none','low',[]),
|
|
284
|
+
has_admin_panel: repo.has(/\/admin\//i) ? fact(true,'low',['admin route']) : fact(false,'low',[]),
|
|
285
|
+
stack: {
|
|
286
|
+
framework: framework.value, database: database.value,
|
|
287
|
+
auth: auth.value, host: host.value,
|
|
288
|
+
},
|
|
289
|
+
_stack_evidence: { framework, database, auth, host },
|
|
290
|
+
};
|
|
291
|
+
return f;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// flatten to the shape gate.py expects
|
|
295
|
+
export function toGateProfile(f) {
|
|
296
|
+
const v = (x) => (x && typeof x === 'object' && 'value' in x) ? x.value : x;
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const [k, val] of Object.entries(f)) {
|
|
299
|
+
if (k.startsWith('_') || k === 'stack') continue;
|
|
300
|
+
out[k] = v(val);
|
|
301
|
+
}
|
|
302
|
+
out.stack = f.stack;
|
|
303
|
+
return out;
|
|
304
|
+
}
|