@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,224 @@
1
+ //
2
+ // Security-preserving context packer.
3
+ //
4
+ // Takes the deterministic reachability result and produces one or more bounded, readable bundles. The rules, in the
5
+ // order they bind:
6
+ //
7
+ // 1. NOTHING that holds a control or a sensitive operation is ever pruned for size reasons. Pruning for "low value"
8
+ // removes only whole symbols that (a) look like telemetry by name, AND (b) have no control, sensitive operation or
9
+ // material unknown anywhere in their own subtree. There is no line-level pruning: a line cannot be proven
10
+ // irrelevant, a whole telemetry helper can.
11
+ // 2. Over budget: de-duplicate (overlapping spans merge), keep the mandatory core (registration, handler, middleware),
12
+ // then fill by priority (symbols with controls / sensitive operations first). What does not fit goes into a LINKED
13
+ // package, not into the void; what fits in no package is recorded as omitted_due_to_budget.
14
+ // 3. If even the mandatory core does not fit the budget the bundle is `unavailable`. It is never truncated into
15
+ // something that looks whole.
16
+ // 4. A bundle is `complete` only when nothing is unresolved, capped, omitted, imprecise or split.
17
+ //
18
+ // The bundle text is machine-generated: the entry point, the resolution status, the list of unknowns and omissions,
19
+ // and the numbered source. No model writes any of it.
20
+ import { sha256, hashOf, sliceLines } from './canonical.js';
21
+ import { looksLikeTelemetry } from './controls.js';
22
+
23
+ export const PROFILES = Object.freeze({ compact: 8000, standard: 32000, extended: 100000 });
24
+ export const TOKENIZER_ESTIMATE = 'chars/4-estimate';
25
+ export const MAX_PACKAGES = 4;
26
+
27
+ export function estimateTokens(text, tokenizer) {
28
+ if (typeof tokenizer === 'function') {
29
+ try { const n = tokenizer(text); if (Number.isFinite(n) && n >= 0) return { tokens: Math.ceil(n), tokenizer: tokenizer.tokenizerName || 'injected', estimated: false }; } catch { /* fall through */ }
30
+ }
31
+ return { tokens: Math.ceil(text.length / 4), tokenizer: TOKENIZER_ESTIMATE, estimated: true };
32
+ }
33
+
34
+ function renderSegment(seg, lines) {
35
+ const width = String(seg.endLine).length;
36
+ const body = [];
37
+ for (let n = seg.startLine; n <= seg.endLine; n++) body.push(`${String(n).padStart(width, ' ')}| ${lines[n - 1] ?? ''}`);
38
+ return `=== SOURCE ${seg.file}:${seg.startLine}-${seg.endLine} | ${seg.reasons.join('+')} ===\n${body.join('\n')}`;
39
+ }
40
+
41
+ /** Merge overlapping spans in one file into single segments; reasons and symbol ids are unioned, priority is the best. */
42
+ function mergeOverlaps(items) {
43
+ const byFile = new Map();
44
+ for (const it of items) { if (!byFile.has(it.file)) byFile.set(it.file, []); byFile.get(it.file).push(it); }
45
+ const out = [];
46
+ for (const file of [...byFile.keys()].sort()) {
47
+ const arr = byFile.get(file).sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine);
48
+ let cur = null;
49
+ for (const it of arr) {
50
+ if (cur && it.startLine <= cur.endLine) {
51
+ cur.endLine = Math.max(cur.endLine, it.endLine);
52
+ cur.symbolIds = [...new Set([...cur.symbolIds, ...it.symbolIds])];
53
+ cur.reasons = [...new Set([...cur.reasons, ...it.reasons])];
54
+ cur.priority = Math.min(cur.priority, it.priority);
55
+ cur.order = Math.min(cur.order, it.order);
56
+ cur.hasFacts = cur.hasFacts || it.hasFacts;
57
+ cur.imprecise = cur.imprecise || it.imprecise;
58
+ } else {
59
+ if (cur) out.push(cur);
60
+ cur = { ...it, symbolIds: [...it.symbolIds], reasons: [...it.reasons] };
61
+ }
62
+ }
63
+ if (cur) out.push(cur);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Pack.
70
+ * entrypoint, reach (reachFrom result), graph, fileContents
71
+ * config { profile, maxTokens, pruneLowValue, tokenizer? }
72
+ */
73
+ export function packContext({ entrypoint, reach, graph, fileContents, config = {} }) {
74
+ const profile = Object.hasOwn(PROFILES, config.profile) ? config.profile : 'standard';
75
+ const maxTokens = Number.isInteger(config.maxTokens) && config.maxTokens > 0 ? config.maxTokens : PROFILES[profile];
76
+ const prune = config.pruneLowValue !== false;
77
+ const byId = new Map(graph.symbols.map(s => [s.id, s]));
78
+ const linesCache = new Map();
79
+ const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
80
+
81
+ const omissions = [];
82
+ const factSyms = new Set(reach.facts.map(f => f.symbolId));
83
+ const unknownFrom = new Set(reach.unknowns.filter(u => u.material).map(u => u.from));
84
+
85
+ // ---- 1. pruning (whole symbols only, with proof) -------------------------------------------------------
86
+ const prunable = new Set();
87
+ if (prune) {
88
+ const visitedById = new Map(reach.visited.map(v => [v.symbolId, v]));
89
+ for (const v of reach.visited) {
90
+ if (v.reason !== 'callee') continue;
91
+ const sym = byId.get(v.symbolId);
92
+ if (!sym || !looksLikeTelemetry(sym.name)) continue;
93
+ const subtree = reach.visited.filter(w => w.path.includes(v.symbolId));
94
+ const clean = subtree.every(w => !factSyms.has(w.symbolId) && !unknownFrom.has(w.symbolId) && w.reason !== 'middleware' && w.reason !== 'inherited-middleware');
95
+ if (!clean) continue;
96
+ for (const w of subtree) {
97
+ prunable.add(w.symbolId);
98
+ omissions.push({ symbolId: w.symbolId, reason: 'non-security-telemetry', proof: `name '${byId.get(v.symbolId).name}' is telemetry-shaped and no control, sensitive operation or material unknown exists in its subtree` });
99
+ }
100
+ }
101
+ void visitedById;
102
+ }
103
+
104
+ // ---- 2. candidate segments -----------------------------------------------------------------------------
105
+ const items = [];
106
+ const root = reach.root;
107
+ let order = 0;
108
+ if (root) {
109
+ // the registration block is always shown: it is where inherited wiring is visible
110
+ if (entrypoint.rootSpan) {
111
+ items.push({ file: entrypoint.rootSpan.file, startLine: entrypoint.rootSpan.startLine, endLine: entrypoint.rootSpan.endLine, symbolIds: [], reasons: ['registration'], priority: 0, order: order++, hasFacts: true, imprecise: false });
112
+ }
113
+ if (root.kind === 'inline-block') {
114
+ items.push({ file: root.file, startLine: root.startLine, endLine: root.endLine, symbolIds: root.symbolId ? [root.symbolId] : [], reasons: ['handler'], priority: 0, order: order++, hasFacts: true, imprecise: false });
115
+ }
116
+ }
117
+ for (const v of reach.visited) {
118
+ if (prunable.has(v.symbolId)) continue;
119
+ const sym = byId.get(v.symbolId);
120
+ if (!sym) continue;
121
+ if (v.reason === 'handler' && root && root.kind === 'inline-block') continue; // shown as the registration block above
122
+ const mandatory = v.reason === 'handler' || v.reason === 'middleware' || v.reason === 'inherited-middleware';
123
+ const hasFacts = factSyms.has(v.symbolId) || unknownFrom.has(v.symbolId);
124
+ items.push({
125
+ file: sym.file, startLine: sym.startLine, endLine: sym.endLine, symbolIds: [sym.id],
126
+ reasons: [v.reason === 'name-match-candidate' ? 'candidate-for-unresolved-call' : v.reason],
127
+ priority: mandatory ? 0 : (v.reason === 'name-match-candidate' ? 4 : (hasFacts ? 2 : 3)),
128
+ order: order++, hasFacts, imprecise: sym.spanPrecision === 'cfg-lower-bound',
129
+ });
130
+ }
131
+ const segments = mergeOverlaps(items);
132
+
133
+ // ---- 3. budget and linked packages ---------------------------------------------------------------------
134
+ const headerFor = (idx, total, setId, localOmitted) => {
135
+ const lines = [];
136
+ lines.push(`# DETERMINISTIC CONTEXT PACKAGE ${idx}/${total} (set ${setId})`);
137
+ lines.push(`entrypoint: ${entrypoint.label} kind=${entrypoint.kind} disposition=${entrypoint.disposition}`);
138
+ lines.push(`handler: ${entrypoint.handlerSymbol || '(none resolved)'} registration: ${entrypoint.registrationSite.file}:${entrypoint.registrationSite.line}`);
139
+ if (entrypoint.middleware.length) lines.push(`middleware: ${entrypoint.middleware.map(m => `${m.name || '?'}[${m.resolution}${m.inherited ? ',inherited' : ''}]`).join(', ')}`);
140
+ if (total > 1) lines.push(`NOTE: this is one of ${total} linked packages for the same entry point; it is NOT a standalone view.`);
141
+ for (const u of reach.unknowns.filter(x => x.material).slice(0, 12)) lines.push(`UNRESOLVED: ${u.site} call '${u.calleeName || '?'}' (${u.reason}); its body is NOT shown unless listed as a candidate`);
142
+ for (const o of localOmitted.slice(0, 12)) lines.push(`NOT INCLUDED: ${o.symbolId || o.range} (${o.reason})`);
143
+ if (reach.capped) lines.push(`LIMIT: traversal stopped at ${reach.limits.maxSymbols} symbols / depth ${reach.limits.maxDepth}; ${reach.unvisited.length} symbol(s) not visited`);
144
+ lines.push('# SOURCE (numbered; the text before the first "| " on each line is the line number)');
145
+ return lines.join('\n');
146
+ };
147
+
148
+ const sorted = [...segments].sort((a, b) => a.priority - b.priority || a.order - b.order);
149
+ const mandatory = sorted.filter(s => s.priority === 0);
150
+ const rest = sorted.filter(s => s.priority !== 0);
151
+ const charBudget = maxTokens * 4;
152
+ const segText = new Map(sorted.map(s => [s, renderSegment(s, linesOf(s.file))]));
153
+ const sizeOf = (s) => segText.get(s).length + 1;
154
+ const HEADER_RESERVE = 1800;
155
+
156
+ const result = { entrypointId: entrypoint.id, profile, maxTokens, pruneLowValue: prune, packages: [], omissions, integrity: 'complete', integrityReasons: [], tokenizer: null };
157
+
158
+ const mandatoryChars = mandatory.reduce((n, s) => n + sizeOf(s), 0);
159
+ if (!root) {
160
+ result.integrity = 'unavailable';
161
+ result.integrityReasons.push('no-handler-or-registration');
162
+ return result;
163
+ }
164
+ if (mandatoryChars + HEADER_RESERVE > charBudget) {
165
+ result.integrity = 'unavailable';
166
+ result.integrityReasons.push('mandatory-core-exceeds-budget');
167
+ for (const s of sorted) omissions.push({ range: `${s.file}:${s.startLine}-${s.endLine}`, symbolId: s.symbolIds[0] || null, reason: 'omitted_due_to_budget', proof: 'minimum bundle (registration, handler, middleware) does not fit the token budget' });
168
+ return result;
169
+ }
170
+
171
+ const bins = [{ segs: [...mandatory], chars: mandatoryChars }];
172
+ const unplaced = [];
173
+ for (const s of rest) {
174
+ const need = sizeOf(s);
175
+ let placed = false;
176
+ for (const bin of bins) {
177
+ if (bin.chars + need + HEADER_RESERVE <= charBudget) { bin.segs.push(s); bin.chars += need; placed = true; break; }
178
+ }
179
+ if (!placed) {
180
+ if (bins.length < MAX_PACKAGES && need + HEADER_RESERVE <= charBudget && s.priority <= 2) { bins.push({ segs: [s], chars: need }); }
181
+ else unplaced.push(s);
182
+ }
183
+ }
184
+ for (const s of unplaced) omissions.push({ range: `${s.file}:${s.startLine}-${s.endLine}`, symbolId: s.symbolIds[0] || null, reason: 'omitted_due_to_budget', proof: 'did not fit any bounded package' });
185
+
186
+ const total = bins.length;
187
+ const setId = hashOf(sorted.map(s => [s.file, s.startLine, s.endLine])).slice(0, 12);
188
+ const pkgs = bins.map((bin, i) => {
189
+ const segs = bin.segs.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.startLine - b.startLine);
190
+ const otherSegs = bins.filter((_, j) => j !== i).flatMap(b => b.segs);
191
+ const localOmitted = [
192
+ ...otherSegs.map(s => ({ range: `${s.file}:${s.startLine}-${s.endLine}`, symbolId: s.symbolIds[0] || null, reason: 'in-linked-package' })),
193
+ ...omissions.filter(o => o.reason === 'omitted_due_to_budget' || o.reason === 'non-security-telemetry'),
194
+ ];
195
+ const body = segs.map(s => segText.get(s)).join('\n');
196
+ const text = `${headerFor(i + 1, total, setId, localOmitted)}\n${body}\n`;
197
+ const est = estimateTokens(text, config.tokenizer);
198
+ result.tokenizer = { name: est.tokenizer, estimated: est.estimated, uncertainty: est.estimated ? 'about 30% either way; chars/4 is not a model tokenizer' : 'exact for the named tokenizer' };
199
+ return {
200
+ index: i + 1, total, setId,
201
+ standalone: total === 1,
202
+ tokensEstimated: est.tokens,
203
+ sha256: sha256(text),
204
+ text,
205
+ segments: segs.map(s => ({ file: s.file, startLine: s.startLine, endLine: s.endLine, symbolIds: s.symbolIds, reasons: s.reasons, spanHash: sha256(sliceLines(fileContents[s.file], s.startLine, s.endLine)) })),
206
+ };
207
+ });
208
+ result.packages = pkgs;
209
+
210
+ // ---- 4. integrity --------------------------------------------------------------------------------------
211
+ const why = result.integrityReasons;
212
+ if (entrypoint.disposition !== 'resolved') why.push(`entrypoint-${entrypoint.disposition}`);
213
+ if (reach.unknowns.some(u => u.material)) why.push('material-unresolved-call');
214
+ if (reach.capped) why.push('traversal-limit-reached');
215
+ if (omissions.some(o => o.reason === 'omitted_due_to_budget')) why.push('omitted-for-budget');
216
+ if (segments.some(s => s.imprecise)) why.push('imprecise-span');
217
+ const setReasons = why.filter(r => r !== 'split-into-linked-packages');
218
+ // Set level: every package is present and nothing else is wrong. Package level: a package that has siblings is never
219
+ // standalone-complete, because it is missing the dependencies that live in the others.
220
+ result.integrity = setReasons.length ? 'partial' : 'complete';
221
+ for (const p of pkgs) p.integrity = total === 1 ? result.integrity : 'partial';
222
+ if (total > 1) why.push('split-into-linked-packages');
223
+ return result;
224
+ }
@@ -0,0 +1,40 @@
1
+ //
2
+ // Persist a build: every bundle (manifest + package texts), the index that maps entry points to their current bundle,
3
+ // and the reverse-dependency map. Nothing else about the repository is written.
4
+ import { writeBundle, writeStoreJson } from './store.js';
5
+ import { reverseDependencies } from './diff.js';
6
+ import { hashOf } from './canonical.js';
7
+
8
+ export const INDEX_SCHEMA = 'context-index/1';
9
+
10
+ export function persistBuild(root, built, config) {
11
+ const failures = [];
12
+ const manifests = [];
13
+ for (const b of built.bundles) {
14
+ if (!b.manifest) continue;
15
+ const r = writeBundle(root, b);
16
+ if (!r.ok) { failures.push({ entrypointId: b.entrypoint.id, code: r.code, reason: r.reason }); continue; }
17
+ manifests.push(b.manifest);
18
+ }
19
+ const index = {
20
+ schemaVersion: INDEX_SCHEMA,
21
+ graph: { schemaVersion: built.graph.schemaVersion, hash: built.graph.hash, source: built.semantic.source },
22
+ repository: built.repository,
23
+ configHash: hashOf(config),
24
+ summary: built.summary,
25
+ entrypoints: built.entrypoints.map(e => {
26
+ const b = built.bundles.find(x => x.entrypoint.id === e.id);
27
+ return {
28
+ entrypointId: e.id, label: e.label, kind: e.kind, disposition: e.disposition, file: e.registrationSite.file,
29
+ contextHash: b && b.manifest ? b.manifest.contextHash : null,
30
+ integrity: b && b.manifest ? b.manifest.context.integrity : null,
31
+ resolutionReasons: e.resolutionReasons,
32
+ };
33
+ }),
34
+ };
35
+ const wi = writeStoreJson(root, 'index.json', index);
36
+ if (!wi.ok) failures.push({ code: wi.code, reason: wi.reason, what: 'index.json' });
37
+ const wr = writeStoreJson(root, 'reverse-dependencies.json', reverseDependencies(manifests));
38
+ if (!wr.ok) failures.push({ code: wr.code, reason: wr.reason, what: 'reverse-dependencies.json' });
39
+ return { ok: failures.length === 0, failures, written: manifests.length };
40
+ }
@@ -0,0 +1,82 @@
1
+ //
2
+ // Question packs: ONE tightly scoped question per review job, versioned so a prompt change is a different job key and
3
+ // a result can be traced to the exact wording that produced it. A pack holds no code and no tools; it is text plus
4
+ // the metadata the verifier needs (which protection dimension the question depends on, what family/CWE a candidate
5
+ // carries, whether the answer is advisory only).
6
+ export const REVIEW_SCHEMA_VERSION = 'context-review/1';
7
+
8
+ const PACKS = [
9
+ {
10
+ id: 'authz-idor', version: 1, title: 'Object ownership and tenant scoping (IDOR / BOLA)', cwe: 'CWE-639', family: 'access-control', lens: 'ctx-authz-idor',
11
+ dimension: 'ownership', multiBundle: false, advisoryOnly: false,
12
+ invariant: 'A data object selected by an identifier the caller controls must be scoped to the authenticated principal, or that principal\'s tenant, before it is read or modified.',
13
+ question: 'Does this entry point let a caller read or modify a data object chosen by an identifier they control without that object being scoped to their own identity or tenant?',
14
+ guidance: [
15
+ 'Trace the untrusted identifier from where it enters to where the object is fetched or changed.',
16
+ 'Look for the scoping: a query filter naming the principal or tenant, an ownership comparison that rejects, a policy check, a row-level rule shown in the code.',
17
+ 'Authentication alone is NOT ownership. A check that the caller is logged in says nothing about whose object it is.',
18
+ 'An unguessable capability token or a public resource intentionally addressed by identifier is not a defect; say so and cite why.',
19
+ 'A control that runs after the object has already been read or written does not protect it.',
20
+ ],
21
+ },
22
+ {
23
+ id: 'authz-function', version: 1, title: 'Privileged function access (BFLA)', cwe: 'CWE-285', family: 'access-control', lens: 'ctx-authz-function',
24
+ dimension: 'authorization-role', multiBundle: false, advisoryOnly: false,
25
+ invariant: 'An action that only some roles may perform must check the caller\'s role or permission before it takes effect.',
26
+ question: 'Can a caller without the required role perform a privileged action through this entry point?',
27
+ guidance: [
28
+ 'Identify what the action does and who should be allowed to do it, from the code shown.',
29
+ 'Look for a role or permission check that rejects, placed before the action. Route-level middleware counts when it is shown.',
30
+ 'Impersonation, role parameters taken from the request and administrative paths with only authentication are the usual gaps.',
31
+ ],
32
+ },
33
+ {
34
+ id: 'cross-route-consistency', version: 1, title: 'Authorization consistency across related routes', cwe: 'CWE-863', family: 'access-control', lens: 'ctx-cross-route',
35
+ dimension: 'ownership', multiBundle: true, advisoryOnly: false,
36
+ invariant: 'Routes that operate on the same kind of object must enforce the same authorization rule.',
37
+ question: 'Across the linked entry points, does one of them enforce a weaker authorization rule than its siblings for the same kind of object?',
38
+ guidance: [
39
+ 'Compare what each linked entry point checks before it touches the shared object.',
40
+ 'Report a difference only when two entry points reach the same object and one lacks a check the other has; cite both.',
41
+ 'Different checks for genuinely different objects are not an inconsistency.',
42
+ ],
43
+ },
44
+ {
45
+ id: 'transaction-integrity', version: 1, title: 'Multi-step state change without a transaction', cwe: 'CWE-662', family: 'business-logic', lens: 'ctx-transaction',
46
+ dimension: null, multiBundle: false, advisoryOnly: false,
47
+ invariant: 'A change that must happen together (several writes, or a write and an external effect) must be atomic or compensated.',
48
+ question: 'Does this entry point perform several dependent state changes where a failure part-way leaves the system inconsistent, with no transaction or compensation shown?',
49
+ guidance: ['Name the two or more changes and the failure point between them.', 'A transaction boundary, a single atomic statement or an explicit compensation shown in the code answers the question.'],
50
+ },
51
+ {
52
+ id: 'external-failure', version: 1, title: 'Outbound call failure semantics', cwe: 'CWE-703', family: 'business-logic', lens: 'ctx-external-failure',
53
+ dimension: null, multiBundle: false, advisoryOnly: false,
54
+ invariant: 'A call to an external service must have a bounded wait and a defined behaviour when it fails.',
55
+ question: 'Does this entry point call an external service with no timeout, cancellation, bounded retry or defined failure behaviour, in a way that matters to correctness or security?',
56
+ guidance: ['Cite the call and the missing or present bound.', 'Fail-open behaviour on a security decision is the important case.'],
57
+ },
58
+ {
59
+ id: 'sensitive-audit', version: 1, title: 'Security-sensitive change without audit evidence', cwe: 'CWE-778', family: 'business-logic', lens: 'ctx-audit',
60
+ dimension: null, multiBundle: false, advisoryOnly: false,
61
+ invariant: 'A security-sensitive state change must leave a record of who did what.',
62
+ question: 'Does this entry point make a security-sensitive change (permissions, credentials, money, deletion) with no audit record shown?',
63
+ guidance: ['Name the sensitive change.', 'A call that records the actor and action, shown in the code, answers the question.'],
64
+ },
65
+ {
66
+ id: 'business-invariant', version: 1, title: 'Cross-entity business invariant (advisory)', cwe: 'CWE-840', family: 'business-logic', lens: 'ctx-business-invariant',
67
+ dimension: null, multiBundle: false, advisoryOnly: true,
68
+ invariant: 'State the business rule the code appears to intend, and check the code against it.',
69
+ question: 'Does the code violate a business rule that the code itself, its names or its comments make clear, in a way that crosses entities?',
70
+ guidance: ['State the rule and where the code makes it clear.', 'This is advisory: report only a clear violation and say what you assumed.'],
71
+ },
72
+ ];
73
+
74
+ export const QUESTION_PACKS = Object.freeze(PACKS.map(p => Object.freeze({ ...p, guidance: Object.freeze([...p.guidance]) })));
75
+
76
+ export function packById(id) {
77
+ const base = String(id || '').split('/')[0];
78
+ return QUESTION_PACKS.find(p => p.id === base) || null;
79
+ }
80
+
81
+ /** `authz-idor/1` */
82
+ export function packRef(pack) { return `${pack.id}/${pack.version}`; }