@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +6 -0
  3. package/dist/1336.index.js +891 -719
  4. package/dist/550.index.js +548 -0
  5. package/dist/7405.index.js +885 -0
  6. package/dist/9382.index.js +4386 -0
  7. package/dist/9579.index.js +1197 -0
  8. package/dist/agentic-security.mjs +4 -4
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +4 -4
  11. package/src/context-intelligence/CLAUDE.md +25 -0
  12. package/src/context-intelligence/adapters/existing-ir.js +14 -0
  13. package/src/context-intelligence/adapters/haskell-hie.js +165 -0
  14. package/src/context-intelligence/build.js +68 -0
  15. package/src/context-intelligence/candidates.js +115 -0
  16. package/src/context-intelligence/canonical.js +120 -0
  17. package/src/context-intelligence/capabilities.js +33 -0
  18. package/src/context-intelligence/cli.js +426 -0
  19. package/src/context-intelligence/config.js +91 -0
  20. package/src/context-intelligence/controls.js +244 -0
  21. package/src/context-intelligence/diff.js +97 -0
  22. package/src/context-intelligence/entrypoints.js +336 -0
  23. package/src/context-intelligence/evaluation/index.js +235 -0
  24. package/src/context-intelligence/evaluation/runner.js +139 -0
  25. package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
  26. package/src/context-intelligence/files.js +80 -0
  27. package/src/context-intelligence/graph-schema.js +227 -0
  28. package/src/context-intelligence/imports.js +123 -0
  29. package/src/context-intelligence/manifest.js +99 -0
  30. package/src/context-intelligence/packer.js +224 -0
  31. package/src/context-intelligence/persist.js +40 -0
  32. package/src/context-intelligence/prompts/packs.js +82 -0
  33. package/src/context-intelligence/queue.js +355 -0
  34. package/src/context-intelligence/reachability.js +188 -0
  35. package/src/context-intelligence/repo-info.js +16 -0
  36. package/src/context-intelligence/review.js +370 -0
  37. package/src/context-intelligence/spans.js +147 -0
  38. package/src/context-intelligence/store.js +203 -0
  39. package/src/posture/artifact-registry.js +2 -0
