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/digest.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Build the block of code the model reads.
|
|
2
|
+
//
|
|
3
|
+
// This is the single most expensive decision in the product. Every token here
|
|
4
|
+
// is written to cache once and then read by every rule-group call, so a digest
|
|
5
|
+
// twice as big costs roughly twice as much for the whole scan. It is also the
|
|
6
|
+
// only defence against someone sending a 2M-token payload to burn our money.
|
|
7
|
+
//
|
|
8
|
+
// The job is not "send the repo". It is "send the parts a question about
|
|
9
|
+
// authorization, tenancy, deletion or spend could possibly be answered from".
|
|
10
|
+
|
|
11
|
+
// MEASURED, not guessed. 3.5 chars/token is the figure for prose; source code
|
|
12
|
+
// tokenizes far worse - identifiers, punctuation and indentation all split.
|
|
13
|
+
// Checked against messages.countTokens on real digests at three sizes:
|
|
14
|
+
// estimated 5,981 -> actual 9,321 (1.56x)
|
|
15
|
+
// estimated 49,991 -> actual 77,175 (1.54x)
|
|
16
|
+
// estimated 149,995 -> actual 226,891 (1.51x)
|
|
17
|
+
// At 3.5 a "150K cap" admitted 227K tokens, so the cap capped nothing and every
|
|
18
|
+
// cost estimate was about half the real number.
|
|
19
|
+
const CHARS_PER_TOKEN = 2.3;
|
|
20
|
+
export const estimateTokens = (s) => Math.ceil(s.length / CHARS_PER_TOKEN);
|
|
21
|
+
|
|
22
|
+
// Higher scores are read first. A schema is 200 lines that answer half the
|
|
23
|
+
// tenancy questions; a React component is 400 lines that answer none.
|
|
24
|
+
const RANK = [
|
|
25
|
+
[/\.(prisma|sql)$/, 100, 'schema'],
|
|
26
|
+
[/(^|\/)(middleware|auth|authz|authorization|session|permissions?|policy|policies|guard|rbac)\.(ts|js|mjs|py|rb|go)$/, 95, 'auth'],
|
|
27
|
+
[/(^|\/)(middleware|auth|guards?|policies|permissions?)\//, 90, 'auth'],
|
|
28
|
+
[/(^|\/)api\/.*\/route\.(ts|js)$/, 85, 'route'],
|
|
29
|
+
[/(^|\/)(pages\/api|app\/api)\//, 85, 'route'],
|
|
30
|
+
[/(^|\/)(routes?|controllers?|handlers?|endpoints?|resolvers?)\//, 80, 'route'],
|
|
31
|
+
[/(^|\/)(actions?|server)\//, 75, 'server'],
|
|
32
|
+
[/\.(server)\.(ts|js)$/, 75, 'server'],
|
|
33
|
+
[/(^|\/)(services?|repositor(y|ies)|queries|db|database|models?|dal)\//, 70, 'data'],
|
|
34
|
+
[/(^|\/)(jobs?|workers?|queues?|tasks?|cron)\//, 60, 'async'],
|
|
35
|
+
[/(^|\/)(lib|utils?|helpers?)\//, 45, 'lib'],
|
|
36
|
+
[/(next|nuxt|vite|astro|svelte)\.config\.(ts|js|mjs)$/, 55, 'config'],
|
|
37
|
+
[/(^|\/)(docker-compose.*\.ya?ml|Dockerfile|.*\.tf)$/, 50, 'infra'],
|
|
38
|
+
[/\.github\/workflows\/.*\.ya?ml$/, 40, 'ci'],
|
|
39
|
+
[/(^|\/)package\.json$/, 35, 'manifest'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Never worth a token.
|
|
43
|
+
const NEVER = [
|
|
44
|
+
/(^|\/)(test|tests|__tests__|spec|e2e|cypress|fixtures?|mocks?|__mocks__)\//,
|
|
45
|
+
/\.(test|spec)\.[a-z]+$/,
|
|
46
|
+
/(^|\/)(node_modules|dist|build|out|coverage|\.next|vendor)\//,
|
|
47
|
+
/-lock\.(json|yaml)$|\.lock$/,
|
|
48
|
+
/\.(css|scss|sass|less|svg|png|jpe?g|gif|webp|ico|woff2?|ttf|eot|map|md|txt)$/,
|
|
49
|
+
/(^|\/)(migrations?)\/.*\/(down|rollback)\.sql$/,
|
|
50
|
+
/\.min\.(js|css)$/,
|
|
51
|
+
/(^|\/)(i18n|locales?|translations?)\//,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The scanner's whole idea is profile -> gate -> check: work out what the app IS
|
|
55
|
+
// before deciding what to ask. The digest ignored that and used one fixed
|
|
56
|
+
// ranking for a static blog and an AI agent platform alike. On tuura that meant
|
|
57
|
+
// lib/agent/ scored 45 - the same as lib/date-helpers/ - so the agent code lost
|
|
58
|
+
// its place to formatting utilities and every AI rule came back "I cannot see
|
|
59
|
+
// the code". Reproducing, in the one new file, the exact mistake the product
|
|
60
|
+
// exists to prevent.
|
|
61
|
+
//
|
|
62
|
+
// Each entry: if the profile fact is true, files matching the pattern get this
|
|
63
|
+
// score instead. Only what the app actually is gets promoted.
|
|
64
|
+
const PROFILE_BOOSTS = [
|
|
65
|
+
['calls_llm', /(^|\/)(ai|llm|agents?|prompts?|completions?|chat|inference|models?)\//i, 92],
|
|
66
|
+
['calls_llm', /(^|\/)[a-z0-9-]*(ai|llm|agent|prompt|completion|anthropic|openai)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
67
|
+
['has_file_uploads', /(^|\/)(uploads?|attachments?|files?|storage|media)\//i, 88],
|
|
68
|
+
['has_file_uploads', /(^|\/)[a-z0-9-]*(upload|attachment|multer|s3|r2|bucket)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 86],
|
|
69
|
+
['has_accounts', /(^|\/)[a-z0-9-]*(auth|session|token|login|password|jwt)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 93],
|
|
70
|
+
['handles_payments', /(^|\/)[a-z0-9-]*(payment|billing|stripe|checkout|invoice|subscription)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
71
|
+
['is_multi_tenant', /(^|\/)[a-z0-9-]*(tenant|org|organization|workspace)[a-z0-9-]*\.(ts|tsx|js|mjs|py|rb)$/i, 88],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function score(path, ctx) {
|
|
75
|
+
if (NEVER.some(re => re.test(path))) return null;
|
|
76
|
+
// Prisma's migrations are GENERATED from schema.prisma - a historical log of
|
|
77
|
+
// every table ever created. On cal.com they are 603 files and would eat the
|
|
78
|
+
// entire budget, leaving 2 route files, which is where the authorization
|
|
79
|
+
// bugs actually are. The schema is the current state; the log is not.
|
|
80
|
+
if (ctx.hasPrisma && /(^|\/)migrations?\//.test(path) && /\.sql$/.test(path)) return null;
|
|
81
|
+
|
|
82
|
+
// what this app IS beats where the file happens to sit
|
|
83
|
+
let boosted = null;
|
|
84
|
+
for (const [fact, re, n] of PROFILE_BOOSTS) {
|
|
85
|
+
if (!ctx.profile?.[fact] || !re.test(path)) continue;
|
|
86
|
+
if (!boosted || n > boosted.n) boosted = { n, kind: fact === 'calls_llm' ? 'ai' : fact.replace(/^(has_|is_|handles_)/, '') };
|
|
87
|
+
}
|
|
88
|
+
if (boosted) return boosted;
|
|
89
|
+
|
|
90
|
+
for (const [re, n, kind] of RANK) if (re.test(path)) return { n, kind };
|
|
91
|
+
// unranked server-ish source still beats nothing, but only just
|
|
92
|
+
if (/\.(ts|tsx|js|jsx|mjs|py|rb|go|php|java|kt|swift)$/.test(path)) return { n: 20, kind: 'other' };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A single enormous generated file can eat the whole budget alone.
|
|
97
|
+
const MAX_FILE_CHARS = 60_000;
|
|
98
|
+
|
|
99
|
+
export function buildDigest(repo, { maxTokens = 150_000, profile = {} } = {}) {
|
|
100
|
+
const ctx = { profile, hasPrisma: repo.files.some(f => /\.prisma$/.test(f.path)) };
|
|
101
|
+
const ranked = [];
|
|
102
|
+
for (const f of repo.files) {
|
|
103
|
+
if (!f.text) continue;
|
|
104
|
+
const s = score(f.path, ctx);
|
|
105
|
+
if (!s) continue;
|
|
106
|
+
ranked.push({ path: f.path, text: f.text, ...s });
|
|
107
|
+
}
|
|
108
|
+
// highest value first; within a rank, smaller files first so a 60k monster
|
|
109
|
+
// never displaces six schemas that would all have fitted
|
|
110
|
+
ranked.sort((a, b) => b.n - a.n || a.text.length - b.text.length);
|
|
111
|
+
|
|
112
|
+
// No single kind may crowd out the others. Without this one category with
|
|
113
|
+
// hundreds of files takes everything and the scan answers questions nobody
|
|
114
|
+
// asked while missing the code the rules are actually about.
|
|
115
|
+
// These must sum to roughly 1.0. The first pass RESERVES each kind its share;
|
|
116
|
+
// the second spends whatever is left in rank order. If the shares sum to more
|
|
117
|
+
// than the budget the first pass runs out early and the lowest-ranked kinds
|
|
118
|
+
// never get their reservation - which is how promoting the AI files knocked
|
|
119
|
+
// routes from 23 to 8, and routes are where authorization bugs live.
|
|
120
|
+
const SHARE = {
|
|
121
|
+
schema: 0.10, auth: 0.08, accounts: 0.12, ai: 0.18, route: 0.20,
|
|
122
|
+
data: 0.12, server: 0.08, file_uploads: 0.06, payments: 0.03,
|
|
123
|
+
multi_tenant: 0.03,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const parts = [];
|
|
127
|
+
const included = [];
|
|
128
|
+
const omitted = [];
|
|
129
|
+
const spentByKind = {};
|
|
130
|
+
let tokens = 0;
|
|
131
|
+
|
|
132
|
+
const take = (f, respectShare) => {
|
|
133
|
+
let body = f.text;
|
|
134
|
+
let truncated = false;
|
|
135
|
+
if (body.length > MAX_FILE_CHARS) { body = body.slice(0, MAX_FILE_CHARS); truncated = true; }
|
|
136
|
+
const block = `\n──── ${f.path}${truncated ? ' [truncated]' : ''}\n${body}\n`;
|
|
137
|
+
const cost = estimateTokens(block);
|
|
138
|
+
if (tokens + cost > maxTokens) return false;
|
|
139
|
+
if (respectShare && SHARE[f.kind] != null) {
|
|
140
|
+
const cap = maxTokens * SHARE[f.kind];
|
|
141
|
+
if ((spentByKind[f.kind] || 0) + cost > cap) return false;
|
|
142
|
+
}
|
|
143
|
+
parts.push(block);
|
|
144
|
+
included.push({ path: f.path, kind: f.kind, tokens: cost });
|
|
145
|
+
spentByKind[f.kind] = (spentByKind[f.kind] || 0) + cost;
|
|
146
|
+
tokens += cost;
|
|
147
|
+
return true;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const leftovers = [];
|
|
151
|
+
for (const f of ranked) if (!take(f, true)) leftovers.push(f);
|
|
152
|
+
// budget left after every kind hit its ceiling - spend it in rank order
|
|
153
|
+
for (const f of leftovers) if (!take(f, false)) omitted.push(f.path);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
text: parts.join(''),
|
|
157
|
+
tokens,
|
|
158
|
+
included,
|
|
159
|
+
omitted,
|
|
160
|
+
// what the model must be told it cannot see, or it will reason as though
|
|
161
|
+
// the absence of an ownership check is proof there isn't one
|
|
162
|
+
coverage: {
|
|
163
|
+
filesRead: included.length,
|
|
164
|
+
filesSkipped: omitted.length,
|
|
165
|
+
byKind: included.reduce((a, f) => (a[f.kind] = (a[f.kind] || 0) + 1, a), {}),
|
|
166
|
+
tokensByKind: spentByKind,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/fs-scan.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Cheap repo walk. Everything downstream reads from this one pass so we never
|
|
2
|
+
// hit the disk twice for the same file.
|
|
3
|
+
import { readdirSync, readFileSync, statSync, existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { join, relative, extname, resolve, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SKIP = new Set([
|
|
7
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
|
|
8
|
+
'.venv', 'venv', '__pycache__', '.turbo', 'vendor', '.cache', 'target',
|
|
9
|
+
'site-packages', 'eggs', '.eggs', '.tox', '.bundle', 'storage', 'public',
|
|
10
|
+
]);
|
|
11
|
+
const TEXT = new Set([
|
|
12
|
+
'.js','.jsx','.ts','.tsx','.mjs','.cjs','.json','.sql','.py','.rb','.go',
|
|
13
|
+
'.yaml','.yml','.toml','.env','.example','.prisma','.md','.swift','.kt',
|
|
14
|
+
'.php','.erb','.haml','.slim','.html','.htm','.vue','.svelte','.cfg','.ini',
|
|
15
|
+
'.rake','.gemspec','.blade','.twig','.tf','.conf',
|
|
16
|
+
// mobile manifests and robots.txt - MOB-004 and UX-006 cannot see them otherwise
|
|
17
|
+
'.plist','.xml','.txt'
|
|
18
|
+
]);
|
|
19
|
+
// files that carry no extension but decide what a project is
|
|
20
|
+
const NAMED = new Set([
|
|
21
|
+
'Gemfile','Rakefile','Procfile','Dockerfile','Makefile','Brewfile',
|
|
22
|
+
'Gemfile.lock','requirements.txt','Pipfile','manage.py','artisan',
|
|
23
|
+
]);
|
|
24
|
+
const MAX_BYTES = 512 * 1024;
|
|
25
|
+
|
|
26
|
+
// A symlink named like a source file will otherwise be read straight through:
|
|
27
|
+
// `config.ts -> /etc/passwd` lands its contents in the scan, and from there into
|
|
28
|
+
// a finding. Anything resolving outside the repo is refused.
|
|
29
|
+
function containedRealPath(root, full) {
|
|
30
|
+
try {
|
|
31
|
+
const real = realpathSync(full);
|
|
32
|
+
const base = realpathSync(root);
|
|
33
|
+
return (real === base || real.startsWith(base + sep)) ? real : null;
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
38
|
+
const files = [];
|
|
39
|
+
const rootAbs = resolve(root);
|
|
40
|
+
const walk = (dir) => {
|
|
41
|
+
if (files.length >= maxFiles) return;
|
|
42
|
+
let entries;
|
|
43
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (files.length >= maxFiles) return;
|
|
46
|
+
if (e.name.startsWith('.') && !e.name.startsWith('.env') && e.name !== '.github') continue;
|
|
47
|
+
const full = join(dir, e.name);
|
|
48
|
+
|
|
49
|
+
let isLink = false;
|
|
50
|
+
try { isLink = lstatSync(full).isSymbolicLink(); } catch { continue; }
|
|
51
|
+
if (isLink && !containedRealPath(rootAbs, full)) {
|
|
52
|
+
// points outside the repo - record that it exists, never read it
|
|
53
|
+
files.push({ path: relative(root, full), text: null, skipped: 'symlink-escapes-repo' });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full); continue; }
|
|
58
|
+
const ext = extname(e.name) || (e.name.startsWith('.env') ? '.env' : '');
|
|
59
|
+
if (!TEXT.has(ext) && !NAMED.has(e.name)) {
|
|
60
|
+
files.push({ path: relative(root, full), text: null }); continue;
|
|
61
|
+
}
|
|
62
|
+
let text = null;
|
|
63
|
+
try { if (statSync(full).size <= MAX_BYTES) text = readFileSync(full, 'utf8'); } catch {}
|
|
64
|
+
files.push({ path: relative(root, full), text });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(root);
|
|
68
|
+
return {
|
|
69
|
+
root,
|
|
70
|
+
files,
|
|
71
|
+
has: (re) => files.some(f => re.test(f.path)),
|
|
72
|
+
find: (re) => files.filter(f => re.test(f.path)),
|
|
73
|
+
grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|php|erb|sql|prisma|ya?ml)$|(^|\/)(Gemfile|manage\.py|artisan)$/) =>
|
|
74
|
+
files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
|
|
75
|
+
exists: (rel) => existsSync(join(root, rel)),
|
|
76
|
+
read: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readPackageJson(repo) {
|
|
81
|
+
const raw = repo.read('package.json');
|
|
82
|
+
if (!raw) return null;
|
|
83
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Monorepos put nothing in the root manifest. Union every workspace package.json
|
|
87
|
+
// or we conclude a pnpm/turbo repo has no dependencies at all.
|
|
88
|
+
export function allManifests(repo) {
|
|
89
|
+
return repo.find(/(^|\/)package\.json$/)
|
|
90
|
+
.map(f => { try { return JSON.parse(f.text || ''); } catch { return null; } })
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function allDeps(pkgOrRepo) {
|
|
95
|
+
// accept either a single manifest (legacy) or a repo (monorepo-aware)
|
|
96
|
+
const manifests = pkgOrRepo && pkgOrRepo.files ? allManifests(pkgOrRepo)
|
|
97
|
+
: pkgOrRepo ? [pkgOrRepo] : [];
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const m of manifests)
|
|
100
|
+
Object.assign(out, m.dependencies||{}, m.devDependencies||{}, m.peerDependencies||{});
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Comments describe code; they are not evidence about it. Strip them before
|
|
105
|
+
// matching, or a comment mentioning "diagnosis" classifies a CRM as health data.
|
|
106
|
+
export function stripComments(text) {
|
|
107
|
+
return text
|
|
108
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
109
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
110
|
+
.replace(/^\s*#(?!\[).*$/gm, '')
|
|
111
|
+
.replace(/^\s*--.*$/gm, '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isWorkspaceRoot(repo) {
|
|
115
|
+
return repo.exists('pnpm-workspace.yaml') || repo.exists('turbo.json') ||
|
|
116
|
+
repo.exists('lerna.json') || repo.exists('nx.json') ||
|
|
117
|
+
!!readPackageJson(repo)?.workspaces;
|
|
118
|
+
}
|
package/src/gate.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The applicability gate. A rule is evaluated only if every `requires:`
|
|
2
|
+
// predicate holds against the detected profile.
|
|
3
|
+
//
|
|
4
|
+
// true -> applies, run the check
|
|
5
|
+
// false -> skipped, shown with the reason, never counted against the score
|
|
6
|
+
// null -> undeterminable, becomes a question, never a failure
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const RULES = require('./rules.json');
|
|
10
|
+
|
|
11
|
+
export const allRules = () => RULES.rules;
|
|
12
|
+
|
|
13
|
+
function get(profile, key) {
|
|
14
|
+
let cur = profile;
|
|
15
|
+
for (const part of key.split('.')) {
|
|
16
|
+
if (cur == null || typeof cur !== 'object' || !(part in cur)) return undefined;
|
|
17
|
+
cur = cur[part];
|
|
18
|
+
}
|
|
19
|
+
return cur;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const coerce = (v) => (v === 'true' ? true : v === 'false' ? false : v);
|
|
23
|
+
|
|
24
|
+
export function evaluate(pred, profile) {
|
|
25
|
+
const p = String(pred).trim();
|
|
26
|
+
|
|
27
|
+
if (p.includes(' or ')) {
|
|
28
|
+
const parts = p.split(' or ').map(x => evaluate(x, profile));
|
|
29
|
+
if (parts.some(x => x === true)) return true;
|
|
30
|
+
return parts.some(x => x === null) ? null : false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let m = p.match(/^([\w.]+)\s+includes any of\s+\[(.+)\]$/);
|
|
34
|
+
if (m) {
|
|
35
|
+
const cur = get(profile, m[1]);
|
|
36
|
+
if (cur == null) return null;
|
|
37
|
+
const vals = m[2].split(',').map(s => s.trim());
|
|
38
|
+
return vals.some(v => Array.isArray(cur) ? cur.includes(v) : cur === v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
m = p.match(/^([\w.]+)\s+in\s+\[(.+)\]$/);
|
|
42
|
+
if (m) {
|
|
43
|
+
const cur = get(profile, m[1]);
|
|
44
|
+
if (cur === undefined) return null;
|
|
45
|
+
return m[2].split(',').map(s => s.trim()).includes(cur);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
m = p.match(/^([\w.]+)\s*(==|!=)\s*(.+)$/);
|
|
49
|
+
if (m) {
|
|
50
|
+
const cur = get(profile, m[1]);
|
|
51
|
+
if (cur === undefined) return null;
|
|
52
|
+
const val = coerce(m[3].trim());
|
|
53
|
+
return m[2] === '==' ? cur === val : cur !== val;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function applies(rule, profile) {
|
|
60
|
+
const reqs = rule.requires || [];
|
|
61
|
+
if (!reqs.length) return true;
|
|
62
|
+
const res = reqs.map(r => evaluate(r, profile));
|
|
63
|
+
if (res.some(x => x === false)) return false;
|
|
64
|
+
if (res.some(x => x === null)) return null;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Why a rule was skipped, in the user's terms rather than predicate syntax.
|
|
69
|
+
export function skipReason(rule, profile) {
|
|
70
|
+
for (const r of rule.requires || []) {
|
|
71
|
+
if (evaluate(r, profile) !== false) continue;
|
|
72
|
+
const key = String(r).match(/^([\w.]+)/)?.[1];
|
|
73
|
+
const actual = get(profile, key);
|
|
74
|
+
return `${key} is ${JSON.stringify(actual)}`;
|
|
75
|
+
}
|
|
76
|
+
return 'precondition not met';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function gate(profile) {
|
|
80
|
+
const evaluated = [], skipped = [], unknown = [];
|
|
81
|
+
for (const rule of RULES.rules) {
|
|
82
|
+
const a = applies(rule, profile);
|
|
83
|
+
if (a === true) evaluated.push(rule);
|
|
84
|
+
else if (a === false) skipped.push({ rule, reason: skipReason(rule, profile) });
|
|
85
|
+
else unknown.push(rule);
|
|
86
|
+
}
|
|
87
|
+
return { evaluated, skipped, unknown, total: RULES.rules.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Facts that, if answered, would unlock the most currently-unknown rules.
|
|
91
|
+
export function missingFacts(profile, unknown) {
|
|
92
|
+
const counts = new Map();
|
|
93
|
+
for (const rule of unknown) {
|
|
94
|
+
for (const req of rule.requires || []) {
|
|
95
|
+
for (const sub of String(req).split(' or ')) {
|
|
96
|
+
if (evaluate(sub, profile) !== null) continue;
|
|
97
|
+
const key = sub.trim().match(/^([\w.]+)/)?.[1];
|
|
98
|
+
if (key) counts.set(key, (counts.get(key) || 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
103
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { scanRepo } from './fs-scan.mjs';
|
|
3
|
+
import { splitWorkspaces } from './workspace.mjs';
|
|
4
|
+
import { detectProfile, toGateProfile } from './detect.mjs';
|
|
5
|
+
import { gate, missingFacts } from './gate.mjs';
|
|
6
|
+
import { runChecks, runRootChecks } from './checks.mjs';
|
|
7
|
+
import { render } from './report.mjs';
|
|
8
|
+
|
|
9
|
+
const target = process.argv[2] || process.cwd();
|
|
10
|
+
const asJson = process.argv.includes('--json');
|
|
11
|
+
|
|
12
|
+
const repo = scanRepo(target, { maxFiles: 12000 });
|
|
13
|
+
const packages = splitWorkspaces(repo);
|
|
14
|
+
|
|
15
|
+
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
16
|
+
// simply match very few rules, which is the correct outcome, not a bug.
|
|
17
|
+
const scanned = packages.map(w => {
|
|
18
|
+
const full = detectProfile(w.view, { root: repo });
|
|
19
|
+
const profile = toGateProfile(full);
|
|
20
|
+
const g = gate(profile);
|
|
21
|
+
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
22
|
+
const findings = runChecks(w.view, profile, applicableIds)
|
|
23
|
+
.map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
|
|
24
|
+
return { name: w.name, full, profile, gate: g, findings };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// The app most representative of this repo leads the report.
|
|
28
|
+
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
29
|
+
const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
|
|
30
|
+
|
|
31
|
+
// Root-scoped checks see the whole repo, once, regardless of how many
|
|
32
|
+
// workspaces it contains. Gated by the lead app's profile.
|
|
33
|
+
const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
|
|
34
|
+
const rootFindings = runRootChecks(repo, lead.profile, rootIds);
|
|
35
|
+
|
|
36
|
+
const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
// Each workspace sorts its own findings, but concatenating several workspaces
|
|
39
|
+
// and the root-scoped checks destroys that order - a monorepo would show a LOW
|
|
40
|
+
// from apps/web above a CRITICAL from apps/api. Sort once, at the end.
|
|
41
|
+
const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
42
|
+
const findings = allFindings
|
|
43
|
+
.filter(f => {
|
|
44
|
+
const k = `${f.id}:${f.file}:${f.line}`;
|
|
45
|
+
if (seen.has(k)) return false;
|
|
46
|
+
seen.add(k); return true;
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
49
|
+
|
|
50
|
+
// Eleven tier-2 rules have a cheap static approximation that ships free. When
|
|
51
|
+
// one of those finds nothing that is NOT a pass - a pattern cannot see an
|
|
52
|
+
// ownership check that lives in middleware. Reporting it as clean would tell
|
|
53
|
+
// someone they are safe when the check simply could not look. Say so instead.
|
|
54
|
+
const firedIds = new Set(findings.map(f => f.id));
|
|
55
|
+
const shallow = lead.gate.evaluated
|
|
56
|
+
.filter(r => r.has_static_approximation && !firedIds.has(r.id));
|
|
57
|
+
|
|
58
|
+
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
59
|
+
|
|
60
|
+
if (asJson) {
|
|
61
|
+
console.log(JSON.stringify({
|
|
62
|
+
packages: scanned.map(s => ({
|
|
63
|
+
name: s.name, profile: s.profile,
|
|
64
|
+
evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
|
|
65
|
+
})),
|
|
66
|
+
findings, questions,
|
|
67
|
+
shallow: shallow.map(r => ({ id: r.id, title: r.title, severity: r.severity })),
|
|
68
|
+
}, null, 2));
|
|
69
|
+
} else {
|
|
70
|
+
process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
|
|
71
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { BRAND } from './brand.mjs';
|
|
2
|
+
|
|
3
|
+
const C = { red:'\x1b[31m', yel:'\x1b[33m', blu:'\x1b[34m', gry:'\x1b[90m',
|
|
4
|
+
bold:'\x1b[1m', dim:'\x1b[2m', grn:'\x1b[32m', cyn:'\x1b[36m', off:'\x1b[0m' };
|
|
5
|
+
const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'MEDIUM'], low:[C.gry,'LOW'] };
|
|
6
|
+
|
|
7
|
+
const QUESTION = {
|
|
8
|
+
business_model: 'Who is this for — consumers, businesses, a marketplace, or internal use?',
|
|
9
|
+
jurisdictions: 'Where are your users? (EU / UK / US / other — this decides which privacy rules apply)',
|
|
10
|
+
expected_scale: 'How many users do you expect — under 1k, under 100k, or more?',
|
|
11
|
+
serves_currency: 'Do you charge in more than one currency?',
|
|
12
|
+
audience_locale: 'Do you serve more than one language or region?',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function render({ repo, scanned, lead, findings, questions, shallow = [] }) {
|
|
16
|
+
const L = [];
|
|
17
|
+
const p = (s = '') => L.push(s);
|
|
18
|
+
const pr = lead.profile;
|
|
19
|
+
|
|
20
|
+
p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${repo}${C.off}\n`);
|
|
21
|
+
|
|
22
|
+
// ---------- what this app is ----------
|
|
23
|
+
p(`${C.bold}What we found${C.off}`);
|
|
24
|
+
const ev = (f) => lead.full[f]?.evidence?.[0];
|
|
25
|
+
const row = (k, v, why) =>
|
|
26
|
+
p(` ${C.gry}${k.padEnd(10)}${C.off}${v}${why ? ` ${C.dim}(${why})${C.off}` : ''}`);
|
|
27
|
+
|
|
28
|
+
row('Type', pr.surface, ev('surface'));
|
|
29
|
+
row('Stack', [pr.stack.framework, pr.stack.database, pr.stack.host].filter(Boolean).join(' · ') || '—');
|
|
30
|
+
row('Accounts', pr.has_accounts ? 'yes' : 'no', ev('has_accounts'));
|
|
31
|
+
if (pr.tenancy !== 'none') row('Tenancy', pr.tenancy, ev('tenancy'));
|
|
32
|
+
if (pr.calls_llm) row('AI', (pr.llm_providers || []).join(', ') || 'yes', ev('calls_llm'));
|
|
33
|
+
if (pr.handles_payments !== 'none') row('Payments', pr.handles_payments);
|
|
34
|
+
if (pr.data_sensitivity !== 'none') row('Data', pr.data_sensitivity, ev('data_sensitivity'));
|
|
35
|
+
row('Stage', pr.stage, ev('stage'));
|
|
36
|
+
|
|
37
|
+
if (scanned.length > 1) {
|
|
38
|
+
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
39
|
+
const libs = scanned.length - apps.length;
|
|
40
|
+
p(`\n ${C.dim}Monorepo — ${apps.length} app${apps.length === 1 ? '' : 's'}` +
|
|
41
|
+
`${libs ? ` and ${libs} shared package${libs === 1 ? '' : 's'}` : ''}, each checked on its own:${C.off}`);
|
|
42
|
+
for (const s of apps.slice(0, 6))
|
|
43
|
+
p(` ${C.dim}${s.name.padEnd(26)} ${s.profile.surface.padEnd(11)} ${s.gate.evaluated.length} checks${C.off}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- findings ----------
|
|
47
|
+
p(`\n${C.bold}Findings${C.off}`);
|
|
48
|
+
if (!findings.length) {
|
|
49
|
+
p(` ${C.grn}Nothing found by the deterministic checks.${C.off}`);
|
|
50
|
+
p(` ${C.dim}These are the fast, free checks — deeper ones need a review pass.${C.off}`);
|
|
51
|
+
} else {
|
|
52
|
+
const counts = findings.reduce((a, f) => (a[f.severity] = (a[f.severity] || 0) + 1, a), {});
|
|
53
|
+
// severity order, not whatever order they happened to arrive in
|
|
54
|
+
const summary = ['critical', 'high', 'medium', 'low']
|
|
55
|
+
.filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(' · ');
|
|
56
|
+
p(` ${C.dim}${summary}${C.off}`);
|
|
57
|
+
for (const f of findings.slice(0, 25)) {
|
|
58
|
+
const [col, label] = SEV[f.severity];
|
|
59
|
+
p(`\n ${col}${C.bold}${label}${C.off} ${f.title}`);
|
|
60
|
+
p(` ${C.gry}${f.file}:${f.line}${C.off}`);
|
|
61
|
+
p(` ${f.detail}`);
|
|
62
|
+
p(` ${C.grn}Fix${C.off} ${f.fix}`);
|
|
63
|
+
}
|
|
64
|
+
if (findings.length > 25) p(`\n ${C.dim}… and ${findings.length - 25} more${C.off}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------- checked, but only as far as a program can see ----------
|
|
68
|
+
if (shallow.length) {
|
|
69
|
+
p(`\n${C.bold}${shallow.length} check${shallow.length === 1 ? '' : 's'} went only as deep as a pattern can${C.off}`);
|
|
70
|
+
p(` ${C.dim}These found nothing. That is not the same as being safe — a pattern`);
|
|
71
|
+
p(` cannot tell whether an ownership check lives in middleware or a guard.${C.off}`);
|
|
72
|
+
for (const r of shallow.slice(0, 6))
|
|
73
|
+
p(` ${C.cyn}~${C.off} ${C.gry}${r.id.padEnd(11)}${C.off}${r.title}`);
|
|
74
|
+
if (shallow.length > 6) p(` ${C.dim} … and ${shallow.length - 6} more${C.off}`);
|
|
75
|
+
p(` ${C.dim}A deep scan reads the actual path these take through your code.${C.off}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- what we did not check, and why ----------
|
|
79
|
+
const g = lead.gate;
|
|
80
|
+
p(`\n${C.bold}Coverage${C.off}`);
|
|
81
|
+
p(` ${C.bold}${g.evaluated.length}${C.off} of ${g.total} checks apply to this app`);
|
|
82
|
+
p(` ${C.dim}${g.skipped.length} skipped — they don't fit what you built${C.off}`);
|
|
83
|
+
|
|
84
|
+
const bySkipFact = {};
|
|
85
|
+
for (const s of g.skipped) {
|
|
86
|
+
const k = s.reason.split(' is ')[0];
|
|
87
|
+
bySkipFact[k] = (bySkipFact[k] || 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
for (const [f, n] of Object.entries(bySkipFact).sort((a, b) => b[1] - a[1]).slice(0, 4))
|
|
90
|
+
p(` ${C.dim}${String(n).padStart(3)} because ${f} is ${JSON.stringify(lead.profile[f] ?? lead.profile.stack?.[f.split('.').pop()])}${C.off}`);
|
|
91
|
+
|
|
92
|
+
// ---------- the three questions ----------
|
|
93
|
+
if (questions.length) {
|
|
94
|
+
p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
|
|
95
|
+
for (const [factName, n] of questions)
|
|
96
|
+
p(` ${C.cyn}?${C.off} ${QUESTION[factName] || factName} ${C.dim}(unlocks ${n})${C.off}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
p(`\n ${C.dim}Anything above wrong? Correct it and the checks adjust.${C.off}\n`);
|
|
100
|
+
return L.join('\n');
|
|
101
|
+
}
|