@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 +29 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1336.index.js +101 -101
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7405.index.js +23 -6
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +338 -48
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +28 -28
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +16 -10
- package/src/context-intelligence/config.js +4 -1
- package/src/context-intelligence/controls.js +108 -10
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +10 -4
- package/src/context-intelligence/review.js +129 -16
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Deterministic counterexample validation for a candidate invariant violation (E4-04).
|
|
2
|
+
//
|
|
3
|
+
// A candidate (for example a cross-route inconsistency from E4-03) says "this route looks weaker than its sibling". This module tries to settle it
|
|
4
|
+
// WITHOUT a model, in layers, and reports where it got to as one of four states that are never blended:
|
|
5
|
+
//
|
|
6
|
+
// refuted the claim is contradicted: the control IS on the path, the route is declared public by policy, there is no sensitive operation
|
|
7
|
+
// on the resource, or an executed attempt by the attacker was denied
|
|
8
|
+
// unresolved the analysis cannot say: an unresolved call or a traversal limit could hide the control, or the fixture could not run
|
|
9
|
+
// plausible every edge is resolved and the path reaches the resource's sensitive operation with no control of that kind before it, but nothing
|
|
10
|
+
// was executed. This is static evidence only
|
|
11
|
+
// proven an authorized LOCAL request fixture ran the real handler code and the attacker, who does not own the object, read or changed the
|
|
12
|
+
// victim's object. Only an execution can produce this state
|
|
13
|
+
//
|
|
14
|
+
// MODEL OUTPUT NEVER ENTERS. This file imports nothing from the review or model paths and reads no prose: facts come from the code analysis, which
|
|
15
|
+
// skips comments, docstrings and string contents, so a comment that claims a check exists, or tells a reviewer to ignore the finding, cannot move a
|
|
16
|
+
// state. The evidence trace is a list of steps each naming its kind (`static` or `executed`), so a reader can see which layer decided.
|
|
17
|
+
//
|
|
18
|
+
// THE FIXTURE RUNNER executes handler code, so it is confined: a child process under Node's permission model with NO file access (the fixture's sources are loaded from memory), no
|
|
19
|
+
// child processes and no workers, a driver that removes `fetch` and refuses every builtin and package, a scrubbed environment, a wall-clock
|
|
20
|
+
// limit and capped output. Network is not part of Node's permission model, so it is blocked by the driver, not by the runtime: lower assurance. Express-style
|
|
21
|
+
// apps are supported through a small in-process shim for `express`, and `db` is a recording in-memory store the fixture supplies. Code that needs
|
|
22
|
+
// anything else (a real database, another module system, a missing dependency) ends as `unresolved`, never as `proven`.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const STATES = Object.freeze(['proven', 'plausible', 'refuted', 'unresolved']);
|
|
27
|
+
const FIXTURE_TIMEOUT_MS = 8000;
|
|
28
|
+
const MAX_OUTPUT = 200_000;
|
|
29
|
+
const MAX_FIXTURE_BYTES = 100_000;
|
|
30
|
+
|
|
31
|
+
// ---- the fixture runner -----------------------------------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const DRIVER = String.raw`
|
|
34
|
+
'use strict';
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const spec = JSON.parse(process.env.FIXTURE_SPEC);
|
|
37
|
+
const ops = [];
|
|
38
|
+
const store = JSON.parse(JSON.stringify(spec.store || {}));
|
|
39
|
+
const match = (doc, q) => Object.entries(q || {}).every(([k, v]) => (v && typeof v === 'object' && !Array.isArray(v)) ? true : doc[k] === v);
|
|
40
|
+
const coll = (name) => ({
|
|
41
|
+
async findOne(q) { ops.push({ op: 'findOne', c: name, q }); return (store[name] || []).find((d) => match(d, q)) || null; },
|
|
42
|
+
async find(q) { ops.push({ op: 'find', c: name, q }); return (store[name] || []).filter((d) => match(d, q)); },
|
|
43
|
+
async updateOne(q, u) { ops.push({ op: 'updateOne', c: name, q }); const d = (store[name] || []).find((x) => match(x, q)); if (d) Object.assign(d, (u && u.$set) || u); return { matchedCount: d ? 1 : 0 }; },
|
|
44
|
+
async deleteOne(q) { ops.push({ op: 'deleteOne', c: name, q }); const i = (store[name] || []).findIndex((x) => match(x, q)); if (i >= 0) store[name].splice(i, 1); return { deletedCount: i >= 0 ? 1 : 0 }; },
|
|
45
|
+
async insertOne(d) { ops.push({ op: 'insertOne', c: name }); (store[name] ||= []).push(d); return { insertedId: d.id }; },
|
|
46
|
+
});
|
|
47
|
+
global.db = new Proxy({}, { get: (_, name) => coll(String(name)) });
|
|
48
|
+
const routes = []; const mw = [];
|
|
49
|
+
const app = {};
|
|
50
|
+
for (const m of ['get', 'post', 'put', 'patch', 'delete']) app[m] = (p, ...hs) => { routes.push({ method: m.toUpperCase(), path: p, handlers: hs.flat() }); return app; };
|
|
51
|
+
app.use = (...a) => { const fn = a.find((x) => typeof x === 'function'); if (fn) mw.push(fn); return app; };
|
|
52
|
+
app.listen = () => app; app.set = () => app; app.disable = () => app;
|
|
53
|
+
const express = () => app; express.json = () => (q, s, n) => n(); express.urlencoded = () => (q, s, n) => n(); express.Router = () => app;
|
|
54
|
+
// The fixture's files are loaded from memory: nothing is read from disk, so the process needs no file permission at all, and require can only
|
|
55
|
+
// reach the shim for express and the fixture's own relative modules. Everything else, builtins included, is refused.
|
|
56
|
+
delete globalThis.fetch; delete globalThis.WebSocket;
|
|
57
|
+
const vm = require('node:vm');
|
|
58
|
+
const cache = new Map();
|
|
59
|
+
const norm = (p) => path.posix.normalize(p).replace(/^\.\//, '');
|
|
60
|
+
const fileFor = (from, request) => {
|
|
61
|
+
const base = path.posix.join(path.posix.dirname(from), request);
|
|
62
|
+
for (const c of [base, base + '.js', base + '.cjs', base + '/index.js']) if (Object.hasOwn(spec.files, norm(c))) return norm(c);
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
const load = (file) => {
|
|
66
|
+
if (cache.has(file)) return cache.get(file).exports;
|
|
67
|
+
const mod = { exports: {} }; cache.set(file, mod);
|
|
68
|
+
const req = (request) => {
|
|
69
|
+
if (request === 'express') return express;
|
|
70
|
+
if (request.startsWith('.')) { const f = fileFor(file, request); if (f) return load(f); throw Object.assign(new Error('no such fixture module ' + request), { code: 'FIXTURE_UNSUPPORTED' }); }
|
|
71
|
+
throw Object.assign(new Error('fixture runner does not provide module ' + request), { code: 'FIXTURE_UNSUPPORTED' });
|
|
72
|
+
};
|
|
73
|
+
const wrapper = '(function (exports, require, module, __filename, __dirname) {' + spec.files[file] + '\n})';
|
|
74
|
+
vm.runInThisContext(wrapper, { filename: file })(mod.exports, req, mod, file, path.posix.dirname(file));
|
|
75
|
+
return mod.exports;
|
|
76
|
+
};
|
|
77
|
+
const matchRoute = (r, method, url) => {
|
|
78
|
+
if (r.method !== method) return null;
|
|
79
|
+
const a = r.path.split('/'); const b = url.split('/');
|
|
80
|
+
if (a.length !== b.length) return null;
|
|
81
|
+
const params = {};
|
|
82
|
+
for (let i = 0; i < a.length; i++) { if (a[i].startsWith(':')) params[a[i].slice(1)] = b[i]; else if (a[i] !== b[i]) return null; }
|
|
83
|
+
return params;
|
|
84
|
+
};
|
|
85
|
+
(async () => {
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
load(norm(spec.entry));
|
|
89
|
+
const req0 = spec.request;
|
|
90
|
+
const hit = routes.map((r) => ({ r, params: matchRoute(r, req0.method, req0.path) })).find((x) => x.params);
|
|
91
|
+
if (!hit) { result = { error: 'no-route', detail: req0.method + ' ' + req0.path }; }
|
|
92
|
+
else {
|
|
93
|
+
const user = spec.user ? JSON.parse(JSON.stringify(spec.user)) : undefined;
|
|
94
|
+
const req = { method: req0.method, params: hit.params, query: req0.query || {}, body: req0.body || {}, headers: {}, user, get: () => undefined };
|
|
95
|
+
let done; const finished = new Promise((r) => { done = r; });
|
|
96
|
+
const res = { statusCode: 200, body: undefined, sent: false,
|
|
97
|
+
status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; this.sent = true; done(); return this; }, send(b) { this.body = b; this.sent = true; done(); return this; },
|
|
98
|
+
sendStatus(c) { this.statusCode = c; this.sent = true; done(); return this; }, set() { return this; }, end() { this.sent = true; done(); return this; }, type() { return this; }, locals: {} };
|
|
99
|
+
const chain = [...mw, ...hit.r.handlers];
|
|
100
|
+
let i = 0;
|
|
101
|
+
const next = (err) => { if (err) { res.statusCode = 500; res.sent = true; done(); return; } const h = chain[i++]; if (!h) { done(); return; } try { const p = h(req, res, next); if (p && typeof p.then === 'function') p.catch((e) => { res.statusCode = 500; res.body = String(e && e.message); res.sent = true; done(); }); } catch (e) { res.statusCode = 500; res.body = String(e && e.message); res.sent = true; done(); } };
|
|
102
|
+
next();
|
|
103
|
+
await Promise.race([finished, new Promise((r) => setTimeout(r, 2000))]);
|
|
104
|
+
result = { status: res.statusCode, body: res.body, sent: res.sent, ops, store };
|
|
105
|
+
}
|
|
106
|
+
} catch (e) { result = { error: e && e.code === 'FIXTURE_UNSUPPORTED' ? 'unsupported' : 'load-failed', detail: String(e && e.message).slice(0, 300) }; }
|
|
107
|
+
process.stdout.write('@@RESULT@@' + JSON.stringify(result));
|
|
108
|
+
})();
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run one request against the real handler code under confinement.
|
|
113
|
+
* @param {{files:Record<string,string>, entry:string, request:{method:string,path:string,body?:object,query?:object}, user?:object, store?:object}} f
|
|
114
|
+
* @returns {{ok:boolean, status?:number, body?:any, ops?:object[], store?:object, error?:string, detail?:string}}
|
|
115
|
+
*/
|
|
116
|
+
export function runFixture(f) {
|
|
117
|
+
const total = Object.values(f.files || {}).reduce((n, t) => n + String(t).length, 0);
|
|
118
|
+
if (total > MAX_FIXTURE_BYTES) return { ok: false, error: 'too-large', detail: `${total} bytes of fixture source` };
|
|
119
|
+
for (const rel of Object.keys(f.files || {})) if (path.isAbsolute(rel) || rel.split('/').includes('..')) return { ok: false, error: 'bad-path', detail: rel };
|
|
120
|
+
// `--permission` with no allow flags: no file reads or writes, no child processes, no workers. The driver comes in through -e and the fixture's
|
|
121
|
+
// files through the environment, so nothing needs the disk. Network is not covered by the permission model; the driver removes `fetch` and
|
|
122
|
+
// refuses every builtin and package, which is the whole of its network reach.
|
|
123
|
+
const r = spawnSync(process.execPath, ['--permission', '-e', DRIVER], {
|
|
124
|
+
encoding: 'utf8', timeout: FIXTURE_TIMEOUT_MS, maxBuffer: MAX_OUTPUT,
|
|
125
|
+
env: { PATH: '/usr/bin:/bin', FIXTURE_SPEC: JSON.stringify({ files: f.files, entry: f.entry, request: f.request, user: f.user, store: f.store }) },
|
|
126
|
+
});
|
|
127
|
+
if (r.error) return { ok: false, error: r.error.code === 'ETIMEDOUT' ? 'timeout' : 'spawn-failed', detail: String(r.error.message).slice(0, 200) };
|
|
128
|
+
const out = String(r.stdout || ''); const i = out.indexOf('@@RESULT@@');
|
|
129
|
+
if (i < 0) return { ok: false, error: 'no-result', detail: String(r.stderr || '').slice(0, 300) };
|
|
130
|
+
let parsed; try { parsed = JSON.parse(out.slice(i + 10)); } catch { return { ok: false, error: 'bad-result' }; }
|
|
131
|
+
return parsed.error ? { ok: false, error: parsed.error, detail: parsed.detail } : { ok: true, ...parsed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Make the attacker's request against the real handler code and say what happened to the victim's object. Used by assessCandidate and, after a patch,
|
|
136
|
+
* by the final verification of a fix (which must execute even when a static look already says the control is present).
|
|
137
|
+
* @returns {{state:'proven'|'refuted'|'unresolved', trace:object[], reasons:string[], reason?:string, status?:number}}
|
|
138
|
+
*/
|
|
139
|
+
export function executeAttempt(candidate, fx) {
|
|
140
|
+
const trace = [];
|
|
141
|
+
const method = fx.method || (candidate.operations?.includes('delete') ? 'DELETE' : candidate.operations?.includes('update') ? 'PUT' : 'GET');
|
|
142
|
+
const attack = runFixture({ files: fx.files, entry: fx.entry, request: { method, path: fx.path, body: fx.body }, user: fx.attacker.user, store: fx.store });
|
|
143
|
+
if (!attack.ok) { trace.push(step('executed', 'attacker request', `${attack.error}: ${attack.detail || ''}`)); return { state: 'unresolved', trace, reasons: [`the fixture could not run (${attack.error}); the claim stays unproven`], reason: `the fixture could not run (${attack.error}); the claim stays unproven` }; }
|
|
144
|
+
trace.push(step('executed', 'attacker request', `${method} ${fx.path} as ${JSON.stringify(fx.attacker.user)} -> ${attack.status}`));
|
|
145
|
+
const marker = fx.victim.marker;
|
|
146
|
+
const leaked = marker ? JSON.stringify(attack.body ?? '').includes(marker) : attack.status >= 200 && attack.status < 300 && attack.body != null;
|
|
147
|
+
const changed = JSON.stringify(attack.store?.[fx.victim.collection] || []) !== JSON.stringify(fx.store[fx.victim.collection] || []);
|
|
148
|
+
if (leaked || changed) {
|
|
149
|
+
trace.push(step('executed', 'effect on the victim\'s object', leaked ? 'the attacker received the victim\'s data' : 'the victim\'s object was modified by the attacker'));
|
|
150
|
+
if (fx.siblingPath) {
|
|
151
|
+
const sib = runFixture({ files: fx.files, entry: fx.entry, request: { method: fx.siblingMethod || 'GET', path: fx.siblingPath }, user: fx.attacker.user, store: fx.store });
|
|
152
|
+
if (sib.ok) trace.push(step('executed', 'the enforcing sibling, same attacker', `${fx.siblingMethod || 'GET'} ${fx.siblingPath} -> ${sib.status}${marker && !JSON.stringify(sib.body ?? '').includes(marker) ? ' (victim data withheld)' : ''}`));
|
|
153
|
+
}
|
|
154
|
+
return { state: 'proven', trace, reasons: ['an executed local request let a principal who does not own the object ' + (leaked ? 'read' : 'change') + ' it'], status: attack.status };
|
|
155
|
+
}
|
|
156
|
+
trace.push(step('executed', 'effect on the victim\'s object', 'none: the attacker was denied or got nothing'));
|
|
157
|
+
return { state: 'refuted', trace, reasons: [`an executed attempt by the attacker was denied (status ${attack.status})`], status: attack.status };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- the assessment ---------------------------------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const step = (kind, what, detail = null) => ({ kind, what, ...(detail ? { detail } : {}) });
|
|
163
|
+
const sameBundle = (b, route) => b.entrypoint && b.entrypoint.label === route;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {{route:string, resource:string, dimension:string, operations?:string[], enforcedAt?:object[], waivedBy?:string[]}} candidate e.g. a finding from findCrossRouteInconsistencies
|
|
167
|
+
* @param {{bundles:object[], fixture?:{files:Record<string,string>, entry:string, victim:{user:object, ownsId:string, collection:string, marker?:string}, attacker:{user:object}, store:object, path:string, method?:string, body?:object, siblingPath?:string, siblingMethod?:string}}} ctx
|
|
168
|
+
* @returns {{state:'proven'|'plausible'|'refuted'|'unresolved', trace:object[], reasons:string[]}}
|
|
169
|
+
*/
|
|
170
|
+
export function assessCandidate(candidate, ctx) {
|
|
171
|
+
const trace = [];
|
|
172
|
+
const fail = (state, reason) => ({ state, trace, reasons: [reason] });
|
|
173
|
+
const bundle = (ctx.bundles || []).find((b) => sameBundle(b, candidate.route));
|
|
174
|
+
if (!bundle || !bundle.manifest) { trace.push(step('static', 'locate the route bundle', 'no bundle for this route')); return fail('unresolved', `no analysed bundle for ${candidate.route}`); }
|
|
175
|
+
trace.push(step('static', 'locate the route bundle', bundle.entrypoint.id));
|
|
176
|
+
|
|
177
|
+
if (candidate.waivedBy && candidate.waivedBy.length) { trace.push(step('static', 'check policy', `public-by-design: ${candidate.waivedBy.join(', ')}`)); return fail('refuted', `declared public by ${candidate.waivedBy.join(', ')}`); }
|
|
178
|
+
|
|
179
|
+
const facts = bundle.reach?.facts || [];
|
|
180
|
+
const sens = facts.filter((f) => f.category === 'sensitive');
|
|
181
|
+
if (!sens.length) { trace.push(step('static', 'find a sensitive operation', 'none on the path')); return fail('refuted', 'no sensitive operation is reachable from this route, so there is nothing to protect'); }
|
|
182
|
+
const first = Math.min(...sens.map((f) => f.effLine));
|
|
183
|
+
const kinds = candidate.dimension === 'ownership' ? ['ownership'] : [candidate.dimension];
|
|
184
|
+
const before = facts.filter((f) => f.category === 'control' && kinds.includes(f.kind) && (f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
|
|
185
|
+
if (before.length) { trace.push(step('static', `look for ${candidate.dimension} before the first sensitive operation`, `found at ${before[0].file}:${before[0].line}`)); return fail('refuted', `${candidate.dimension} is enforced at ${before[0].file}:${before[0].line} before the sensitive operation`); }
|
|
186
|
+
trace.push(step('static', `look for ${candidate.dimension} before the first sensitive operation`, 'none'));
|
|
187
|
+
|
|
188
|
+
const hidden = (bundle.manifest.unknowns || []).some((u) => u.material) || bundle.reach?.capped;
|
|
189
|
+
if (hidden) { trace.push(step('static', 'check for unresolved calls', 'a material unresolved call or a traversal limit')); return fail('unresolved', 'an unresolved call or a traversal limit could hide the control'); }
|
|
190
|
+
trace.push(step('static', 'check for unresolved calls', 'none'));
|
|
191
|
+
|
|
192
|
+
const fx = ctx.fixture;
|
|
193
|
+
if (!fx) return { state: 'plausible', trace, reasons: ['every edge on the path is resolved and no control precedes the sensitive operation; nothing was executed'] };
|
|
194
|
+
|
|
195
|
+
const ex = executeAttempt(candidate, fx);
|
|
196
|
+
trace.push(...ex.trace);
|
|
197
|
+
if (ex.state === 'unresolved') return fail('unresolved', ex.reason);
|
|
198
|
+
if (ex.state === 'proven') return { state: 'proven', trace, reasons: ex.reasons };
|
|
199
|
+
return { state: 'refuted', trace, reasons: ex.reasons };
|
|
200
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Cross-route consistency (E4-03): does every route that touches a resource enforce what its sibling routes enforce?
|
|
2
|
+
//
|
|
3
|
+
// The classic authorization bug is not a missing check on the only route; it is a check on `GET /orders/:id` and none on `PUT /orders/:id` or
|
|
4
|
+
// `DELETE /orders/:id`. A per-route review sees each handler as fine. This compares, per resource, the controls the routes carry, using the facts
|
|
5
|
+
// DCI already extracted along route -> middleware -> handler -> service -> persistence, so a check made in a service layer counts for the route that
|
|
6
|
+
// reaches it.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT CLAIMS. For a resource touched by two or more routes, a control (authentication, authorization-role, ownership, tenant-scope) that at
|
|
9
|
+
// least one route enforces before its sensitive operation and another does not is an INCONSISTENCY. It is evidence that the missing route is
|
|
10
|
+
// weaker than its sibling, not proof of a vulnerability: that needs a counterexample (E4-04).
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT REFUSES TO CLAIM.
|
|
13
|
+
// - A route whose control could hide behind an unresolved call or a traversal limit is `unknown`, never "missing": absence of evidence in an
|
|
14
|
+
// incomplete context is not absence of the control, and dynamic dispatch is not proof of safety either.
|
|
15
|
+
// - A route that an invariant declares public-by-design for that control is SUPPRESSED, with the policy id and reason kept.
|
|
16
|
+
// - A resource touched by a single route has nothing to compare and produces nothing.
|
|
17
|
+
// - The resource name comes from the text of the data operation (collection, table or model); an operation whose target cannot be named is
|
|
18
|
+
// left out and counted, not guessed.
|
|
19
|
+
import { evaluateInvariants } from './invariants.js';
|
|
20
|
+
|
|
21
|
+
export const CONSISTENCY_DIMENSIONS = Object.freeze(['authentication', 'authorization-role', 'ownership', 'tenant-scope']);
|
|
22
|
+
|
|
23
|
+
const SQL_TARGET = /\b(?:FROM|INTO|UPDATE)\s+[`"']?([A-Za-z_][\w.]*)/i;
|
|
24
|
+
const COLLECTION = /\bcollection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)/;
|
|
25
|
+
const DB_MEMBER = /\b(?:db|database|mongo|prisma|knex|models?)\s*\.\s*([A-Za-z_]\w*)\s*\.\s*(?:find|findOne|findMany|findById|findAll|findUnique|findFirst|insert|insertOne|insertMany|create|update|updateOne|updateMany|delete|deleteOne|deleteMany|remove|save|upsert|count|aggregate|get)\b/;
|
|
26
|
+
const MODEL = /\b([A-Z][A-Za-z0-9]+)\s*\.\s*(?:find|findOne|findAll|findByPk|findById|findUnique|create|update|destroy|delete|remove|save|where|objects)\b/;
|
|
27
|
+
const REPO_CALL = /\b([a-z][A-Za-z0-9]*?)(?:Repo|Repository|Dao|Store)\s*\.\s*\w+\s*\(/;
|
|
28
|
+
|
|
29
|
+
/** Resource key: lowercase, singular-ish (`Orders`, `order`, `orders` all become `order`). */
|
|
30
|
+
export function resourceKey(text) {
|
|
31
|
+
const t = String(text || '');
|
|
32
|
+
const m = COLLECTION.exec(t) || DB_MEMBER.exec(t) || SQL_TARGET.exec(t) || MODEL.exec(t) || REPO_CALL.exec(t);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const raw = m[1].split('.').pop().toLowerCase();
|
|
35
|
+
if (!raw || ['db', 'models', 'model', 'table', 'tables', 'users_table_placeholder'].includes(raw)) return null;
|
|
36
|
+
return raw.replace(/ies$/, 'y').replace(/(?<!s)s$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function operationOf(fact) {
|
|
40
|
+
const t = String(fact.text || '');
|
|
41
|
+
if (/\b(?:delete|remove|destroy|drop)\w*/i.test(t)) return 'delete';
|
|
42
|
+
if (/\b(?:update|set|patch|save|upsert|replace)\w*/i.test(t)) return 'update';
|
|
43
|
+
if (/\b(?:insert|create|add)\w*/i.test(t)) return 'create';
|
|
44
|
+
return fact.subkind === 'write' ? 'update' : 'read';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stateOf(bundle, dim) {
|
|
48
|
+
const facts = bundle.reach?.facts || [];
|
|
49
|
+
const sens = facts.filter((f) => f.category === 'sensitive');
|
|
50
|
+
const first = sens.length ? Math.min(...sens.map((f) => f.effLine)) : null;
|
|
51
|
+
const kinds = dim === 'ownership' ? ['ownership'] : [dim];
|
|
52
|
+
const ctl = facts.filter((f) => f.category === 'control' && kinds.includes(f.kind) && (first === null || f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
|
|
53
|
+
if (ctl.length) return { state: 'enforced', evidence: ctl.slice(0, 3).map((f) => ({ file: f.file, line: f.line, basis: f.basis, evidence: f.evidence })) };
|
|
54
|
+
const prot = bundle.manifest?.protection?.[dim === 'tenant-scope' ? 'ownership' : dim];
|
|
55
|
+
if (prot && prot.state === 'present' && dim !== 'tenant-scope' && dim !== 'ownership') return { state: 'enforced', evidence: prot.evidence || [] };
|
|
56
|
+
const unresolved = (bundle.manifest?.unknowns || []).some((u) => u.material) || bundle.reach?.capped || prot?.state === 'not_resolved' || prot?.state === 'omitted_due_to_budget';
|
|
57
|
+
return unresolved ? { state: 'unknown' } : { state: 'absent' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{entrypoint:object, manifest:object, reach:object}[]} bundles
|
|
62
|
+
* @param {{invariants?:object, now?:Date}} [opts] an invariants document: its public-by-design waivers suppress findings
|
|
63
|
+
*/
|
|
64
|
+
export function findCrossRouteInconsistencies(bundles, opts = {}) {
|
|
65
|
+
const now = opts.now || new Date();
|
|
66
|
+
const built = bundles.filter((b) => b.manifest && b.reach);
|
|
67
|
+
const byResource = new Map();
|
|
68
|
+
let unnamed = 0;
|
|
69
|
+
for (const b of built) {
|
|
70
|
+
const sens = (b.reach.facts || []).filter((f) => f.category === 'sensitive' && String(f.kind).startsWith('data-'));
|
|
71
|
+
const touched = new Map();
|
|
72
|
+
for (const f of sens) {
|
|
73
|
+
const key = resourceKey(f.text);
|
|
74
|
+
if (!key) { unnamed++; continue; }
|
|
75
|
+
if (!touched.has(key)) touched.set(key, new Set());
|
|
76
|
+
touched.get(key).add(operationOf(f));
|
|
77
|
+
}
|
|
78
|
+
for (const [key, ops] of touched) {
|
|
79
|
+
if (!byResource.has(key)) byResource.set(key, []);
|
|
80
|
+
byResource.get(key).push({ bundle: b, ops: [...ops].sort() });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const findings = []; const suppressed = []; const unknown = [];
|
|
84
|
+
const waiver = (b, dim) => {
|
|
85
|
+
if (!opts.invariants) return null;
|
|
86
|
+
const r = evaluateInvariants({ ...opts.invariants, invariants: [...opts.invariants.invariants, { id: 'PROBE-DIM', title: 'probe', rule: 'requires', appliesTo: { routes: ['**'] }, requires: [dim] }] }, [b], { now });
|
|
87
|
+
const probe = r.results.find((x) => x.invariantId === 'PROBE-DIM');
|
|
88
|
+
return probe && probe.verdict === 'public_by_policy' ? probe.waivedBy : null;
|
|
89
|
+
};
|
|
90
|
+
for (const [resource, entries] of [...byResource].sort(([a], [c]) => (a < c ? -1 : 1))) {
|
|
91
|
+
const routes = [...new Map(entries.map((e) => [e.bundle.entrypoint.id, e])).values()];
|
|
92
|
+
if (routes.length < 2) continue;
|
|
93
|
+
for (const dim of CONSISTENCY_DIMENSIONS) {
|
|
94
|
+
const states = routes.map((r) => ({ r, ...stateOf(r.bundle, dim) }));
|
|
95
|
+
const enforced = states.filter((s) => s.state === 'enforced');
|
|
96
|
+
if (!enforced.length) continue;
|
|
97
|
+
for (const s of states) {
|
|
98
|
+
if (s.state === 'enforced') continue;
|
|
99
|
+
const ep = s.r.bundle.entrypoint;
|
|
100
|
+
const base = { resource, dimension: dim, route: ep.label, file: ep.rootSpan?.file || ep.registrationSite?.file || '', operations: s.r.ops, enforcedAt: enforced.map((e) => ({ route: e.r.bundle.entrypoint.label, operations: e.r.ops, evidence: e.evidence })) };
|
|
101
|
+
if (s.state === 'unknown') { unknown.push({ ...base, reason: 'an unresolved call or a traversal limit could hide the control; dynamic dispatch is not proof of absence' }); continue; }
|
|
102
|
+
const w = waiver(s.r.bundle, dim);
|
|
103
|
+
if (w) { suppressed.push({ ...base, waivedBy: w, reason: 'declared public-by-design for this control' }); continue; }
|
|
104
|
+
const klass = dim === 'tenant-scope' ? 'cross-tenant' : dim === 'ownership' ? 'idor' : 'missing-control';
|
|
105
|
+
findings.push({ id: `xroute:${resource}:${dim}:${ep.id}`, class: klass, ...base, confidence: 'inconsistency', explanation: `${enforced.map((e) => e.r.bundle.entrypoint.label).join(', ')} enforce ${dim} on ${resource}; ${ep.label} (${s.r.ops.join('/')}) does not` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
findings.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
110
|
+
return { findings, suppressed, unknown, resourcesCompared: [...byResource].filter(([, e]) => new Set(e.map((x) => x.bundle.entrypoint.id)).size >= 2).map(([k]) => k).sort(), operationsWithoutResource: unnamed };
|
|
111
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// not resolve, a handler matched by name only, a router whose mount point is not visible
|
|
13
13
|
// unresolved no handler could be identified; the manifest still exists and says so
|
|
14
14
|
// excluded_by_policy deliberately not processed (operator exclusion, or the entry-point cap)
|
|
15
|
-
import { hashOf } from './canonical.js';
|
|
15
|
+
import { hashOf, safeLabel } from './canonical.js';
|
|
16
16
|
import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
|
|
17
17
|
import { dependencyParams, signatureText } from './controls.js';
|
|
18
18
|
import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
|
|
@@ -313,7 +313,7 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
|
|
|
313
313
|
const n = (ord.get(stem) || 0) + 1;
|
|
314
314
|
ord.set(stem, n);
|
|
315
315
|
r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
|
|
316
|
-
r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}
|
|
316
|
+
r.label = safeLabel(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
|
|
317
317
|
void hname;
|
|
318
318
|
let disposition;
|
|
319
319
|
const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
|
|
@@ -92,6 +92,8 @@ export function metricsFor(units, protocolHash, name) {
|
|
|
92
92
|
precision: precisionOf(c), recall: recallOf(c), f1: f1Of(c),
|
|
93
93
|
precisionInterval: pn > 0 ? wilsonInterval(c.tp, pn) : { status: 'unmeasured', reason: 'nothing was reported' },
|
|
94
94
|
f1Interval: f1,
|
|
95
|
+
// A point estimate with no interval around it reads as a finding. It is reportable only when the interval is measured.
|
|
96
|
+
reportable: f1 && f1.status === 'measured',
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -127,6 +129,9 @@ function readLedger(root) {
|
|
|
127
129
|
const pct = (x) => (x === null || x === undefined ? 'n/a' : (x * 100).toFixed(0) + '%');
|
|
128
130
|
const interval = (i) => (!i || i.status !== 'measured' ? `unmeasured (${i && i.reason ? i.reason.split(',')[0] : 'no data'})` : `${pct(i.low)} to ${pct(i.high)}`);
|
|
129
131
|
|
|
132
|
+
// Precision, recall and F1 are printed only when their interval is measured; otherwise the counts stand alone.
|
|
133
|
+
const shown = (m, x) => (m.reportable ? pct(x) : 'withheld');
|
|
134
|
+
|
|
130
135
|
export function renderMarkdown(card) {
|
|
131
136
|
const L = [];
|
|
132
137
|
L.push('# Context Intelligence evaluation scorecard', '');
|
|
@@ -141,18 +146,20 @@ export function renderMarkdown(card) {
|
|
|
141
146
|
for (const s of card.systems) {
|
|
142
147
|
if (s.status !== 'measured') { L.push(`| ${s.system} | ${s.status}: ${s.reason} | | | | | | | | | | |`); continue; }
|
|
143
148
|
const m = s.metrics;
|
|
144
|
-
L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${
|
|
149
|
+
L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${shown(m, m.precision)} | ${shown(m, m.recall)} | ${shown(m, m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
|
|
145
150
|
}
|
|
146
151
|
L.push('', '## Economics (estimated; no price supplied)', '', '| system | median ctx tokens | P95 | P99 | model calls | calls per true positive | wall ms |', '|---|---|---|---|---|---|---|');
|
|
147
|
-
for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null ? '
|
|
152
|
+
for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null || !s.metrics || !s.metrics.reportable ? 'withheld' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
|
|
153
|
+
L.push('', 'Precision, recall, F1 and calls per true positive are withheld while their interval is unmeasured (fewer than 10 independent groups). The counts above are what was observed; they are not an estimate of anything.');
|
|
148
154
|
L.push('', `Replay: \`${card.replay}\``, '');
|
|
149
155
|
if (card.leakage.length) { L.push('## Leakage hits', ...card.leakage.map(h => `- ${h.where} ${h.file || ''} ${h.term}`), ''); }
|
|
150
156
|
return L.join('\n');
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export function renderCsv(card) {
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
// The marker is a column, not a banner: a CSV is the format that gets pasted into a spreadsheet without its header text.
|
|
161
|
+
const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars', 'synthetic']];
|
|
162
|
+
for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars, card.synthetic === false ? false : true]);
|
|
156
163
|
return rows.map(r => r.join(',')).join('\n') + '\n';
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -189,6 +189,16 @@ function stripUntrustedKeepUser(t) { return stripUntrusted(t).replace(/""/g, '""
|
|
|
189
189
|
|
|
190
190
|
function cite(l, role, claim) { return { role, file: l.file, startLine: l.n, endLine: l.n, claim }; }
|
|
191
191
|
|
|
192
|
+
/** A reader that cites a line says what is on it: the claim quotes the cited text, as a careful model would. */
|
|
193
|
+
function quoteCited(obj, blocks) {
|
|
194
|
+
const text = (c) => {
|
|
195
|
+
for (const b of blocks) if (b.file === c.file) { const l = b.lines.find(x => x.n === c.startLine); if (l) return l.text.trim().slice(0, 100); }
|
|
196
|
+
return '';
|
|
197
|
+
};
|
|
198
|
+
const fix = (list) => (Array.isArray(list) ? list.map(c => ({ ...c, claim: `${c.claim}: ${text(c)}` })) : list);
|
|
199
|
+
return { ...obj, evidence: fix(obj.evidence), observedMitigations: fix(obj.observedMitigations) };
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
/** The decision for a pack, from the observations. Returns the review JSON object. */
|
|
193
203
|
export function decide(packId, obs, ctx) {
|
|
194
204
|
const base = { observedMitigations: [], attackerControl: [], uncertainties: [], likelihood: 'low' };
|
|
@@ -237,7 +247,7 @@ export function scriptedReader() {
|
|
|
237
247
|
const invoke = async (prompt) => {
|
|
238
248
|
if (/^QUESTION PACK: /m.test(prompt)) {
|
|
239
249
|
const packId = /^QUESTION PACK: ([\w-]+)\//m.exec(prompt)[1];
|
|
240
|
-
const label = /^ENTRY POINT
|
|
250
|
+
const label = (/^ENTRY POINT LABEL \(data\): (.*)$/m.exec(prompt) || [])[1] || '';
|
|
241
251
|
const { blocks, unresolved, notIncluded, limit } = blocksFromReviewPrompt(prompt);
|
|
242
252
|
const routes = findRoutes(blocks);
|
|
243
253
|
const [method, ...rest] = label.split(' ');
|
|
@@ -246,7 +256,7 @@ export function scriptedReader() {
|
|
|
246
256
|
if (!route) return JSON.stringify({ evidence: [], observedMitigations: [], conclusion: 'insufficient_evidence', invariant: 'unspecified', uncertainties: ['the registration is not in the provided context'] });
|
|
247
257
|
const obs = analyseRoute(route, blocks);
|
|
248
258
|
const complete = !unresolved && !notIncluded && !limit;
|
|
249
|
-
return JSON.stringify(decide(packId, obs, { complete, unresolved }));
|
|
259
|
+
return JSON.stringify(quoteCited(decide(packId, obs, { complete, unresolved }), blocks));
|
|
250
260
|
}
|
|
251
261
|
// hunt prompt: report the candidates for the lens, with a location
|
|
252
262
|
const lens = /Your lens is ([\w ]+)\./.exec(prompt);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// DCI findings through the EXISTING reporting path (E4-07).
|
|
2
|
+
//
|
|
3
|
+
// An invariant violation or a cross-route inconsistency is not reported in a format of its own. It becomes a candidate in the schema the `hunt`
|
|
4
|
+
// pipeline already uses, goes through the same confirmation and judging (`adjudicate`: confirm.js -> judge.js), and comes out as a normal finding with
|
|
5
|
+
// a stableId, a severity, a CWE and a `contextIntelligence` block. From there triage, SARIF (`toSARIF`), CI gates, the verified-fix policy and the
|
|
6
|
+
// privacy controls apply with no DCI-specific code.
|
|
7
|
+
//
|
|
8
|
+
// WHAT THE EXISTING TIERS MEAN HERE. Severity comes from the confirmation tier alone (taint-confirmed high, sink-adjacent medium, unconfirmed low).
|
|
9
|
+
// A deterministic counterexample maps onto those tiers like this, and only like this:
|
|
10
|
+
// proven -> taint-confirmed an executed local request showed the exposure
|
|
11
|
+
// plausible -> sink-adjacent a resolved path reaches the operation with no such control before it
|
|
12
|
+
// unresolved -> unconfirmed the analysis could not say
|
|
13
|
+
// refuted -> no finding the claim was contradicted; it is counted, not reported
|
|
14
|
+
//
|
|
15
|
+
// WHAT A DCI FINDING CARRIES (all under `contextIntelligence`, never as new top-level fields): the invariant violated (or the sibling-route rule),
|
|
16
|
+
// the affected authorization scope (resource, control, routes, files), the code that shows it, the counterexample state and trace, a fix
|
|
17
|
+
// recommendation, and a verification slot that `verifyDciFix` fills after a patch.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { adjudicate } from './candidates.js';
|
|
20
|
+
import { assessCandidate, executeAttempt } from './counterexample.js';
|
|
21
|
+
import { findCrossRouteInconsistencies } from './cross-route.js';
|
|
22
|
+
import { evaluateInvariants } from './invariants.js';
|
|
23
|
+
import { buildContext } from './build.js';
|
|
24
|
+
import { normalizeConfig } from './config.js';
|
|
25
|
+
import { buildFixVerificationRecord, evidenceItem, leg } from '../fix/verification-record.js';
|
|
26
|
+
import { patchHashOf } from '../fix/verification-adapters.js';
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
|
|
31
|
+
const CWE = { ownership: 'CWE-639', 'tenant-scope': 'CWE-639', authentication: 'CWE-306', 'authorization-role': 'CWE-862' };
|
|
32
|
+
const TIER_OF = { proven: 'taint-confirmed', plausible: 'sink-adjacent', unresolved: 'unconfirmed' };
|
|
33
|
+
const FIX = {
|
|
34
|
+
ownership: 'Scope the data operation to the authenticated principal: add the owner to the query filter (for example `{ id, ownerId: req.user.id }`) or compare the loaded object\'s owner with the principal and reject on mismatch, the way the enforcing sibling route does.',
|
|
35
|
+
'tenant-scope': 'Scope the data operation to the principal\'s tenant: add the tenant to the query filter or reject when the object\'s tenant differs from the principal\'s, the way the enforcing sibling route does.',
|
|
36
|
+
authentication: 'Require an authenticated principal before the sensitive operation, using the same guard the sibling routes use.',
|
|
37
|
+
'authorization-role': 'Require the role the sibling routes require before the sensitive operation.',
|
|
38
|
+
};
|
|
39
|
+
const sha = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
|
|
40
|
+
const siteOf = (ep) => ({ file: ep?.registrationSite?.file || ep?.rootSpan?.file || '', line: ep?.registrationSite?.line || ep?.rootSpan?.startLine || 1 });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{crossRoute?:object, invariantResults?:object[], bundles:object[], invariants?:object}} src
|
|
44
|
+
* @returns {object[]} candidates in the shared discovery schema, each with `deterministic` evidence
|
|
45
|
+
*/
|
|
46
|
+
export function candidatesFromEvidence({ crossRoute, invariantResults = [], bundles }) {
|
|
47
|
+
const byLabel = new Map(bundles.filter((b) => b.manifest).map((b) => [b.entrypoint.label, b]));
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const f of crossRoute?.findings || []) {
|
|
50
|
+
const b = byLabel.get(f.route); if (!b) continue;
|
|
51
|
+
const site = siteOf(b.entrypoint);
|
|
52
|
+
const id = sha(['xroute', f.resource, f.dimension, f.route, site.file]).slice(0, 12);
|
|
53
|
+
out.push({
|
|
54
|
+
id, focusAreaId: sha(['ctx', b.manifest.contextHash]).slice(0, 12), lens: 'dci-authz',
|
|
55
|
+
title: `Inconsistent ${f.dimension} enforcement on ${f.resource}: ${f.route}`, file: site.file, line: site.line, family: 'authorization', cwe: CWE[f.dimension] || 'CWE-285',
|
|
56
|
+
rationale: f.explanation, entryPoint: f.route, sink: `${f.operations.join('/')} of ${f.resource}`, origin: 'context-intelligence', questionPack: null, advisoryOnly: false,
|
|
57
|
+
witnesses: [{ entrypointId: b.entrypoint.id, label: f.route, contextHash: b.manifest.contextHash, questionPack: null }],
|
|
58
|
+
sourceSpanEvidence: f.enforcedAt.flatMap((e) => (e.evidence || []).map((x) => ({ role: 'guard', file: x.file, startLine: x.line, endLine: x.line }))),
|
|
59
|
+
scopeIntegrity: { contextIntegrity: b.manifest.context.integrity, standalone: true, deterministicAgreement: 'agrees', deterministicProtection: null },
|
|
60
|
+
reviewStatus: 'deterministic', uncertainties: [], recommendedValidation: `Run the same request as a principal who does not own the ${f.resource} against ${f.route} and against ${f.enforcedAt[0]?.route}; the sibling should refuse and this route should not.`,
|
|
61
|
+
remediation: FIX[f.dimension] || 'Enforce the same control as the sibling routes.',
|
|
62
|
+
deterministic: {
|
|
63
|
+
rule: { kind: 'cross-route-consistency', description: `Every route that touches a resource enforces what its sibling routes enforce` },
|
|
64
|
+
invariant: null,
|
|
65
|
+
scope: { resource: f.resource, control: f.dimension, operations: f.operations, route: f.route, enforcedAt: f.enforcedAt.map((e) => e.route), files: [...new Set([site.file, ...f.enforcedAt.flatMap((e) => (e.evidence || []).map((x) => x.file))])] },
|
|
66
|
+
counterexample: null, fix: { recommendation: FIX[f.dimension] || null }, verification: { status: 'pending' },
|
|
67
|
+
},
|
|
68
|
+
_bundle: b, _crossRoute: f,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
for (const r of invariantResults) {
|
|
72
|
+
if (r.verdict !== 'violated') continue;
|
|
73
|
+
const b = byLabel.get(r.label); if (!b) continue;
|
|
74
|
+
const site = siteOf(b.entrypoint);
|
|
75
|
+
const dims = r.controls.filter((c) => c.state === 'absent').map((c) => c.dim);
|
|
76
|
+
const id = sha(['inv', r.invariantId, r.entrypointId]).slice(0, 12);
|
|
77
|
+
out.push({
|
|
78
|
+
id, focusAreaId: sha(['ctx', b.manifest.contextHash]).slice(0, 12), lens: 'dci-authz',
|
|
79
|
+
title: `Invariant ${r.invariantId} violated: ${r.label}`, file: site.file, line: site.line, family: 'authorization', cwe: CWE[dims[0]] || 'CWE-285',
|
|
80
|
+
rationale: `${r.title}. ${r.reasons.join('; ')}`, entryPoint: r.label, sink: dims.join(', ') + ' missing', origin: 'context-intelligence', questionPack: null, advisoryOnly: false,
|
|
81
|
+
witnesses: [{ entrypointId: r.entrypointId, label: r.label, contextHash: b.manifest.contextHash, questionPack: null }], sourceSpanEvidence: [],
|
|
82
|
+
scopeIntegrity: { contextIntegrity: b.manifest.context.integrity, standalone: true, deterministicAgreement: 'agrees', deterministicProtection: null },
|
|
83
|
+
reviewStatus: 'deterministic', uncertainties: [], recommendedValidation: `Request ${r.label} as a principal lacking: ${dims.join(', ')}.`,
|
|
84
|
+
remediation: dims.map((d) => FIX[d]).filter(Boolean).join(' ') || 'Enforce the controls the invariant requires.',
|
|
85
|
+
deterministic: { rule: { kind: 'invariant', id: r.invariantId, title: r.title, declaredSeverity: r.severity || null }, invariant: { id: r.invariantId, title: r.title, requires: dims }, scope: { resource: null, control: dims, route: r.label, files: [r.file] }, counterexample: null, fix: { recommendation: dims.map((d) => FIX[d]).filter(Boolean) }, verification: { status: 'pending' } },
|
|
86
|
+
_bundle: b, _invariant: r,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Attempt a counterexample for every candidate that has a cross-route basis, set each candidate's confirmation tier from it, and run the shared
|
|
94
|
+
* adjudication. Refuted candidates are returned separately and produce no finding.
|
|
95
|
+
*/
|
|
96
|
+
export async function dciFindings({ candidates, bundles, fixtures = {}, scanRoot = null }) {
|
|
97
|
+
const states = new Map(); const refuted = [];
|
|
98
|
+
const live = [];
|
|
99
|
+
for (const c of candidates) {
|
|
100
|
+
let a;
|
|
101
|
+
if (c._crossRoute) a = assessCandidate(c._crossRoute, { bundles, fixture: fixtures[c._crossRoute.route] || fixtures[c.id] });
|
|
102
|
+
else a = { state: 'plausible', trace: [{ kind: 'static', what: 'invariant evaluation', detail: 'the protection verdict for the required control is absent' }], reasons: ['no sibling route to attempt a counterexample against'] };
|
|
103
|
+
states.set(c.id, a);
|
|
104
|
+
const pub = { state: a.state, trace: a.trace, reasons: a.reasons };
|
|
105
|
+
if (a.state === 'refuted') { refuted.push({ id: c.id, title: c.title, file: c.file, line: c.line, refutation: pub }); continue; }
|
|
106
|
+
live.push({ ...c, deterministic: { ...c.deterministic, counterexample: pub } });
|
|
107
|
+
}
|
|
108
|
+
const clean = live.map(({ _bundle, _crossRoute, _invariant, ...rest }) => rest);
|
|
109
|
+
const taintProbe = async (cand) => ({ tier: TIER_OF[states.get(cand.id).state] || 'unconfirmed', evidence: { counterexample: states.get(cand.id).state } });
|
|
110
|
+
const adj = await adjudicate(clean, { taintProbe, scanRoot });
|
|
111
|
+
return { ...adj, counterexampleRefuted: refuted, assessments: Object.fromEntries([...states].map(([k, v]) => [k, v.state])) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- final verification of a fix ------------------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
async function analyse(files, invariants, now) {
|
|
117
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dci-verify-'));
|
|
118
|
+
try {
|
|
119
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{"name":"p","version":"1.0.0"}');
|
|
120
|
+
for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true }); fs.writeFileSync(path.join(root, f), t); }
|
|
121
|
+
const built = await buildContext(root, { config: normalizeConfig({}).config });
|
|
122
|
+
if (!built.ok) return { ok: false, reason: built.reason };
|
|
123
|
+
const crossRoute = findCrossRouteInconsistencies(built.bundles, { now, invariants });
|
|
124
|
+
const inv = invariants ? evaluateInvariants(invariants, built.bundles, { now }) : { results: [], problems: [] };
|
|
125
|
+
return { ok: true, bundles: built.bundles, crossRoute, invariantResults: inv.results };
|
|
126
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Settle a DCI finding after a patch: is the cross-route inconsistency (or the invariant violation) gone in the patched tree, and does the same
|
|
131
|
+
* executed attempt now fail? Returns a verdict and a patch-level verification record in the shared fix-verification schema, so the verified-fix policy
|
|
132
|
+
* (completeness tiers, residual risk) applies unchanged.
|
|
133
|
+
* @param {{finding:object, candidate:object, before:Record<string,string>, after:Record<string,string>, fixture?:object, invariants?:object, now?:Date}} o
|
|
134
|
+
*/
|
|
135
|
+
export async function verifyDciFix({ finding, candidate, before, after, fixture = null, invariants = null, now = new Date() }) {
|
|
136
|
+
const post = await analyse(after, invariants, now);
|
|
137
|
+
const route = candidate._crossRoute?.route || candidate._invariant?.label;
|
|
138
|
+
const dim = candidate._crossRoute?.dimension;
|
|
139
|
+
const stillInconsistent = post.ok && post.crossRoute.findings.some((f) => f.route === route && (!dim || f.dimension === dim));
|
|
140
|
+
const stillViolated = post.ok && post.invariantResults.some((r) => r.verdict === 'violated' && r.label === route && (!candidate._invariant || r.invariantId === candidate._invariant.invariantId));
|
|
141
|
+
let after1 = null;
|
|
142
|
+
// the attempt is EXECUTED on the patched files even though a static look may already see the control: a fix is verified by the attack failing, not by the code looking right
|
|
143
|
+
if (candidate._crossRoute && fixture) after1 = executeAttempt(candidate._crossRoute, { ...fixture, files: after });
|
|
144
|
+
const execDenied = after1 && after1.state === 'refuted' && after1.trace.some((t) => t.kind === 'executed');
|
|
145
|
+
const execStillWorks = after1 && after1.state === 'proven';
|
|
146
|
+
const ev = []; const add = (kind, v) => { const e = evidenceItem({ kind, mediaType: 'application/json', sha256: sha(v) }); if (!ev.some((x) => x.id === e.id)) ev.push(e); return e.id; };
|
|
147
|
+
const before1 = finding?.contextIntelligence?.deterministic?.counterexample;
|
|
148
|
+
const securityAfter = !post.ok ? leg('blocked', { reason: `the patched tree could not be analysed (${post.reason})` })
|
|
149
|
+
: (stillInconsistent || stillViolated) ? leg('failed', { evidenceIds: [add('dci-reanalysis', { stillInconsistent, stillViolated })], reason: 'the same inconsistency or invariant violation is still reported on the patched tree' })
|
|
150
|
+
: leg('passed', { evidenceIds: [add('dci-reanalysis', { stillInconsistent, stillViolated })] });
|
|
151
|
+
const files = Object.keys(after).filter((f) => after[f] !== before[f]);
|
|
152
|
+
const record = buildFixVerificationRecord({
|
|
153
|
+
findingId: finding?.id || candidate.id, findingStableId: finding?.stableId, mode: 'verify', route: 'dci-reverify', patchHash: patchHashOf(Object.fromEntries(files.map((f) => [f, after[f]]))), postPatchHash: patchHashOf(after), affectedFiles: files.length ? files : ['(none)'],
|
|
154
|
+
securityBefore: leg('passed', { evidenceIds: [add('dci-finding', { id: finding?.id, scope: finding?.contextIntelligence?.deterministic?.scope })] }),
|
|
155
|
+
securityAfter,
|
|
156
|
+
exploitProofBefore: before1 && before1.state === 'proven' ? leg('passed', { evidenceIds: [add('dci-counterexample', before1)] }) : leg('not_run', { reason: 'the finding had no executed counterexample' }),
|
|
157
|
+
exploitProofAfter: execDenied ? leg('passed', { evidenceIds: [add('dci-counterexample-after', after1)] }) : execStillWorks ? leg('failed', { evidenceIds: [add('dci-counterexample-after', after1)], reason: 'the same executed attempt still succeeds on the patched tree' }) : leg('not_run', { reason: fixture ? 'the executed attempt could not be made on the patched tree' : 'no fixture was supplied' }),
|
|
158
|
+
newFindingDelta: post.ok ? (post.crossRoute.findings.filter((f) => !before1 || f.route !== route).length > 0 ? leg('not_run', { reason: 'other inconsistencies remain; not compared with the pre-patch set here' }) : leg('passed', { evidenceIds: [add('dci-reanalysis', { remaining: post.crossRoute.findings.length })] })) : leg('not_run', { reason: 'the patched tree could not be analysed' }),
|
|
159
|
+
behaviorPreservation: leg('not_run', { reason: 'run the project tests through the fix verifier (verifyPatch) to cover behaviour' }),
|
|
160
|
+
behaviorTests: leg('not_run', { reason: 'not run by this check' }),
|
|
161
|
+
// behaviour is required like everywhere else: a re-analysis and an attack that fails say the weakness is gone, not that the feature survived
|
|
162
|
+
requiredLegs: ['securityAfter', 'behaviorPreservation', 'behaviorTests', ...(fixture ? ['exploitProofAfter'] : [])], evidence: ev, tools: { engineVersion: null, verifier: 'dci-reverify' },
|
|
163
|
+
});
|
|
164
|
+
const status = (stillInconsistent || stillViolated || execStillWorks) ? 'failed' : (!post.ok ? 'inconclusive' : 'verified');
|
|
165
|
+
return { status, stillInconsistent, stillViolated, executedAfter: after1 ? { state: after1.state, trace: after1.trace } : null, record };
|
|
166
|
+
}
|