@@ -0,0 +1,244 @@
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
+ // An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
51
+ // qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
52
+ const OWNER_KEY = '\\w*?(?:[Oo]wner|[Tt]enant|[Oo]rg(?:ani[sz]ation)?|[Aa]ccount|[Cc]reated_?[Bb]y|[Aa]uthor|[Tt]eam|[Ww]orkspace)(?:_?(?:[Ii]d|ID))?\\b|[Uu]ser_?(?:[Ii]d|ID)\\b';
53
+ const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
54
+ const TENANT_WORD = /tenant|org|workspace|team|account/i;
55
+ // a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
56
+ const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
57
+ // an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
58
+ const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
59
+ const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
60
+ const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
61
+
62
+ /**
63
+ * Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
64
+ * same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
65
+ * `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
66
+ * principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
67
+ */
68
+ export function ownerBinding(text, extra = null) {
69
+ let best = null;
70
+ const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
71
+ const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
72
+ for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
73
+ // the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
74
+ const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
75
+ if (!OWNER_KEY_RE.test(forKey)) continue;
76
+ const pr = principalIn(clause, extra);
77
+ if (pr) take(pr, TENANT_WORD.test(clause));
78
+ }
79
+ for (const m of text.match(STRING_LITERAL) || []) {
80
+ const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
81
+ if (placeholder) {
82
+ const pr = principalIn(text, extra);
83
+ if (pr) take(pr, TENANT_WORD.test(m));
84
+ }
85
+ if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
86
+ }
87
+ return best;
88
+ }
89
+
90
+ const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
91
+ 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;
92
+
93
+ 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;
94
+
95
+ 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;
96
+ 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/;
97
+ const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
98
+ 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\.)/;
99
+
100
+ // Names that answer a question rather than enforce a rule.
101
+ const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
102
+ function bareCallStatement(trimmedLine, name) {
103
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
105
+ }
106
+
107
+ const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
108
+
109
+ function kindOfAuthName(name) {
110
+ const n = name.toLowerCase();
111
+ if (/(?:owner|ownedby|belongsto)/.test(n)) return 'ownership';
112
+ if (/(?:tenant|org|scope|member|workspace|team)/.test(n)) return 'tenant-scope';
113
+ if (/(?:role|admin|perm|polic|abilit|^can|\.can|cannot|allow)/.test(n)) return 'authorization-role';
114
+ return 'authentication';
115
+ }
116
+
117
+ /**
118
+ * The text of the statement that starts on line `ln`: from that line until its parentheses balance (capped). A query
119
+ * filter is judged on its OWN statement; looking at neighbouring lines is how a guard written on the next line gets
120
+ * mistaken for a filter on the query above it.
121
+ */
122
+ function statementAt(lines, ln, maxLines = 12) {
123
+ let depth = 0;
124
+ let text = '';
125
+ for (let i = ln - 1; i < Math.min(lines.length, ln - 1 + maxLines); i++) {
126
+ const s = lines[i];
127
+ text += ' ' + s;
128
+ for (const c of s) { if (c === '(') depth++; else if (c === ')') depth--; }
129
+ if (depth <= 0) break;
130
+ }
131
+ return text;
132
+ }
133
+
134
+ function clip(s) { return s.trim().slice(0, 160); }
135
+
136
+ /**
137
+ * Facts for one symbol.
138
+ * symbol graph symbol (id, file, startLine, endLine, declLine)
139
+ * lines the FILE's lines (0-based array)
140
+ * Returns [{ symbolId, line, category:'control'|'sensitive', kind, subkind, evidence, basis, rejectionSeen, text }]
141
+ */
142
+ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ const depParams = symbol.declLine ? dependencyParams(signatureText(lines, symbol.declLine)).filter(d => PRINCIPAL_DEP.test(d.dependency)) : [];
146
+ const extraPrincipal = depParams.length ? new RegExp(`\\b(?:${depParams.map(d => d.param).join('|')})\\b\\s*(?:\\.|\\[)`) : null;
147
+ const push = (f) => {
148
+ const key = `${f.line}|${f.category}|${f.kind}|${f.basis}`;
149
+ if (seen.has(key)) return;
150
+ seen.add(key);
151
+ out.push({ symbolId: symbol.id, ...f });
152
+ };
153
+
154
+ for (let ln = symbol.startLine; ln <= symbol.endLine; ln++) {
155
+ const raw = lines[ln - 1];
156
+ if (raw === undefined) break;
157
+ const t = raw.trim();
158
+ if (t === '' || /^(?:\/\/|#(?!\[)|--|\/\*|\*)/.test(t)) continue;
159
+
160
+ // decorators / annotations that sit above the declaration are guards in their own right
161
+ if (ln < (symbol.declLine || symbol.startLine) + 1 && DECORATOR.test(raw)) {
162
+ const kind = /role|perm|PreAuthorize|PostAuthorize|Secured|RolesAllowed|admin|staff|Roles|Permissions|RequirePermission/i.test(raw) ? 'authorization-role' : 'authentication';
163
+ push({ line: ln, category: 'control', kind, subkind: null, evidence: 'observed', basis: 'decorator', rejectionSeen: true, text: clip(raw) });
164
+ continue;
165
+ }
166
+
167
+ // a declaration line is not a call: `function requireUser(` names a function, it does not invoke one
168
+ 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);
169
+
170
+ // calls on this line
171
+ const calls = [];
172
+ const re = /([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)\s*\(/g;
173
+ let m;
174
+ if (!isDeclLine) { while ((m = re.exec(raw))) calls.push(m[1]); }
175
+ if (haskell && !isDeclLine) {
176
+ const hre = /\b([A-Z][\w.]*\.[a-z][\w']*|[a-z][\w']*)\b/g;
177
+ while ((m = hre.exec(raw))) if (AUTH_NAME.test(m[1]) || HS_SENSITIVE.test(m[1])) calls.push(m[1]);
178
+ }
179
+ for (const name of calls) {
180
+ if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
181
+ const lastSeg = name.split(/[.:]/).pop();
182
+ // A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
183
+ // so it guards nothing, whatever the lines around it say.
184
+ const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
185
+ const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
186
+ push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: discarded ? 'call-name-result-discarded' : 'call-name', rejectionSeen: rej, text: clip(raw) });
187
+ }
188
+ const last = name.split(/[.:]/).pop();
189
+ const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
190
+ const isSens = haskell
191
+ ? HS_SENSITIVE.test(name)
192
+ : (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)));
193
+ if (isSens) {
194
+ const verb = WRITE_VERB.test(last) ? 'write' : 'read';
195
+ const sub = /(?:writeFile|appendFile|unlink|rm|rmdir)/i.test(last) ? 'filesystem'
196
+ : /(?:spawn|exec|system|popen|callProcess|readProcess)/i.test(last) && !/execute|query/i.test(last) ? 'process'
197
+ : /(?:fetch|request|axios|http|got|superagent|requests|httpx)/i.test(name) ? 'external-call'
198
+ : `data-${verb}`;
199
+ push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
200
+ // a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
201
+ const stmt = statementAt(lines, ln);
202
+ const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
203
+ if (pr) {
204
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
205
+ }
206
+ }
207
+ }
208
+ if (SQL_TEXT.test(raw)) {
209
+ const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
210
+ push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
211
+ const stmt = statementAt(lines, ln);
212
+ const pr = ownerBinding(stmt, extraPrincipal);
213
+ if (pr) {
214
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
215
+ }
216
+ }
217
+
218
+ // a resource field compared with the principal: the ownership check itself
219
+ const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
220
+ if (cmpPr) {
221
+ const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
222
+ push({ line: ln, category: 'control', kind: cmpPr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr.level === 'qualified' ? 'observed' : 'inferred', basis: cmpPr.level === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
223
+ }
224
+ }
225
+ // A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
226
+ // 'inferred' (the name) with the rejection read from the body; a name alone never counts as rejecting.
227
+ if (symbol.kind === undefined || symbol.kind === 'function') {
228
+ const nm = String(symbol.name || '');
229
+ if (nm && !nm.startsWith('<') && AUTH_NAME.test(nm)) {
230
+ const body = lines.slice(symbol.startLine - 1, symbol.endLine).join('\n');
231
+ 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] || '') });
232
+ }
233
+ }
234
+ 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));
235
+ return out;
236
+ }
237
+
238
+ /** True when a fact set contains anything the packer must never drop. */
239
+ export function isSecuritySignificant(facts) {
240
+ return facts.length > 0;
241
+ }
242
+
243
+ 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;
244
+ 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
+ }