@clear-capabilities/agentic-security-scanner 0.170.0 → 0.172.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/CHANGELOG.md +38 -0
- package/bin/agentic-security.js +6 -0
- package/dist/1336.index.js +891 -719
- package/dist/550.index.js +548 -0
- package/dist/7405.index.js +868 -0
- package/dist/9382.index.js +4220 -0
- package/dist/9579.index.js +1197 -0
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/context-intelligence/CLAUDE.md +21 -0
- package/src/context-intelligence/adapters/existing-ir.js +14 -0
- package/src/context-intelligence/adapters/haskell-hie.js +138 -0
- package/src/context-intelligence/build.js +68 -0
- package/src/context-intelligence/candidates.js +115 -0
- package/src/context-intelligence/canonical.js +95 -0
- package/src/context-intelligence/capabilities.js +33 -0
- package/src/context-intelligence/cli.js +422 -0
- package/src/context-intelligence/config.js +90 -0
- package/src/context-intelligence/controls.js +194 -0
- package/src/context-intelligence/diff.js +97 -0
- package/src/context-intelligence/entrypoints.js +336 -0
- package/src/context-intelligence/evaluation/index.js +228 -0
- package/src/context-intelligence/evaluation/runner.js +139 -0
- package/src/context-intelligence/evaluation/scripted-reader.js +279 -0
- package/src/context-intelligence/files.js +80 -0
- package/src/context-intelligence/graph-schema.js +227 -0
- package/src/context-intelligence/imports.js +123 -0
- package/src/context-intelligence/manifest.js +99 -0
- package/src/context-intelligence/packer.js +224 -0
- package/src/context-intelligence/persist.js +40 -0
- package/src/context-intelligence/prompts/packs.js +82 -0
- package/src/context-intelligence/queue.js +355 -0
- package/src/context-intelligence/reachability.js +182 -0
- package/src/context-intelligence/repo-info.js +16 -0
- package/src/context-intelligence/review.js +321 -0
- package/src/context-intelligence/spans.js +147 -0
- package/src/context-intelligence/store.js +203 -0
- package/src/posture/artifact-registry.js +2 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Security-relevant facts inside ONE symbol's source: authentication / authorization / ownership / tenant controls,
|
|
3
|
+
// and the sensitive operations they have to precede.
|
|
4
|
+
//
|
|
5
|
+
// EVIDENCE DISCIPLINE. Every fact says how it was established:
|
|
6
|
+
// evidence 'observed' the text itself shows it (a decorator, a comparison of a resource field with the principal,
|
|
7
|
+
// a query whose filter mentions the principal)
|
|
8
|
+
// evidence 'inferred' only a NAME suggests it (`requireAdmin(...)`); the callee's body was not shown to reject
|
|
9
|
+
// A name is never promoted to 'observed'. Nothing here decides that code is safe; it decides what must NOT be
|
|
10
|
+
// dropped from a context bundle, and what the model is told was seen.
|
|
11
|
+
//
|
|
12
|
+
// This module is deliberately pattern-based and says so: it exists to be conservative about what it keeps, not
|
|
13
|
+
// to prove anything. The packer never removes a symbol that contains any fact reported here.
|
|
14
|
+
|
|
15
|
+
export const CONTROL_KINDS = Object.freeze(['authentication', 'authorization-role', 'ownership', 'tenant-scope']);
|
|
16
|
+
|
|
17
|
+
const DECORATOR = /^\s*@(?:login_required|permission_required|user_passes_test|PreAuthorize|PostAuthorize|Secured|RolesAllowed|Authorize|Authenticated|authenticated|requires_auth|requires_roles|jwt_required|auth_required|has_role|roles_required|admin_required|staff_member_required|csrf_protect|UseGuards|Roles|Permissions|RequirePermission)\b/;
|
|
18
|
+
// A QUALIFIED principal reference names where the identity came from (the session, the authenticated user object).
|
|
19
|
+
// A BARE identifier (`userId`, `orgId`) may just as well have been copied out of the URL, so it is only ever INFERRED.
|
|
20
|
+
const QUALIFIED_PRINCIPAL = /(?:\breq\.user\b|\brequest\.user\b|\bcurrent_?[Uu]ser\b|\bsession\.user\b|\bsession\[['"]user|\bprincipal\b|\bctx\.user\b|\bauth\.user\b|\bg\.user\b|\bres\.locals\.user\b|\bSecurityContext\w*|\bgetPrincipal\b|\bauthUser\b|\bcurrentUserId\b|\bauthId\b|\breq\.auth\b|\brequest\.auth\b)/;
|
|
21
|
+
const BARE_PRINCIPAL = /(?:\buserId\b|\buser_id\b|\btenantId\b|\btenant_id\b|\borgId\b|\borg_id\b|\buid\b|\bme\.id\b)/;
|
|
22
|
+
// Request-supplied values are attacker-controlled by definition; blank them before looking for a principal reference.
|
|
23
|
+
const UNTRUSTED_ACCESSOR = /\b(?:req|request)\.(?:params|query|body|args|form|json|headers|cookies|values|data)\b(?:\.\w+|\[[^\]]*\])?|\b(?:params|query|body)\[[^\]]*\]|\bparams\.\w+|\brequest\.(?:GET|POST)\b(?:\.get\([^)]*\)|\[[^\]]*\])?|\bparam\s+["'][\w-]+["']/g;
|
|
24
|
+
function principalIn(text, extra = null) {
|
|
25
|
+
const noReq = text.replace(UNTRUSTED_ACCESSOR, ' ');
|
|
26
|
+
// a column name inside a SQL string (`org_id`) is not a reference to the principal; look only outside string literals
|
|
27
|
+
const code = noReq.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
|
|
28
|
+
if (QUALIFIED_PRINCIPAL.test(code) || /\bsession\[['"]user/.test(noReq)) return 'qualified';
|
|
29
|
+
if (extra && extra.test(code)) return 'qualified';
|
|
30
|
+
if (BARE_PRINCIPAL.test(code)) return 'bare';
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Dependency-injected principals (FastAPI-style): a signature parameter bound to an auth-looking dependency holds the
|
|
35
|
+
// authenticated principal, so `user.id` in the body is the principal and not an arbitrary local.
|
|
36
|
+
const DEP_PARAM = /(\w+)\s*(?::[^=,)]*?)?=\s*(?:Depends|Security)\(\s*([\w.]+)|(\w+)\s*:\s*Annotated\[[^\]]*?(?:Depends|Security)\(\s*([\w.]+)/g;
|
|
37
|
+
const PRINCIPAL_DEP = /(?:user|auth|principal|session|token|account|member|identity|credential)/i;
|
|
38
|
+
export function dependencyParams(signatureText) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let m;
|
|
41
|
+
DEP_PARAM.lastIndex = 0;
|
|
42
|
+
while ((m = DEP_PARAM.exec(signatureText))) out.push({ param: m[1] || m[3], dependency: m[2] || m[4] });
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export function signatureText(lines, declLine, maxLines = 12) {
|
|
46
|
+
let t = '';
|
|
47
|
+
for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
const OWNER_FIELD = /(?:owner|tenant|org(?:anization)?|account|created_?by|author|user_?id|userId|team|workspace)/i;
|
|
51
|
+
const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
|
|
52
|
+
const REJECT = /(?:\breturn\b|\bthrow\b|\braise\b|\babort\b|\bexit\b|\b40[0-9]\b|\bforbidden\b|\bunauthori[sz]ed\b|\bHttpError\b|\breject\b|\bnot_?allowed\b|\bdeny\b|\bdenied\b|throwError|\bfinish\b|\bhalt\b|\bPermissionDenied\b|\bAccessDenied\b|status\()/i;
|
|
53
|
+
|
|
54
|
+
const AUTH_NAME = /(?:^|[.:])(?:(?:require|ensure|check|assert|verify|validate|enforce|authori[sz]e|authenticate|protect|guard|must|need|has|is|can|cannot|allow)\w*(?:auth|login|session|token|jwt|user|perm|role|admin|owner|access|tenant|scope|member|polic|abilit|credential|principal)\w*|authori[sz]e\w*|authenticate\w*|isAuthenticated|requireAuth\w*|requireUser|requireAdmin|requireAuthId|can|cannot|ability\.\w+|policy\.\w+|hasRole|hasPermission|isAdmin|isOwner|checkOwner\w*|ownedBy|belongsTo)$/i;
|
|
55
|
+
|
|
56
|
+
const SENSITIVE_NAME = /(?:^|[.:])(?:query|queryOne|queryRow|exec|execute|executeQuery|raw|rawQuery|prepare|find|findAll|findOne|findById|findByPk|findUnique|findFirst|findMany|get|getById|getBy|select|selectList|selectFirst|insert|insertOne|insertMany|insert_|update|updateOne|updateMany|upsert|delete|deleteOne|deleteMany|remove|destroy|save|create|createMany|aggregate|runDB|writeFile|writeFileSync|appendFile|unlink|rm|rmdir|spawn|spawnSync|execSync|execFile|system|popen|fetch|request|post|put|patch)$/i;
|
|
57
|
+
const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|session|repo|repository|dao|model|models|collection|table|prisma|knex|sequelize|orm|store|objects|manager|em|entityManager|mongo|redis|cache|conn|stmt|statement|fs|child_process|cp|axios|http|https|requests|httpx|got|superagent|Database|Persist|Yesod|System)\b/;
|
|
58
|
+
const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
|
|
59
|
+
const HS_SENSITIVE = /(?:^|\.)(?:runDB|selectList|selectFirst|getBy|getJust|insert_?|insertEntity|update|updateWhere|delete|deleteWhere|replace|upsert|query|query_|execute|execute_|readFile|writeFile|callProcess|readProcess)$|^(?:Database\.|System\.Process\.|System\.Directory\.)/;
|
|
60
|
+
|
|
61
|
+
const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
|
|
62
|
+
|
|
63
|
+
function kindOfAuthName(name) {
|
|
64
|
+
const n = name.toLowerCase();
|
|
65
|
+
if (/(?:owner|ownedby|belongsto)/.test(n)) return 'ownership';
|
|
66
|
+
if (/(?:tenant|org|scope|member|workspace|team)/.test(n)) return 'tenant-scope';
|
|
67
|
+
if (/(?:role|admin|perm|polic|abilit|^can|\.can|cannot|allow)/.test(n)) return 'authorization-role';
|
|
68
|
+
return 'authentication';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The text of the statement that starts on line `ln`: from that line until its parentheses balance (capped). A query
|
|
73
|
+
* filter is judged on its OWN statement; looking at neighbouring lines is how a guard written on the next line gets
|
|
74
|
+
* mistaken for a filter on the query above it.
|
|
75
|
+
*/
|
|
76
|
+
function statementAt(lines, ln, maxLines = 12) {
|
|
77
|
+
let depth = 0;
|
|
78
|
+
let text = '';
|
|
79
|
+
for (let i = ln - 1; i < Math.min(lines.length, ln - 1 + maxLines); i++) {
|
|
80
|
+
const s = lines[i];
|
|
81
|
+
text += ' ' + s;
|
|
82
|
+
for (const c of s) { if (c === '(') depth++; else if (c === ')') depth--; }
|
|
83
|
+
if (depth <= 0) break;
|
|
84
|
+
}
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clip(s) { return s.trim().slice(0, 160); }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Facts for one symbol.
|
|
92
|
+
* symbol graph symbol (id, file, startLine, endLine, declLine)
|
|
93
|
+
* lines the FILE's lines (0-based array)
|
|
94
|
+
* Returns [{ symbolId, line, category:'control'|'sensitive', kind, subkind, evidence, basis, rejectionSeen, text }]
|
|
95
|
+
*/
|
|
96
|
+
export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
97
|
+
const out = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const depParams = symbol.declLine ? dependencyParams(signatureText(lines, symbol.declLine)).filter(d => PRINCIPAL_DEP.test(d.dependency)) : [];
|
|
100
|
+
const extraPrincipal = depParams.length ? new RegExp(`\\b(?:${depParams.map(d => d.param).join('|')})\\b\\s*(?:\\.|\\[)`) : null;
|
|
101
|
+
const push = (f) => {
|
|
102
|
+
const key = `${f.line}|${f.category}|${f.kind}|${f.basis}`;
|
|
103
|
+
if (seen.has(key)) return;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
out.push({ symbolId: symbol.id, ...f });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for (let ln = symbol.startLine; ln <= symbol.endLine; ln++) {
|
|
109
|
+
const raw = lines[ln - 1];
|
|
110
|
+
if (raw === undefined) break;
|
|
111
|
+
const t = raw.trim();
|
|
112
|
+
if (t === '' || /^(?:\/\/|#(?!\[)|--|\/\*|\*)/.test(t)) continue;
|
|
113
|
+
|
|
114
|
+
// decorators / annotations that sit above the declaration are guards in their own right
|
|
115
|
+
if (ln < (symbol.declLine || symbol.startLine) + 1 && DECORATOR.test(raw)) {
|
|
116
|
+
const kind = /role|perm|PreAuthorize|PostAuthorize|Secured|RolesAllowed|admin|staff|Roles|Permissions|RequirePermission/i.test(raw) ? 'authorization-role' : 'authentication';
|
|
117
|
+
push({ line: ln, category: 'control', kind, subkind: null, evidence: 'observed', basis: 'decorator', rejectionSeen: true, text: clip(raw) });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// a declaration line is not a call: `function requireUser(` names a function, it does not invoke one
|
|
122
|
+
const isDeclLine = ln === symbol.declLine && /(?:\bfunction\b|\bdef\b|=>|^\s*(?:async\s+)?[\w$.]+\s*[=:]\s*(?:async\s*)?(?:function|\()|^\s*(?:public|private|protected|static|func|fun)\b)/.test(raw);
|
|
123
|
+
|
|
124
|
+
// calls on this line
|
|
125
|
+
const calls = [];
|
|
126
|
+
const re = /([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)\s*\(/g;
|
|
127
|
+
let m;
|
|
128
|
+
if (!isDeclLine) { while ((m = re.exec(raw))) calls.push(m[1]); }
|
|
129
|
+
if (haskell && !isDeclLine) {
|
|
130
|
+
const hre = /\b([A-Z][\w.]*\.[a-z][\w']*|[a-z][\w']*)\b/g;
|
|
131
|
+
while ((m = hre.exec(raw))) if (AUTH_NAME.test(m[1]) || HS_SENSITIVE.test(m[1])) calls.push(m[1]);
|
|
132
|
+
}
|
|
133
|
+
for (const name of calls) {
|
|
134
|
+
if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
|
|
135
|
+
const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(name.split(/[.:]/).pop());
|
|
136
|
+
push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: 'call-name', rejectionSeen: rej, text: clip(raw) });
|
|
137
|
+
}
|
|
138
|
+
const last = name.split(/[.:]/).pop();
|
|
139
|
+
const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
|
|
140
|
+
const isSens = haskell
|
|
141
|
+
? HS_SENSITIVE.test(name)
|
|
142
|
+
: (SENSITIVE_NAME.test(name) && (DB_RECEIVER.test(recv) || /^(?:query|queryOne|queryRow|execute|executeQuery|rawQuery|findOne|findById|findByPk|findUnique|findFirst|findMany|insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|runDB|writeFile|writeFileSync|appendFile|unlink|spawn|spawnSync|execSync|execFile|upsert)$/.test(last)));
|
|
143
|
+
if (isSens) {
|
|
144
|
+
const verb = WRITE_VERB.test(last) ? 'write' : 'read';
|
|
145
|
+
const sub = /(?:writeFile|appendFile|unlink|rm|rmdir)/i.test(last) ? 'filesystem'
|
|
146
|
+
: /(?:spawn|exec|system|popen|callProcess|readProcess)/i.test(last) && !/execute|query/i.test(last) ? 'process'
|
|
147
|
+
: /(?:fetch|request|axios|http|got|superagent|requests|httpx)/i.test(name) ? 'external-call'
|
|
148
|
+
: `data-${verb}`;
|
|
149
|
+
push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
|
|
150
|
+
// a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
|
|
151
|
+
const stmt = statementAt(lines, ln);
|
|
152
|
+
const pr = sub.startsWith('data-') && OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
|
|
153
|
+
if (pr) {
|
|
154
|
+
push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (SQL_TEXT.test(raw)) {
|
|
159
|
+
const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
|
|
160
|
+
push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
|
|
161
|
+
const stmt = statementAt(lines, ln);
|
|
162
|
+
const pr = OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
|
|
163
|
+
if (pr) {
|
|
164
|
+
push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// a resource field compared with the principal: the ownership check itself
|
|
169
|
+
const cmpPr = COMPARE.test(raw) && OWNER_FIELD.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? principalIn(raw, extraPrincipal) : null;
|
|
170
|
+
if (cmpPr) {
|
|
171
|
+
const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
|
|
172
|
+
push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(raw) ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr === 'qualified' ? 'observed' : 'inferred', basis: cmpPr === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
|
|
176
|
+
// 'inferred' (the name) with the rejection read from the body; a name alone never counts as rejecting.
|
|
177
|
+
if (symbol.kind === undefined || symbol.kind === 'function') {
|
|
178
|
+
const nm = String(symbol.name || '');
|
|
179
|
+
if (nm && !nm.startsWith('<') && AUTH_NAME.test(nm)) {
|
|
180
|
+
const body = lines.slice(symbol.startLine - 1, symbol.endLine).join('\n');
|
|
181
|
+
if (REJECT.test(body)) push({ line: symbol.declLine || symbol.startLine, category: 'control', kind: kindOfAuthName(nm), subkind: null, evidence: 'inferred', basis: 'guard-function', rejectionSeen: true, text: clip(lines[(symbol.declLine || symbol.startLine) - 1] || '') });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
out.sort((a, b) => a.line - b.line || (a.category < b.category ? -1 : a.category > b.category ? 1 : 0) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.basis < b.basis ? -1 : 1));
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when a fact set contains anything the packer must never drop. */
|
|
189
|
+
export function isSecuritySignificant(facts) {
|
|
190
|
+
return facts.length > 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const TELEMETRY_NAME = /(?:^|[.:_])(?:log(?:ger)?|logging|metrics?|telemetry|statsd|trace|tracing|span|instrument|monitor|analytics|track(?:ing)?|debug|audit_?log_?noop|format_?(?:date|time|currency|number)|pretty|humanize)\w*$/i;
|
|
194
|
+
export function looksLikeTelemetry(name) { return TELEMETRY_NAME.test(String(name || '')); }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Incremental invalidation and route diffs.
|
|
3
|
+
//
|
|
4
|
+
// Invalidation is by RECOMPUTATION, not by prediction: the symbol graph is rebuilt from the current snapshot (cheap,
|
|
5
|
+
// local, no model) and every bundle is content-addressed. A review is reused only when the bundle it was made from has
|
|
6
|
+
// exactly the same context hash, so:
|
|
7
|
+
// * a change to a shared authorization middleware changes the text of every route that includes it, so all of their
|
|
8
|
+
// hashes change and all of their cached reviews are invalidated, though no handler file was touched;
|
|
9
|
+
// * an unrelated edit leaves untouched bundles byte-identical, so their reviews are cache hits;
|
|
10
|
+
// * a dirty worktree or a different branch is a different snapshot, so a review of another state is never replayed
|
|
11
|
+
// as current.
|
|
12
|
+
// There is no optimistic reuse path: if the previous index cannot be read or verified, everything is a miss.
|
|
13
|
+
import { readStoreJson, readManifest } from './store.js';
|
|
14
|
+
|
|
15
|
+
/** symbol / file -> the entry points whose bundles include it. Used to EXPLAIN invalidation, never to skip a rebuild. */
|
|
16
|
+
export function reverseDependencies(manifests) {
|
|
17
|
+
const bySymbol = new Map();
|
|
18
|
+
const byFile = new Map();
|
|
19
|
+
for (const m of manifests) {
|
|
20
|
+
for (const s of m.reachableSymbols) {
|
|
21
|
+
if (!bySymbol.has(s.symbolId)) bySymbol.set(s.symbolId, new Set());
|
|
22
|
+
bySymbol.get(s.symbolId).add(m.entrypointId);
|
|
23
|
+
if (!byFile.has(s.file)) byFile.set(s.file, new Set());
|
|
24
|
+
byFile.get(s.file).add(m.entrypointId);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const toObj = (map) => Object.fromEntries([...map.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, v]) => [k, [...v].sort()]));
|
|
28
|
+
return { symbols: toObj(bySymbol), files: toObj(byFile) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function routeKey(m) { return `${m.entrypoint.kind}|${m.entrypoint.method}|${m.entrypoint.path}|${m.entrypoint.registrationSite.file}`; }
|
|
32
|
+
const ctlKey = (c) => `${c.kind}|${c.file}|${c.line}|${c.evidence}|${c.basis}`;
|
|
33
|
+
const sensKey = (s) => `${s.kind}|${s.file}|${s.basis}`;
|
|
34
|
+
const unkKey = (u) => `${u.reason}|${u.calleeName}|${u.site.split(':')[0]}`;
|
|
35
|
+
const symKey = (s) => `${s.symbolId}|${s.spanHash}`;
|
|
36
|
+
|
|
37
|
+
function setDiff(a, b) {
|
|
38
|
+
const A = new Set(a), B = new Set(b);
|
|
39
|
+
return { added: [...B].filter(x => !A.has(x)).sort(), removed: [...A].filter(x => !B.has(x)).sort() };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Diff two sets of manifests (each an array). */
|
|
43
|
+
export function diffManifestSets(fromManifests, toManifests) {
|
|
44
|
+
const from = new Map(fromManifests.map(m => [routeKey(m), m]));
|
|
45
|
+
const to = new Map(toManifests.map(m => [routeKey(m), m]));
|
|
46
|
+
const added = [], removed = [], changed = [], unchanged = [];
|
|
47
|
+
for (const [k, m] of [...to.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
48
|
+
const prev = from.get(k);
|
|
49
|
+
if (!prev) { added.push({ route: `${m.entrypoint.method} ${m.entrypoint.path}`.trim(), file: m.entrypoint.registrationSite.file, integrity: m.context.integrity }); continue; }
|
|
50
|
+
if (prev.contextHash === m.contextHash) { unchanged.push(k); continue; }
|
|
51
|
+
const controls = setDiff(prev.controls.map(ctlKey), m.controls.map(ctlKey));
|
|
52
|
+
const sensitive = setDiff(prev.sensitiveOperations.map(sensKey), m.sensitiveOperations.map(sensKey));
|
|
53
|
+
const unknowns = setDiff(prev.unknowns.filter(u => u.material).map(unkKey), m.unknowns.filter(u => u.material).map(unkKey));
|
|
54
|
+
const deps = setDiff(prev.reachableSymbols.map(symKey), m.reachableSymbols.map(symKey));
|
|
55
|
+
const protection = {};
|
|
56
|
+
for (const dim of Object.keys(m.protection)) {
|
|
57
|
+
const a = prev.protection?.[dim]?.state; const b = m.protection[dim].state;
|
|
58
|
+
if (a !== b) protection[dim] = { from: a || null, to: b };
|
|
59
|
+
}
|
|
60
|
+
changed.push({
|
|
61
|
+
route: `${m.entrypoint.method} ${m.entrypoint.path}`.trim(), file: m.entrypoint.registrationSite.file,
|
|
62
|
+
contextHash: { from: prev.contextHash, to: m.contextHash },
|
|
63
|
+
integrity: { from: prev.context.integrity, to: m.context.integrity },
|
|
64
|
+
authControlChanges: controls, newSensitiveOperations: sensitive.added, removedSensitiveOperations: sensitive.removed,
|
|
65
|
+
unknownEdgeChanges: unknowns, dependencyChanges: { addedOrChangedSymbols: deps.added.length, removedOrChangedSymbols: deps.removed.length }, protectionChanges: protection,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const [k, m] of [...from.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
69
|
+
if (!to.has(k)) removed.push({ route: `${m.entrypoint.method} ${m.entrypoint.path}`.trim(), file: m.entrypoint.registrationSite.file });
|
|
70
|
+
}
|
|
71
|
+
return { added, removed, changed, unchangedCount: unchanged.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cache accounting against the stored index: for each current bundle, is the same context hash already stored, and if
|
|
76
|
+
* not, why (new route, or which previous hash it replaced).
|
|
77
|
+
*/
|
|
78
|
+
export function cacheReport(root, currentManifests, { hasReview = () => false } = {}) {
|
|
79
|
+
const idx = readStoreJson(root, 'index.json');
|
|
80
|
+
const previous = new Map();
|
|
81
|
+
let indexStatus = 'missing';
|
|
82
|
+
if (idx.ok && idx.value && Array.isArray(idx.value.entrypoints)) {
|
|
83
|
+
indexStatus = 'ok';
|
|
84
|
+
for (const e of idx.value.entrypoints) previous.set(e.entrypointId, e.contextHash);
|
|
85
|
+
} else if (!idx.ok && idx.code === 'corrupt-json') indexStatus = 'corrupt';
|
|
86
|
+
const rep = { indexStatus, hits: 0, misses: 0, invalidated: 0, newRoutes: 0, reviewCacheHits: 0, details: [] };
|
|
87
|
+
for (const m of currentManifests) {
|
|
88
|
+
const stored = readManifest(root, m.contextHash);
|
|
89
|
+
const prevHash = previous.get(m.entrypointId);
|
|
90
|
+
if (stored.ok && stored.value.manifestHash === m.manifestHash) rep.hits++;
|
|
91
|
+
else rep.misses++;
|
|
92
|
+
if (prevHash === undefined) { rep.newRoutes++; }
|
|
93
|
+
else if (prevHash !== m.contextHash) { rep.invalidated++; rep.details.push({ entrypointId: m.entrypointId, route: `${m.entrypoint.method} ${m.entrypoint.path}`.trim(), previous: prevHash, current: m.contextHash }); }
|
|
94
|
+
if (hasReview(m)) rep.reviewCacheHits++;
|
|
95
|
+
}
|
|
96
|
+
return rep;
|
|
97
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Entry points -> exact handler symbols.
|
|
3
|
+
//
|
|
4
|
+
// There is NO second route detector here. HTTP routes come from the engine's own route scan (`scanRoutes`) and, for
|
|
5
|
+
// Haskell, from the existing web model (`analyzeHaskellWeb`); non-HTTP surfaces come from the existing entry-point
|
|
6
|
+
// inventory. What this module adds is the join the context packer needs and the existing records do not carry: which
|
|
7
|
+
// declaration is the handler, which middleware wraps it, and an honest disposition for every route.
|
|
8
|
+
//
|
|
9
|
+
// Every detected entry point ends in exactly ONE disposition:
|
|
10
|
+
// resolved handler is a known declaration and nothing about how it is wired is in doubt
|
|
11
|
+
// partially_resolved a handler (or a registration block) is known, but something is not: a middleware that does
|
|
12
|
+
// not resolve, a handler matched by name only, a router whose mount point is not visible
|
|
13
|
+
// unresolved no handler could be identified; the manifest still exists and says so
|
|
14
|
+
// excluded_by_policy deliberately not processed (operator exclusion, or the entry-point cap)
|
|
15
|
+
import { hashOf } from './canonical.js';
|
|
16
|
+
import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
|
|
17
|
+
import { dependencyParams, signatureText } from './controls.js';
|
|
18
|
+
import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
|
|
19
|
+
|
|
20
|
+
export const DISPOSITIONS = Object.freeze(['resolved', 'partially_resolved', 'unresolved', 'excluded_by_policy']);
|
|
21
|
+
export const ENTRYPOINT_KINDS = Object.freeze(['http', 'webhook', 'queue', 'cron', 'upload', 'cli']);
|
|
22
|
+
|
|
23
|
+
const JS_FILE = /\.(?:js|jsx|mjs|cjs|ts|tsx)$/i;
|
|
24
|
+
const HS_FILE = /\.l?hs$/i;
|
|
25
|
+
const INLINE_FN = /^(?:async\s+)?(?:function\b|\([^)]*\)\s*(?::\s*[\w<>\[\]|. ]+)?\s*=>|[A-Za-z_$][\w$]*\s*=>|\\)/;
|
|
26
|
+
const IDENT_CHAIN = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/;
|
|
27
|
+
|
|
28
|
+
function lastSeg(n) { const p = String(n).split(/[.:]/); return p[p.length - 1]; }
|
|
29
|
+
|
|
30
|
+
function globToRe(g) {
|
|
31
|
+
const esc = String(g).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\0/g, '.*');
|
|
32
|
+
return new RegExp(`^${esc}$`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function symbolIndexes(graph) {
|
|
36
|
+
const byFile = new Map();
|
|
37
|
+
const byName = new Map();
|
|
38
|
+
const byId = new Map();
|
|
39
|
+
const imports = new Map();
|
|
40
|
+
for (const i of graph.imports || []) { if (!imports.has(i.file)) imports.set(i.file, []); imports.get(i.file).push(i); }
|
|
41
|
+
for (const s of graph.symbols) {
|
|
42
|
+
byId.set(s.id, s);
|
|
43
|
+
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
44
|
+
byFile.get(s.file).push(s);
|
|
45
|
+
if (s.kind === 'function' && !s.name.startsWith('<')) {
|
|
46
|
+
for (const key of new Set([s.name, lastSeg(s.name)])) {
|
|
47
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
48
|
+
byName.get(key).push(s);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const arr of byFile.values()) arr.sort((a, b) => a.declLine - b.declLine || (a.id < b.id ? -1 : 1));
|
|
53
|
+
return { byFile, byName, byId, imports };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve a referenced name to a symbol: same-file exact, then project-unique by name (low confidence). */
|
|
57
|
+
function resolveName(name, file, idx) {
|
|
58
|
+
// an import statement binds the name to a specific module: that beats any project-wide guess
|
|
59
|
+
const imp = (idx.imports.get(file) || []).find(i => i.local === name.split('.')[0]);
|
|
60
|
+
if (imp && imp.kind === 'repo') {
|
|
61
|
+
const member = name.includes('.') ? name.split('.').pop() : (imp.member === '*' || imp.member === 'default' ? name : imp.member);
|
|
62
|
+
const hit = (idx.byName.get(member) || []).filter(s => s.file === imp.target);
|
|
63
|
+
if (hit.length === 1) return { symbol: hit[0], resolution: 'resolved', via: 'import' };
|
|
64
|
+
}
|
|
65
|
+
const exact = (idx.byName.get(name) || []).filter(s => s.file === file);
|
|
66
|
+
if (exact.length === 1) return { symbol: exact[0], resolution: 'resolved' };
|
|
67
|
+
if (exact.length > 1) return { symbol: null, resolution: 'ambiguous', candidates: exact.map(s => s.id) };
|
|
68
|
+
const key = lastSeg(name);
|
|
69
|
+
const all = idx.byName.get(name) || idx.byName.get(key) || [];
|
|
70
|
+
if (all.length === 1) return { symbol: all[0], resolution: 'name-match' };
|
|
71
|
+
if (all.length > 1) return { symbol: null, resolution: 'ambiguous', candidates: all.slice(0, 5).map(s => s.id) };
|
|
72
|
+
return { symbol: null, resolution: 'unresolved' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `X.use(...)` registrations in a JS/TS file: the global / prefix middleware a route can inherit. */
|
|
76
|
+
function collectUses(fileContents) {
|
|
77
|
+
const uses = [];
|
|
78
|
+
for (const [file, text] of Object.entries(fileContents)) {
|
|
79
|
+
if (!JS_FILE.test(file)) continue;
|
|
80
|
+
const lines = text.split('\n');
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const m = /^\s*([A-Za-z_$][\w$]*)\.use\s*\(/.exec(lines[i]);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const st = callStatement(lines, i + 1);
|
|
85
|
+
if (!st) continue;
|
|
86
|
+
const args = splitArgs(st.text);
|
|
87
|
+
let prefix = '/';
|
|
88
|
+
let rest = args;
|
|
89
|
+
const q = args[0] && /^(['"`])(.*)\1$/.exec(args[0]);
|
|
90
|
+
if (q) { prefix = q[2]; rest = args.slice(1); }
|
|
91
|
+
const names = rest.map(a => { const id = IDENT_CHAIN.exec(a.replace(/^\[|\]$/g, '')); return id ? { name: id[0], text: a } : { name: null, text: a }; });
|
|
92
|
+
uses.push({ file, line: i + 1, receiver: m[1], prefix, args: names });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return uses;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function routeKey(r) { return `${r.file}|${r.line}|${r.method}|${r.path}`; }
|
|
99
|
+
|
|
100
|
+
async function collectRoutes(fileContents) {
|
|
101
|
+
const { scanRoutes } = await import('../engine.js');
|
|
102
|
+
const { analyzeHaskellWeb } = await import('../language/haskell-web.js');
|
|
103
|
+
const routes = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const add = (r) => { const k = routeKey(r); if (!seen.has(k)) { seen.add(k); routes.push(r); } };
|
|
106
|
+
const hs = {};
|
|
107
|
+
for (const file of Object.keys(fileContents).sort()) {
|
|
108
|
+
if (HS_FILE.test(file)) { hs[file] = fileContents[file]; continue; }
|
|
109
|
+
let found = [];
|
|
110
|
+
try { found = scanRoutes(file, fileContents[file]) || []; } catch { found = []; }
|
|
111
|
+
for (const r of found) add({ ...r, language: 'generic' });
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(hs).length) {
|
|
114
|
+
let web = null;
|
|
115
|
+
try { web = analyzeHaskellWeb(hs); } catch { web = null; }
|
|
116
|
+
if (web) for (const r of web.routes) add({ method: r.method, path: r.path, framework: r.framework, file: r.file, line: r.line, hasAuth: r.auth.status === 'authenticated', language: 'haskell', handler: r.handler, webModel: { auth: r.auth, role: r.role, ownership: r.ownership, idLookups: r.idLookups, csrf: r.csrf, sensitive: r.sensitive, uploads: r.uploads } });
|
|
117
|
+
}
|
|
118
|
+
return routes;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveHandler(route, ctx) {
|
|
122
|
+
const { fileContents, idx, graph } = ctx;
|
|
123
|
+
const lines = fileContents[route.file].split('\n');
|
|
124
|
+
const reasons = [];
|
|
125
|
+
const out = { handlerSymbol: null, handlerKind: 'unknown', rootSpan: null, middleware: [], reasons, confidence: 'none' };
|
|
126
|
+
const fileSyms = idx.byFile.get(route.file) || [];
|
|
127
|
+
|
|
128
|
+
if (route.language === 'haskell') {
|
|
129
|
+
const h = route.handler || {};
|
|
130
|
+
if (h.kind === 'named' && h.name) {
|
|
131
|
+
const r = resolveName(h.name, route.file, idx);
|
|
132
|
+
if (r.symbol) { out.handlerSymbol = r.symbol.id; out.handlerKind = 'named'; out.confidence = r.resolution === 'resolved' ? 'exact' : 'name-match'; if (r.resolution !== 'resolved') reasons.push('handler-matched-by-name-only'); }
|
|
133
|
+
else reasons.push(`handler-${r.resolution}`);
|
|
134
|
+
} else if (h.kind === 'inline') {
|
|
135
|
+
out.handlerKind = 'inline';
|
|
136
|
+
out.rootSpan = { file: route.file, ...inlineBlockSpan(lines, route.line) };
|
|
137
|
+
const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= route.line && s.endLine >= route.line).sort((a, b) => (b.startLine - a.startLine))[0];
|
|
138
|
+
out.handlerSymbol = enclosing ? enclosing.id : null;
|
|
139
|
+
out.confidence = enclosing ? 'inline-block' : 'none';
|
|
140
|
+
if (!enclosing) reasons.push('inline-handler-without-enclosing-declaration');
|
|
141
|
+
} else {
|
|
142
|
+
reasons.push('handler-not-statically-known');
|
|
143
|
+
// the manifest still shows where the route is registered, so the gap is inspectable rather than absent
|
|
144
|
+
out.rootSpan = { file: route.file, ...inlineBlockSpan(lines, route.line) };
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rawLine = lines[route.line - 1] || '';
|
|
150
|
+
if (/^\s*@/.test(rawLine)) {
|
|
151
|
+
// decorator / annotation style: the handler is the next declaration
|
|
152
|
+
const decl = fileSyms.filter(s => s.kind === 'function' && s.declLine >= route.line && s.declLine <= route.line + 12).sort((a, b) => a.declLine - b.declLine)[0];
|
|
153
|
+
if (decl) {
|
|
154
|
+
out.handlerSymbol = decl.id; out.handlerKind = 'decorated'; out.confidence = 'exact';
|
|
155
|
+
// dependency-injected guards in the signature (`user=Depends(require_user)`) run before the body
|
|
156
|
+
for (const d of dependencyParams(signatureText(lines, decl.declLine))) {
|
|
157
|
+
const r = resolveName(d.dependency, route.file, idx);
|
|
158
|
+
out.middleware.push({ name: d.dependency, symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: decl.declLine, dependency: true, param: d.param });
|
|
159
|
+
if (!r.symbol) reasons.push(`dependency-${r.resolution}:${d.dependency}`);
|
|
160
|
+
else if (r.resolution === 'name-match') reasons.push(`dependency-matched-by-name-only:${d.dependency}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else reasons.push('decorated-handler-not-found');
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const st = JS_FILE.test(route.file) ? callStatement(lines, route.line) : null;
|
|
168
|
+
if (!st) {
|
|
169
|
+
// not a call-style registration we can parse: fall back to the enclosing declaration, flagged
|
|
170
|
+
const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= route.line && s.endLine >= route.line).sort((a, b) => b.startLine - a.startLine)[0];
|
|
171
|
+
if (enclosing) { out.handlerSymbol = enclosing.id; out.handlerKind = 'enclosing'; out.confidence = 'enclosing'; reasons.push('registration-form-not-parsed'); }
|
|
172
|
+
else reasons.push('registration-form-not-parsed');
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
out.rootSpan = { file: route.file, startLine: route.line, endLine: st.endLine, precision: 'brace' };
|
|
176
|
+
const args = splitArgs(st.text).slice(1); // drop the path
|
|
177
|
+
const flat = [];
|
|
178
|
+
for (const a of args) {
|
|
179
|
+
if (a.startsWith('[') && a.endsWith(']')) flat.push(...splitArgs(`(${a.slice(1, -1)})`));
|
|
180
|
+
else flat.push(a);
|
|
181
|
+
}
|
|
182
|
+
if (!flat.length) { reasons.push('registration-has-no-handler-argument'); return out; }
|
|
183
|
+
const last = flat[flat.length - 1];
|
|
184
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
185
|
+
const a = flat[i];
|
|
186
|
+
if (INLINE_FN.test(a)) { out.middleware.push({ name: '<inline>', symbolId: null, resolution: 'inline', line: route.line }); continue; }
|
|
187
|
+
const id = IDENT_CHAIN.exec(a);
|
|
188
|
+
if (!id) { out.middleware.push({ name: null, text: a.slice(0, 80), symbolId: null, resolution: 'unresolved', line: route.line }); reasons.push('middleware-not-an-identifier'); continue; }
|
|
189
|
+
const r = resolveName(id[0], route.file, idx);
|
|
190
|
+
out.middleware.push({ name: id[0], symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: route.line, factory: /\(/.test(a) });
|
|
191
|
+
if (!r.symbol) reasons.push(`middleware-${r.resolution}:${id[0]}`);
|
|
192
|
+
else if (r.resolution === 'name-match') reasons.push(`middleware-matched-by-name-only:${id[0]}`);
|
|
193
|
+
}
|
|
194
|
+
if (INLINE_FN.test(last)) {
|
|
195
|
+
const cands = fileSyms.filter(s => s.kind === 'function' && s.name.startsWith('<anon>') === false && false);
|
|
196
|
+
const anon = fileSyms.filter(s => s.id.includes('::<anon>@') && s.declLine >= route.line && s.declLine <= st.endLine);
|
|
197
|
+
const outermost = anon.filter(a => !anon.some(b => b !== a && b.startLine <= a.startLine && b.endLine >= a.endLine && (b.startLine < a.startLine || b.endLine > a.endLine)));
|
|
198
|
+
void cands;
|
|
199
|
+
const pick = outermost.sort((a, b) => a.declLine - b.declLine || (a.id < b.id ? -1 : 1)).pop();
|
|
200
|
+
if (pick) { out.handlerSymbol = pick.id; out.handlerKind = 'inline'; out.confidence = 'exact'; }
|
|
201
|
+
else reasons.push('inline-handler-not-in-ir');
|
|
202
|
+
} else {
|
|
203
|
+
const id = IDENT_CHAIN.exec(last);
|
|
204
|
+
if (!id) { reasons.push('handler-not-an-identifier'); }
|
|
205
|
+
else {
|
|
206
|
+
const r = resolveName(id[0], route.file, idx);
|
|
207
|
+
if (r.symbol) { out.handlerSymbol = r.symbol.id; out.handlerKind = 'named'; out.confidence = r.resolution === 'resolved' ? 'exact' : 'name-match'; if (r.resolution !== 'resolved') reasons.push('handler-matched-by-name-only'); }
|
|
208
|
+
else reasons.push(`handler-${r.resolution}:${id[0]}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
void graph;
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pathMatchesPrefix(routePath, prefix) {
|
|
216
|
+
if (prefix === '/' || prefix === '') return true;
|
|
217
|
+
const p = prefix.replace(/\/+$/, '');
|
|
218
|
+
return routePath === p || routePath.startsWith(p + '/') || routePath.startsWith(p);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build the entry-point records.
|
|
223
|
+
* config.entrypointKinds, config.exclude (globs on file or route path), config.maxEntrypoints
|
|
224
|
+
*/
|
|
225
|
+
export async function collectEntrypoints({ fileContents, graph, config = {} }) {
|
|
226
|
+
const kinds = new Set(config.entrypointKinds && config.entrypointKinds.length ? config.entrypointKinds : ['http']);
|
|
227
|
+
const idx = symbolIndexes(graph);
|
|
228
|
+
const uses = collectUses(fileContents);
|
|
229
|
+
const exclude = (config.exclude || []).map(globToRe);
|
|
230
|
+
const cap = Number.isInteger(config.maxEntrypoints) && config.maxEntrypoints > 0 ? config.maxEntrypoints : 5000;
|
|
231
|
+
const ctx = { fileContents, idx, graph };
|
|
232
|
+
const records = [];
|
|
233
|
+
|
|
234
|
+
if (kinds.has('http') || kinds.has('webhook')) {
|
|
235
|
+
for (const route of await collectRoutes(fileContents)) {
|
|
236
|
+
const isWebhook = /webhook/i.test(route.path || '');
|
|
237
|
+
if (isWebhook && !kinds.has('webhook') && !kinds.has('http')) continue;
|
|
238
|
+
const h = resolveHandler(route, ctx);
|
|
239
|
+
const reasons = [...h.reasons];
|
|
240
|
+
const middleware = [...h.middleware];
|
|
241
|
+
// inherited middleware registered with `X.use(...)` on the same receiver, earlier in the same file
|
|
242
|
+
if (JS_FILE.test(route.file)) {
|
|
243
|
+
const lines = fileContents[route.file].split('\n');
|
|
244
|
+
const recvM = /([A-Za-z_$][\w$]*)\.(?:get|post|put|patch|delete|all|options|head|route)\s*\(/.exec(lines[route.line - 1] || '');
|
|
245
|
+
const receiver = recvM ? recvM[1] : null;
|
|
246
|
+
for (const u of uses) {
|
|
247
|
+
if (u.file !== route.file || u.line >= route.line || u.receiver !== receiver) continue;
|
|
248
|
+
if (!pathMatchesPrefix(String(route.path || ''), u.prefix)) continue;
|
|
249
|
+
for (const a of u.args) {
|
|
250
|
+
if (!a.name) { middleware.push({ name: null, text: a.text.slice(0, 80), symbolId: null, resolution: 'unresolved', line: u.line, scope: u.prefix, inherited: true }); reasons.push('inherited-middleware-not-an-identifier'); continue; }
|
|
251
|
+
const r = resolveName(a.name, u.file, idx);
|
|
252
|
+
middleware.push({ name: a.name, symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: u.line, scope: u.prefix, inherited: true });
|
|
253
|
+
if (!r.symbol) reasons.push(`inherited-middleware-${r.resolution}:${a.name}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (receiver && /^(?:router|routes?|r|api|\w*Router)$/i.test(receiver) && !uses.some(u => u.receiver !== receiver && u.args.some(a => a.name === receiver))) {
|
|
257
|
+
reasons.push('router-mount-not-resolved');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const authScheme = route.webModel
|
|
261
|
+
? { status: route.webModel.auth.status, kind: route.webModel.auth.kind || null, basis: 'haskell-web-model', role: route.webModel.role.status, ownership: route.webModel.ownership || null, idLookups: route.webModel.idLookups || [], csrf: !!route.webModel.csrf }
|
|
262
|
+
: { status: route.hasAuth ? 'authenticated' : 'none', basis: 'route-scan-heuristic', note: 'authentication seen near the registration; says nothing about ownership' };
|
|
263
|
+
const base = {
|
|
264
|
+
kind: isWebhook ? 'webhook' : 'http',
|
|
265
|
+
method: String(route.method || 'GET'),
|
|
266
|
+
pathTemplate: String(route.path || ''),
|
|
267
|
+
framework: route.framework || null,
|
|
268
|
+
language: route.language === 'haskell' ? 'haskell' : (JS_FILE.test(route.file) ? 'javascript' : (route.file.split('.').pop())),
|
|
269
|
+
registrationSite: { file: route.file, line: route.line },
|
|
270
|
+
handlerSymbol: h.handlerSymbol,
|
|
271
|
+
handlerKind: h.handlerKind,
|
|
272
|
+
handlerConfidence: h.confidence,
|
|
273
|
+
rootSpan: h.rootSpan,
|
|
274
|
+
middleware,
|
|
275
|
+
authScheme,
|
|
276
|
+
externalDependencies: [],
|
|
277
|
+
resolutionReasons: [...new Set(reasons)].sort(),
|
|
278
|
+
};
|
|
279
|
+
records.push(base);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// non-HTTP surfaces from the existing inventory
|
|
284
|
+
const wantInv = ['queue', 'cron', 'upload', 'cli', 'webhook'].filter(k => kinds.has(k));
|
|
285
|
+
if (wantInv.length) {
|
|
286
|
+
const inv = buildEntrypointInventory(fileContents, { routes: [] });
|
|
287
|
+
for (const e of inv.entrypoints) {
|
|
288
|
+
if (!wantInv.includes(e.type)) continue;
|
|
289
|
+
const fileSyms = idx.byFile.get(e.file) || [];
|
|
290
|
+
const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= e.line && s.endLine >= e.line).sort((a, b) => b.startLine - a.startLine)[0];
|
|
291
|
+
records.push({
|
|
292
|
+
kind: e.type, method: '', pathTemplate: e.name, framework: null,
|
|
293
|
+
language: JS_FILE.test(e.file) ? 'javascript' : e.file.split('.').pop(),
|
|
294
|
+
registrationSite: { file: e.file, line: e.line },
|
|
295
|
+
handlerSymbol: enclosing ? enclosing.id : null,
|
|
296
|
+
handlerKind: enclosing ? 'enclosing' : 'unknown', handlerConfidence: enclosing ? 'enclosing' : 'none',
|
|
297
|
+
rootSpan: null, middleware: [],
|
|
298
|
+
authScheme: { status: e.trust === 'authenticated' ? 'authenticated' : 'none', basis: 'inventory-heuristic' },
|
|
299
|
+
externalDependencies: [],
|
|
300
|
+
resolutionReasons: enclosing ? ['non-http-handler-is-enclosing-declaration'] : ['no-enclosing-declaration'],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ids, dispositions
|
|
306
|
+
records.sort((a, b) =>
|
|
307
|
+
(a.registrationSite.file < b.registrationSite.file ? -1 : a.registrationSite.file > b.registrationSite.file ? 1 : 0) ||
|
|
308
|
+
(a.registrationSite.line - b.registrationSite.line) || (a.method < b.method ? -1 : 1) || (a.pathTemplate < b.pathTemplate ? -1 : a.pathTemplate > b.pathTemplate ? 1 : 0));
|
|
309
|
+
const ord = new Map();
|
|
310
|
+
records.forEach((r, i) => {
|
|
311
|
+
const hname = r.handlerSymbol || `@${r.registrationSite.line}`;
|
|
312
|
+
const stem = hashOf({ k: r.kind, m: r.method, p: r.pathTemplate, f: r.registrationSite.file, h: r.handlerSymbol ? r.handlerSymbol : null });
|
|
313
|
+
const n = (ord.get(stem) || 0) + 1;
|
|
314
|
+
ord.set(stem, n);
|
|
315
|
+
r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
|
|
316
|
+
r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}`.trim() || r.kind;
|
|
317
|
+
void hname;
|
|
318
|
+
let disposition;
|
|
319
|
+
const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
|
|
320
|
+
if (excluded) { disposition = 'excluded_by_policy'; r.resolutionReasons = [...r.resolutionReasons, 'excluded-by-operator-pattern']; }
|
|
321
|
+
else if (i >= cap) { disposition = 'excluded_by_policy'; r.resolutionReasons = [...r.resolutionReasons, 'max-entrypoints-cap']; }
|
|
322
|
+
else if (!r.handlerSymbol && !r.rootSpan) disposition = 'unresolved';
|
|
323
|
+
else if (!r.handlerSymbol) disposition = 'partially_resolved';
|
|
324
|
+
else if (r.resolutionReasons.length) disposition = 'partially_resolved';
|
|
325
|
+
else disposition = 'resolved';
|
|
326
|
+
r.disposition = disposition;
|
|
327
|
+
});
|
|
328
|
+
return records;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Counts per disposition. Every entry point is in exactly one bucket, so the buckets always sum to the total. */
|
|
332
|
+
export function countDispositions(records) {
|
|
333
|
+
const c = { total: records.length, resolved: 0, partially_resolved: 0, unresolved: 0, excluded_by_policy: 0 };
|
|
334
|
+
for (const r of records) c[r.disposition]++;
|
|
335
|
+
return c;
|
|
336
|
+
}
|