@clear-capabilities/agentic-security-scanner 0.172.1 → 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 +15 -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/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/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 +135 -11
- 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 +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +28 -3
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- 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 +2 -2
- package/src/context-intelligence/review.js +68 -4
- 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,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
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// First-class security invariants for Deterministic Context Intelligence (E4-02).
|
|
2
|
+
//
|
|
3
|
+
// An invariant is a rule a team states once, in a versioned file, and the analysis then checks on every entry point it applies to:
|
|
4
|
+
// "every /orders route must be authenticated and owner-scoped", "the health check is public by design". It replaces the reviewer's unwritten
|
|
5
|
+
// expectation with something that can be validated, scoped, expired and audited.
|
|
6
|
+
//
|
|
7
|
+
// FILE context-intelligence-invariants.json at the project root (or a path the caller passes)
|
|
8
|
+
// SCHEMA { schema: 'agentic-security/dci-invariants', schemaVersion: '1.0.0', invariants: [ ... ] }
|
|
9
|
+
// RULES 'requires' the matching entry points must carry every listed control before their first sensitive operation
|
|
10
|
+
// 'public-by-design' the matching entry points are deliberately open on the listed dimensions; this is the only way a missing control
|
|
11
|
+
// is accepted, and it needs a reason, an owner and a review date
|
|
12
|
+
// SCOPE appliesTo.routes / methods choose entry points by label; validity.paths limits the invariant to handlers in those files
|
|
13
|
+
// (path-scoped validity); validity.from / until bound it in time. An invariant that is out of scope for an entry point says
|
|
14
|
+
// `not_applicable`, never `satisfied`.
|
|
15
|
+
//
|
|
16
|
+
// VERDICTS satisfied | violated | unknown (the analysis could not resolve enough to say) | not_applicable | public_by_policy | expired
|
|
17
|
+
// A verdict never rests on a model. It reads the protection states DCI already computed, so it is as strong as that evidence and says so.
|
|
18
|
+
//
|
|
19
|
+
// The validator is closed-world: an unknown field, a duplicate id, an empty control list, a glob that is not a glob, a public-by-design with no
|
|
20
|
+
// reason, or an invariant already past its `until` date is reported with a path and a code, never silently dropped.
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { join as pathJoin } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const INVARIANTS_SCHEMA = 'agentic-security/dci-invariants';
|
|
25
|
+
export const INVARIANTS_VERSION = '1.0.0';
|
|
26
|
+
export const INVARIANTS_FILE = 'context-intelligence-invariants.json';
|
|
27
|
+
export const CONTROL_DIMENSIONS = Object.freeze(['authentication', 'authorization-role', 'ownership', 'tenant-scope']);
|
|
28
|
+
export const VERDICTS = Object.freeze(['satisfied', 'violated', 'unknown', 'not_applicable', 'public_by_policy', 'expired']);
|
|
29
|
+
const RULES = ['requires', 'public-by-design'];
|
|
30
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ANY'];
|
|
31
|
+
const ID_RE = /^[A-Z][A-Z0-9-]{2,63}$/;
|
|
32
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
33
|
+
const TOP = ['schema', 'schemaVersion', 'invariants'];
|
|
34
|
+
const INV = ['id', 'title', 'rule', 'appliesTo', 'requires', 'waives', 'public', 'validity', 'severity'];
|
|
35
|
+
const APPLIES = ['routes', 'methods'];
|
|
36
|
+
const VALID = ['paths', 'from', 'until'];
|
|
37
|
+
const PUB = ['reason', 'owner', 'reviewBy'];
|
|
38
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
39
|
+
|
|
40
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
const validGlob = (g) => typeof g === 'string' && g.length > 0 && g.length <= 200 && !/[\0\r\n]/.test(g) && !/\*\*\*/.test(g) && !g.includes('..');
|
|
42
|
+
|
|
43
|
+
/** Glob to RegExp: `**` any characters, `*` any characters except `/`, everything else literal. */
|
|
44
|
+
export function globToRegExp(glob) {
|
|
45
|
+
let re = '';
|
|
46
|
+
for (let i = 0; i < glob.length; i++) {
|
|
47
|
+
const c = glob[i];
|
|
48
|
+
if (c === '*') { if (glob[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; } else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
return new RegExp(`^${re}$`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @returns {{ok:boolean, errors:{code:string,path:string,message:string}[], warnings:{code:string,path:string,message:string}[]}} */
|
|
54
|
+
export function validateInvariants(doc, { now = new Date() } = {}) {
|
|
55
|
+
const errors = []; const warnings = [];
|
|
56
|
+
const err = (code, path, message) => errors.push({ code, path, message });
|
|
57
|
+
const warn = (code, path, message) => warnings.push({ code, path, message });
|
|
58
|
+
if (!isObj(doc)) { err('NOT_AN_OBJECT', '', 'the invariants file must be a JSON object'); return { ok: false, errors, warnings }; }
|
|
59
|
+
for (const k of Object.keys(doc)) if (!TOP.includes(k)) err('UNKNOWN_FIELD', k, `field '${k}' is not part of this schema`);
|
|
60
|
+
if (doc.schema !== INVARIANTS_SCHEMA) { err('SCHEMA_MISMATCH', 'schema', `expected ${INVARIANTS_SCHEMA}`); return { ok: false, errors, warnings }; }
|
|
61
|
+
const m = typeof doc.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(doc.schemaVersion) : null;
|
|
62
|
+
if (!m) { err('BAD_VERSION', 'schemaVersion', 'must be a semantic version'); return { ok: false, errors, warnings }; }
|
|
63
|
+
if (Number(m[1]) !== 1) { err('UNSUPPORTED_MAJOR', 'schemaVersion', `major ${m[1]} is not supported (supported: 1)`); return { ok: false, errors, warnings }; }
|
|
64
|
+
if (!Array.isArray(doc.invariants)) { err('MISSING_FIELD', 'invariants', 'must be a list'); return { ok: false, errors, warnings }; }
|
|
65
|
+
if (doc.invariants.length > 500) err('TOO_MANY', 'invariants', 'at most 500 invariants');
|
|
66
|
+
const today = new Date(now); const iso = (d) => d.toISOString().slice(0, 10);
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
doc.invariants.forEach((inv, i) => {
|
|
69
|
+
const p = `invariants[${i}]`;
|
|
70
|
+
if (!isObj(inv)) { err('BAD_TYPE', p, 'an invariant is an object'); return; }
|
|
71
|
+
for (const k of Object.keys(inv)) if (!INV.includes(k)) err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of an invariant`);
|
|
72
|
+
if (typeof inv.id !== 'string' || !ID_RE.test(inv.id)) err('BAD_ID', `${p}.id`, 'id must look like ORDERS-001: capitals, digits and hyphens, 3 to 64 characters');
|
|
73
|
+
else if (seen.has(inv.id)) err('DUPLICATE_ID', `${p}.id`, `duplicate id ${inv.id}`); else seen.add(inv.id);
|
|
74
|
+
if (typeof inv.title !== 'string' || !inv.title.trim()) err('MISSING_FIELD', `${p}.title`, 'a title is required');
|
|
75
|
+
if (!RULES.includes(inv.rule)) err('UNKNOWN_ENUM', `${p}.rule`, `rule must be one of ${RULES.join(', ')}`);
|
|
76
|
+
if (inv.severity !== undefined && !SEVERITIES.includes(inv.severity)) err('UNKNOWN_ENUM', `${p}.severity`, `severity must be one of ${SEVERITIES.join(', ')}`);
|
|
77
|
+
const a = inv.appliesTo;
|
|
78
|
+
if (!isObj(a)) err('MISSING_FIELD', `${p}.appliesTo`, 'appliesTo is required');
|
|
79
|
+
else {
|
|
80
|
+
for (const k of Object.keys(a)) if (!APPLIES.includes(k)) err('UNKNOWN_FIELD', `${p}.appliesTo.${k}`, `field '${k}' is not part of appliesTo`);
|
|
81
|
+
if (!Array.isArray(a.routes) || !a.routes.length) err('MISSING_FIELD', `${p}.appliesTo.routes`, 'at least one route glob is required');
|
|
82
|
+
else a.routes.forEach((g, j) => { if (!validGlob(g)) err('BAD_GLOB', `${p}.appliesTo.routes[${j}]`, 'not a usable glob'); else if (g === '**' || g === '/**') warn('BROAD_SCOPE', `${p}.appliesTo.routes[${j}]`, 'matches every route'); });
|
|
83
|
+
if (a.methods !== undefined && (!Array.isArray(a.methods) || !a.methods.length || a.methods.some((x) => !METHODS.includes(x)))) err('UNKNOWN_ENUM', `${p}.appliesTo.methods`, `methods must be a non-empty list of ${METHODS.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
const dimList = (field, value) => {
|
|
86
|
+
if (!Array.isArray(value) || !value.length) { err('MISSING_FIELD', `${p}.${field}`, `${field} must list at least one control`); return; }
|
|
87
|
+
if (new Set(value).size !== value.length) err('DUPLICATE_ID', `${p}.${field}`, 'a control is listed twice');
|
|
88
|
+
value.forEach((d, j) => { if (!CONTROL_DIMENSIONS.includes(d)) err('UNKNOWN_ENUM', `${p}.${field}[${j}]`, `control must be one of ${CONTROL_DIMENSIONS.join(', ')}`); });
|
|
89
|
+
};
|
|
90
|
+
if (inv.rule === 'requires') {
|
|
91
|
+
dimList('requires', inv.requires);
|
|
92
|
+
if (inv.waives !== undefined || inv.public !== undefined) err('RULE_VIOLATION', `${p}`, "'requires' does not take waives or public");
|
|
93
|
+
} else if (inv.rule === 'public-by-design') {
|
|
94
|
+
dimList('waives', inv.waives);
|
|
95
|
+
if (inv.requires !== undefined) err('RULE_VIOLATION', `${p}.requires`, "'public-by-design' does not take requires");
|
|
96
|
+
const pub = inv.public;
|
|
97
|
+
if (!isObj(pub)) err('MISSING_FIELD', `${p}.public`, 'a public-by-design invariant needs { reason, owner, reviewBy }');
|
|
98
|
+
else {
|
|
99
|
+
for (const k of Object.keys(pub)) if (!PUB.includes(k)) err('UNKNOWN_FIELD', `${p}.public.${k}`, `field '${k}' is not part of public`);
|
|
100
|
+
if (typeof pub.reason !== 'string' || pub.reason.trim().length < 10) err('MISSING_FIELD', `${p}.public.reason`, 'a reason of at least 10 characters is required: openness must be justified');
|
|
101
|
+
if (typeof pub.owner !== 'string' || !pub.owner.trim()) err('MISSING_FIELD', `${p}.public.owner`, 'an owner is required');
|
|
102
|
+
if (typeof pub.reviewBy !== 'string' || !DATE_RE.test(pub.reviewBy) || Number.isNaN(Date.parse(pub.reviewBy))) err('BAD_DATE', `${p}.public.reviewBy`, 'reviewBy must be a YYYY-MM-DD date');
|
|
103
|
+
else if (pub.reviewBy < iso(today)) err('EXPIRED', `${p}.public.reviewBy`, `the review date ${pub.reviewBy} has passed: public-by-design must be re-approved`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const v = inv.validity;
|
|
107
|
+
if (v !== undefined) {
|
|
108
|
+
if (!isObj(v)) err('BAD_TYPE', `${p}.validity`, 'validity is an object');
|
|
109
|
+
else {
|
|
110
|
+
for (const k of Object.keys(v)) if (!VALID.includes(k)) err('UNKNOWN_FIELD', `${p}.validity.${k}`, `field '${k}' is not part of validity`);
|
|
111
|
+
if (v.paths !== undefined) { if (!Array.isArray(v.paths) || !v.paths.length) err('BAD_TYPE', `${p}.validity.paths`, 'paths must be a non-empty list of globs'); else v.paths.forEach((g, j) => { if (!validGlob(g)) err('BAD_GLOB', `${p}.validity.paths[${j}]`, 'not a usable glob'); }); }
|
|
112
|
+
for (const f of ['from', 'until']) if (v[f] !== undefined && (typeof v[f] !== 'string' || !DATE_RE.test(v[f]) || Number.isNaN(Date.parse(v[f])))) err('BAD_DATE', `${p}.validity.${f}`, `${f} must be a YYYY-MM-DD date`);
|
|
113
|
+
if (v.from && v.until && v.from > v.until) err('RULE_VIOLATION', `${p}.validity`, 'from is after until');
|
|
114
|
+
if (v.until && v.until < iso(today)) warn('PAST_UNTIL', `${p}.validity.until`, `until ${v.until} has passed: the invariant is reported as expired`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const norm = (s) => String(s || '').trim();
|
|
122
|
+
function routeMatches(a, label, method) {
|
|
123
|
+
if (Array.isArray(a.methods) && method && !a.methods.includes('ANY') && !a.methods.includes(method) && method !== 'ANY') return false;
|
|
124
|
+
// the label is `METHOD /path`; route globs name the path
|
|
125
|
+
const path = norm(label).replace(/^[A-Z]+\s+/, '');
|
|
126
|
+
return a.routes.some((g) => globToRegExp(g).test(path));
|
|
127
|
+
}
|
|
128
|
+
function handlerFile(ep) { return ep?.rootSpan?.file || ep?.registrationSite?.file || ''; }
|
|
129
|
+
|
|
130
|
+
/** Control state of one dimension for a bundle: present / absent / unresolved / none-to-protect. */
|
|
131
|
+
function dimensionState(bundle, dim) {
|
|
132
|
+
const prot = bundle.manifest?.protection || {};
|
|
133
|
+
if (dim === 'tenant-scope') {
|
|
134
|
+
const facts = bundle.reach?.facts || [];
|
|
135
|
+
const sens = facts.filter((f) => f.category === 'sensitive');
|
|
136
|
+
if (!sens.length) return { state: 'none-to-protect', why: 'no sensitive operation was seen' };
|
|
137
|
+
const first = Math.min(...sens.map((f) => f.effLine));
|
|
138
|
+
const ctl = facts.filter((f) => f.category === 'control' && f.kind === 'tenant-scope' && (f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
|
|
139
|
+
if (ctl.length) return { state: 'present', why: 'a tenant scope precedes the first sensitive operation', evidence: ctl.slice(0, 4).map((f) => ({ file: f.file, line: f.line, basis: f.basis })) };
|
|
140
|
+
const unresolved = (bundle.manifest?.unknowns || []).some((u) => u.material);
|
|
141
|
+
return unresolved ? { state: 'unresolved', why: 'an unresolved call could hide a tenant scope' } : { state: 'absent', why: 'the resolved context contains no tenant scope' };
|
|
142
|
+
}
|
|
143
|
+
const d = prot[dim];
|
|
144
|
+
if (!d) return { state: 'unresolved', why: 'the bundle carries no protection verdict for this dimension' };
|
|
145
|
+
if (d.state === 'present') return { state: 'present', why: d.why, evidence: d.evidence };
|
|
146
|
+
if (d.state === 'not_seen') return { state: 'none-to-protect', why: d.why };
|
|
147
|
+
if (d.state === 'not_present' || d.state === 'evaluated_and_ineffective') return { state: 'absent', why: d.why, evidence: d.evidence };
|
|
148
|
+
return { state: 'unresolved', why: d.why };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {object} doc a validated invariants document
|
|
153
|
+
* @param {{entrypoint:object, manifest:object, reach:object}[]} bundles the bundles buildContext returned (those with a manifest)
|
|
154
|
+
* @returns {{results:object[], problems:string[]}}
|
|
155
|
+
*/
|
|
156
|
+
export function evaluateInvariants(doc, bundles, { now = new Date() } = {}) {
|
|
157
|
+
const v = validateInvariants(doc, { now });
|
|
158
|
+
if (!v.ok) return { results: [], problems: v.errors.map((e) => `${e.path}: ${e.code}`) };
|
|
159
|
+
const today = now.toISOString().slice(0, 10);
|
|
160
|
+
const results = [];
|
|
161
|
+
const publics = doc.invariants.filter((i) => i.rule === 'public-by-design');
|
|
162
|
+
for (const b of bundles.filter((x) => x.manifest)) {
|
|
163
|
+
const ep = b.entrypoint; const file = handlerFile(ep);
|
|
164
|
+
for (const inv of doc.invariants) {
|
|
165
|
+
if (inv.rule !== 'requires') continue;
|
|
166
|
+
const base = { invariantId: inv.id, title: inv.title, entrypointId: ep.id, label: ep.label, file, ...(inv.severity ? { severity: inv.severity } : {}) };
|
|
167
|
+
if (!routeMatches(inv.appliesTo, ep.label, ep.method)) continue;
|
|
168
|
+
const val = inv.validity || {};
|
|
169
|
+
if (Array.isArray(val.paths) && !val.paths.some((g) => globToRegExp(g).test(file))) { results.push({ ...base, verdict: 'not_applicable', reasons: [`the handler file ${file} is outside this invariant's validity paths`] }); continue; }
|
|
170
|
+
if (val.from && today < val.from) { results.push({ ...base, verdict: 'not_applicable', reasons: [`the invariant is valid from ${val.from}`] }); continue; }
|
|
171
|
+
if (val.until && today > val.until) { results.push({ ...base, verdict: 'expired', reasons: [`the invariant expired on ${val.until}`] }); continue; }
|
|
172
|
+
const dims = inv.requires.map((d) => ({ dim: d, ...dimensionState(b, d) }));
|
|
173
|
+
const waivedBy = (dim) => publics.find((p) => p.waives.includes(dim) && routeMatches(p.appliesTo, ep.label, ep.method) && (!p.validity?.paths || p.validity.paths.some((g) => globToRegExp(g).test(file))) && !(p.public.reviewBy < today));
|
|
174
|
+
const missing = dims.filter((x) => x.state === 'absent');
|
|
175
|
+
const unresolved = dims.filter((x) => x.state === 'unresolved');
|
|
176
|
+
const allNone = dims.every((x) => x.state === 'none-to-protect');
|
|
177
|
+
if (allNone) { results.push({ ...base, verdict: 'not_applicable', reasons: ['no sensitive operation was seen, so there is nothing for a control to protect'], controls: dims }); continue; }
|
|
178
|
+
const unwaived = missing.filter((x) => !waivedBy(x.dim));
|
|
179
|
+
const waived = missing.filter((x) => waivedBy(x.dim));
|
|
180
|
+
if (unwaived.length) results.push({ ...base, verdict: 'violated', reasons: unwaived.map((x) => `${x.dim}: ${x.why}`), controls: dims });
|
|
181
|
+
else if (waived.length) results.push({ ...base, verdict: 'public_by_policy', reasons: waived.map((x) => `${x.dim} is waived by ${waivedBy(x.dim).id}: ${waivedBy(x.dim).public.reason}`), waivedBy: [...new Set(waived.map((x) => waivedBy(x.dim).id))], controls: dims });
|
|
182
|
+
else if (unresolved.length) results.push({ ...base, verdict: 'unknown', reasons: unresolved.map((x) => `${x.dim}: ${x.why}`), controls: dims });
|
|
183
|
+
else results.push({ ...base, verdict: 'satisfied', reasons: dims.filter((x) => x.state === 'present').map((x) => `${x.dim}: ${x.why}`), controls: dims });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
results.sort((a, b) => (a.invariantId + a.entrypointId < b.invariantId + b.entrypointId ? -1 : 1));
|
|
187
|
+
return { results, problems: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Read and validate the invariants file of a project. A missing file is "no invariants", not an error; a malformed one reports every problem. */
|
|
191
|
+
export function loadInvariants(root, { file = INVARIANTS_FILE, now } = {}) {
|
|
192
|
+
const full = pathJoin(root, file);
|
|
193
|
+
if (!existsSync(full)) return { present: false, doc: null, validation: { ok: true, errors: [], warnings: [] } };
|
|
194
|
+
let doc;
|
|
195
|
+
try { doc = JSON.parse(readFileSync(full, 'utf8')); } catch (e) { return { present: true, doc: null, validation: { ok: false, errors: [{ code: 'BAD_JSON', path: '', message: String(e.message).slice(0, 200) }], warnings: [] } }; }
|
|
196
|
+
return { present: true, doc, validation: validateInvariants(doc, now ? { now } : undefined) };
|
|
197
|
+
}
|
|
@@ -143,6 +143,8 @@ export async function runQueue(jobs, ctx) {
|
|
|
143
143
|
maxCalls: Math.min(HARD_LIMITS.maxCalls, Math.max(1, ctx.limits.maxCalls || 100)),
|
|
144
144
|
maxWallMs: Math.min(HARD_LIMITS.maxWallSeconds, Math.max(1, ctx.limits.maxWallSeconds || 900)) * 1000,
|
|
145
145
|
maxTokensPerJob: ctx.limits.maxTokensPerJob || 40000,
|
|
146
|
+
maxTokensPerRepo: Number.isFinite(ctx.limits.maxTokensPerRepo) ? ctx.limits.maxTokensPerRepo : null,
|
|
147
|
+
anonymizePrompts: ctx.limits.anonymizePrompts === true,
|
|
146
148
|
maxRetries: Math.min(HARD_LIMITS.maxRetries, Math.max(0, ctx.limits.maxRetries ?? 3)),
|
|
147
149
|
jobTimeoutMs: ctx.limits.jobTimeoutMs || 120000,
|
|
148
150
|
maxCostUsd: Number.isFinite(ctx.limits.maxCostUsd) ? ctx.limits.maxCostUsd : null,
|
|
@@ -156,6 +158,7 @@ export async function runQueue(jobs, ctx) {
|
|
|
156
158
|
const results = new Map();
|
|
157
159
|
let calls = 0;
|
|
158
160
|
let estCost = 0;
|
|
161
|
+
let tokensUsed = 0;
|
|
159
162
|
let cooldownUntil = 0;
|
|
160
163
|
let budgetStop = null;
|
|
161
164
|
let persistChain = Promise.resolve();
|
|
@@ -205,6 +208,7 @@ export async function runQueue(jobs, ctx) {
|
|
|
205
208
|
if (ctx.signal && ctx.signal.aborted) return 'cancelled';
|
|
206
209
|
if (now() - started >= L.maxWallMs) return 'budget-exhausted:wall-time';
|
|
207
210
|
if (calls >= L.maxCalls) return 'budget-exhausted:max-calls';
|
|
211
|
+
if (L.maxTokensPerRepo !== null && tokensUsed >= L.maxTokensPerRepo) return 'budget-exhausted:max-tokens-per-repo';
|
|
208
212
|
if (L.maxCostUsd !== null && L.costPerCallUsd !== null && estCost + L.costPerCallUsd > L.maxCostUsd) return 'budget-exhausted:max-cost';
|
|
209
213
|
return null;
|
|
210
214
|
};
|
|
@@ -228,8 +232,9 @@ export async function runQueue(jobs, ctx) {
|
|
|
228
232
|
state.jobs[job.key] = { state: 'running', reason: null, attempts, label: job.label };
|
|
229
233
|
const out = await runReviewOnce({
|
|
230
234
|
pack: loadedJob.pack, entrypoints: loadedJob.entrypoints, manifests: loadedJob.manifests, packageIndexes: job.packageIndexes, packageTexts: loadedJob.packageTexts,
|
|
231
|
-
fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs },
|
|
235
|
+
fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs, anonymizePrompts: L.anonymizePrompts },
|
|
232
236
|
});
|
|
237
|
+
if (out.usage) tokensUsed += (out.usage.inputTokensEstimated || 0) + (out.usage.outputTokensEstimated || 0);
|
|
233
238
|
if (out.state === 'skipped_by_policy') calls--; // nothing was sent
|
|
234
239
|
if (out.state === 'failed' && out.retryable && attempts <= L.maxRetries) {
|
|
235
240
|
// exponential backoff with jitter; the shared cooldown slows every worker, which is what a 429 storm needs
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// * hide a limit. Hitting the symbol or depth cap marks the walk `capped` and lists what was left unvisited.
|
|
12
12
|
// * lose a control. Middleware and every callee are walked, and each visited symbol's controls are collected by the
|
|
13
13
|
// same classifier the packer uses to refuse pruning.
|
|
14
|
-
import { analyzeSymbolText } from './controls.js';
|
|
14
|
+
import { analyzeSymbolText, blankComments } from './controls.js';
|
|
15
15
|
import { cmpCodeUnit } from './canonical.js';
|
|
16
16
|
import { MATERIAL_REASONS } from './graph-schema.js';
|
|
17
17
|
|
|
@@ -36,7 +36,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
36
36
|
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
37
37
|
const { byId, edgesFrom } = indexGraph(graph);
|
|
38
38
|
const linesCache = new Map();
|
|
39
|
-
const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
|
|
39
|
+
const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, blankComments(fileContents[f] || '', f).split('\n')); return linesCache.get(f); };
|
|
40
40
|
|
|
41
41
|
const visited = [];
|
|
42
42
|
const seen = new Set();
|
|
@@ -138,12 +138,69 @@ export function checkEgressForFiles(files, { scanRoot, model }) {
|
|
|
138
138
|
// other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
|
|
139
139
|
if (model.remote && !model.remoteApproved) return { allowed: false, reason: 'remote-endpoint-not-approved (pass --allow-remote or set review.allowRemote after reviewing the egress policy)', file: files[0] || null, evaluated: false };
|
|
140
140
|
for (const file of files) {
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// Fail closed. A policy check that throws (a corrupt policy file, an unreadable path, a bug) is not an allow: the answer is "deny", with the
|
|
142
|
+
// reason, and nothing is sent. This is the only place that decides whether a prompt may leave the machine.
|
|
143
|
+
let d;
|
|
144
|
+
try { d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file }); }
|
|
145
|
+
catch (err) { return { allowed: false, reason: `egress-policy-error: ${String((err && err.message) || err).slice(0, 120)}`, file, evaluated: true, failedClosed: true }; }
|
|
146
|
+
if (!d || d.allowed !== true) return { allowed: false, reason: (d && d.reason) || 'egress-policy-returned-no-decision', file, evaluated: true, ...(d ? {} : { failedClosed: true }) };
|
|
143
147
|
}
|
|
144
148
|
return { allowed: true, evaluated: true };
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
// ---- model output hygiene -------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A model's text is untrusted. It can carry a command for a human or a script to run ("run curl ... | sh to confirm"). Nothing here executes model text, but
|
|
155
|
+
* these strings reach reports, tickets and fix suggestions that people copy from, so shell-looking content is removed and counted, never passed on.
|
|
156
|
+
*/
|
|
157
|
+
const FENCE = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
|
|
158
|
+
const CMD_WORD = '(?:curl|wget|bash|sh|zsh|sudo|su|rm|chmod|chown|nc|ncat|netcat|python3?|node|perl|ruby|php|powershell|pwsh|cmd(?:\\.exe)?|eval|exec|scp|ssh|dd|mkfs|kill|pkill|git\\s+push|npm\\s+(?:i|install|publish)|pip3?\\s+install|docker\\s+run)';
|
|
159
|
+
const INLINE_CMD = new RegExp('`[^`\\n]*\\b' + CMD_WORD + '\\b[^`\\n]*`|\\$\\([^)]*\\)|\\b(?:curl|wget)\\s+[^\\s]+[^\\n]*\\|\\s*(?:sh|bash)\\b|(?:^|\\n)\\s*(?:\\$|#)\\s+' + CMD_WORD + '\\b[^\\n]*', 'gi');
|
|
160
|
+
export function stripUntrustedCommands(text) {
|
|
161
|
+
if (typeof text !== 'string' || !text) return { text: text ?? '', stripped: 0 };
|
|
162
|
+
let n = 0;
|
|
163
|
+
let out = text.replace(FENCE, () => { n++; return '[command block removed]'; });
|
|
164
|
+
out = out.replace(INLINE_CMD, () => { n++; return '[command removed]'; });
|
|
165
|
+
return { text: out, stripped: n };
|
|
166
|
+
}
|
|
167
|
+
function sanitizeReviewStrings(review) {
|
|
168
|
+
let total = 0;
|
|
169
|
+
const walk = (v, key) => {
|
|
170
|
+
if (typeof v === 'string') {
|
|
171
|
+
if (['promptHash', 'responseHash', 'contextHash', 'entrypointId', 'file', 'role', 'kind', 'conclusion', 'likelihood', 'questionPack', 'status'].includes(key)) return v;
|
|
172
|
+
const r = stripUntrustedCommands(v); total += r.stripped; return r.text;
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, key));
|
|
175
|
+
if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) o[k] = walk(x, k); return o; }
|
|
176
|
+
return v;
|
|
177
|
+
};
|
|
178
|
+
const out = walk(review, '');
|
|
179
|
+
if (total) out.untrustedCommandsStripped = total;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Where a suggestion came from and what checked it. Attached to every review so a reader never mistakes a model's words for a finding. */
|
|
184
|
+
function provenanceOf(model, promptHash) {
|
|
185
|
+
let host = null; try { host = model.endpoint ? new URL(model.endpoint).hostname : null; } catch { host = null; }
|
|
186
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
187
|
+
return { producer: 'model', provider: model.provider ?? null, model: model.model ?? null, endpointClass: !model.endpoint ? 'none' : loopback ? 'loopback' : 'remote', promptHash, checkedBy: 'deterministic-citation-verifier', promotedToFinding: false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- prompt anonymization ---------------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** Replace each real file path with a stable pseudonym (`f_<hash>.<ext>`). The mapping stays in memory; only the pseudonyms leave. */
|
|
193
|
+
export function pseudonymizer(files) {
|
|
194
|
+
const toPseudo = new Map(); const toReal = new Map();
|
|
195
|
+
for (const f of [...new Set(files)].sort()) {
|
|
196
|
+
const ext = (/\.[A-Za-z0-9]{1,6}$/.exec(f) || [''])[0];
|
|
197
|
+
const p = `f_${sha256(f).slice(0, 10)}${ext}`;
|
|
198
|
+
toPseudo.set(f, p); toReal.set(p, f);
|
|
199
|
+
}
|
|
200
|
+
const swap = (text, map) => { let t = text; for (const [a, b] of [...map].sort((x, y) => y[0].length - x[0].length)) t = t.split(a).join(b); return t; };
|
|
201
|
+
return { toPseudo: (t) => swap(t, toPseudo), toReal: (t) => swap(t, toReal), size: toPseudo.size };
|
|
202
|
+
}
|
|
203
|
+
|
|
147
204
|
// ---- response parsing and verification ------------------------------------------------------------------------
|
|
148
205
|
|
|
149
206
|
function extractJson(raw) {
|
|
@@ -352,7 +409,12 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
|
|
|
352
409
|
const badRedact = redacted.find(r => !r.ok);
|
|
353
410
|
if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
|
|
354
411
|
|
|
355
|
-
|
|
412
|
+
// Policy-driven anonymization: file paths are replaced by pseudonyms before the prompt is built, and mapped back in the answer before its citations
|
|
413
|
+
// are checked against the real snapshot. The model never sees a real path, and what is hashed is what was sent.
|
|
414
|
+
const anon = limits.anonymizePrompts ? pseudonymizer(files) : null;
|
|
415
|
+
const sendTexts = anon ? redacted.map(r => anon.toPseudo(r.text)) : redacted.map(r => r.text);
|
|
416
|
+
const sendEntrypoints = anon ? entrypoints.map(e => JSON.parse(anon.toPseudo(JSON.stringify(e)))) : entrypoints;
|
|
417
|
+
const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints: sendEntrypoints, packageTexts: sendTexts, contextHash: manifests[0].contextHash });
|
|
356
418
|
const est = estimateTokens(prompt);
|
|
357
419
|
if (est.tokens > limits.maxTokensPerJob) return { state: 'skipped_by_policy', reason: `prompt is about ${est.tokens} tokens, over the ${limits.maxTokensPerJob} per-job limit` };
|
|
358
420
|
|
|
@@ -364,7 +426,9 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
|
|
|
364
426
|
return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
|
|
365
427
|
}
|
|
366
428
|
const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
|
|
367
|
-
const
|
|
429
|
+
const rawReal = anon && typeof raw === 'string' ? anon.toReal(raw) : raw;
|
|
430
|
+
const review0 = verifyReview({ raw: rawReal, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
|
|
431
|
+
const review = { ...sanitizeReviewStrings(review0), modelProvenance: provenanceOf(model, promptHash), ...(anon ? { promptAnonymized: true } : {}) };
|
|
368
432
|
if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
|
|
369
433
|
return { state: 'succeeded', reason: null, review, usage };
|
|
370
434
|
}
|
|
@@ -67,6 +67,17 @@ export function pathIsCoveredByPrefix(path, prefix) {
|
|
|
67
67
|
* - `set = {"x.z"}` does NOT cover "x.y" ✗
|
|
68
68
|
* - `set = {"x.y.z"}` does NOT cover "x.y" ✗ (we don't propagate UP)
|
|
69
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* True iff some path in `set` lies STRICTLY BELOW `path` (`o.cmd` is below `o`). Reading `o` as a whole value (passing it to a function, serialising
|
|
72
|
+
* it) reads every field, so a tainted field taints the whole read; reading `o.safe` does not, because `o.cmd` is not below `o.safe`.
|
|
73
|
+
*/
|
|
74
|
+
export function hasTaintedDescendant(set, path) {
|
|
75
|
+
if (!set || typeof path !== 'string') return false;
|
|
76
|
+
const prefix = `${path}.`;
|
|
77
|
+
for (const p of set) if (typeof p === 'string' && p.startsWith(prefix)) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
export function isCoveredBy(set, path) {
|
|
71
82
|
if (!set || typeof path !== 'string') return false;
|
|
72
83
|
if (set.has(path)) return true;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Dynamic features the static analysis cannot resolve, reported instead of silently approximated (E2-03).
|
|
2
|
+
//
|
|
3
|
+
// Where the engine meets one of these it takes the sound direction (a write through an unknown key taints the whole container, a read through
|
|
4
|
+
// an unknown key reads the whole container) and loses precision. That is the right trade, but it must be visible: a result that depended on it is
|
|
5
|
+
// less precise than one that did not, and a reviewer should be able to see which functions contain such constructs.
|
|
6
|
+
//
|
|
7
|
+
// Pure: reads the Layer-1 IR only.
|
|
8
|
+
const LIMIT_KINDS = Object.freeze({
|
|
9
|
+
'computed-write': 'a write through a key that is not a literal (`o[k] = v`): the engine taints the whole container `o`, so clean sibling fields read afterwards are over-approximated as tainted',
|
|
10
|
+
'computed-read': 'a read through a key that is not a literal (`o[k]`): the engine cannot tell which field is read, so any tainted field of `o` reaches it',
|
|
11
|
+
'eval-like': 'code built at run time (`eval`, `new Function`, `setTimeout` with a string): its content is not analysed',
|
|
12
|
+
'dynamic-require': 'a module name computed at run time (`require(name)`, `import(name)`): the callee is unknown, so the call stays unresolved',
|
|
13
|
+
'reflective-call': 'a call through reflection (`Reflect.apply`, `fn.call/apply` on a computed target, `obj[name]()`): the callee is unknown',
|
|
14
|
+
'getter-or-proxy': 'property access that may run user code (`Proxy`, `Object.defineProperty` accessors): reads and writes may have effects the engine does not model',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const expressions = (e, acc = []) => {
|
|
18
|
+
if (!e || typeof e !== 'object') return acc;
|
|
19
|
+
acc.push(e);
|
|
20
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value']) if (e[k] && typeof e[k] === 'object') expressions(e[k], acc);
|
|
21
|
+
for (const k of ['args', 'parts', 'elements', 'props', 'branches']) if (Array.isArray(e[k])) for (const x of e[k]) expressions(x && x.value && !x.kind ? x.value : x, acc);
|
|
22
|
+
return acc;
|
|
23
|
+
};
|
|
24
|
+
const nameOf = (c) => (typeof c === 'string' ? c : c && c.kind === 'ident' ? c.name : c && c.kind === 'member' ? `${nameOf(c.object)}.${c.prop}` : '');
|
|
25
|
+
|
|
26
|
+
/** @returns {{kind:string, line:number, detail:string, effect:string}[]} */
|
|
27
|
+
function limitsOfFunction(fn) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const add = (kind, line, detail) => out.push({ kind, line: Number.isInteger(line) ? line : (fn.line || 0), detail, effect: LIMIT_KINDS[kind] });
|
|
30
|
+
for (const n of Object.values(fn.cfg?.nodes || {})) {
|
|
31
|
+
if (n.kind === 'assign') {
|
|
32
|
+
if (typeof n.target === 'string' && /\.\*$/.test(n.target)) add('computed-write', n.line, n.target);
|
|
33
|
+
for (const x of expressions(n.source)) inspect(x, n.line, add);
|
|
34
|
+
} else if (n.kind === 'call') {
|
|
35
|
+
inspect({ kind: 'call', callee: n.callee, args: n.args }, n.line, add);
|
|
36
|
+
for (const a of n.args || []) for (const x of expressions(a)) inspect(x, n.line, add);
|
|
37
|
+
} else if (n.kind === 'return' && n.value) for (const x of expressions(n.value)) inspect(x, n.line, add);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inspect(x, line, add) {
|
|
43
|
+
if (x.kind === 'member' && x.prop === '*') add('computed-read', line, `${nameOf(x.object)}[…]`);
|
|
44
|
+
if (x.kind === 'call') {
|
|
45
|
+
const c = nameOf(x.callee);
|
|
46
|
+
if (/^(?:eval|Function|new Function|setTimeout|setInterval)$/.test(c)) { if (c === 'eval' || c === 'Function' || c === 'new Function' || (x.args && x.args[0] && x.args[0].kind !== 'function-value' && x.args[0].kind !== 'ident' && x.args[0].kind !== 'literal')) add('eval-like', line, c); else if ((c === 'setTimeout' || c === 'setInterval') && x.args && x.args[0] && x.args[0].kind === 'literal' && typeof x.args[0].value === 'string') add('eval-like', line, `${c}(string)`); }
|
|
47
|
+
if (c === 'require' && x.args && x.args[0] && x.args[0].kind !== 'literal') add('dynamic-require', line, 'require(<expression>)');
|
|
48
|
+
if (/^Reflect\.(?:apply|construct|get|set)$/.test(c)) add('reflective-call', line, c);
|
|
49
|
+
if (x.callee && x.callee.kind === 'member' && x.callee.prop === '*') add('reflective-call', line, `${nameOf(x.callee.object)}[…](…)`);
|
|
50
|
+
if (/^(?:Object\.defineProperty|Object\.defineProperties|Object\.setPrototypeOf)$/.test(c) || c === 'Proxy' || c === 'new Proxy') add('getter-or-proxy', line, c);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{perFile:Record<string,{functions:object[]}>}} projectIR
|
|
56
|
+
* @returns {{file:string, qid:string, function:string, kind:string, line:number, detail:string, effect:string}[]} sorted, stable
|
|
57
|
+
*/
|
|
58
|
+
export function analysisLimits(projectIR) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const file of Object.keys(projectIR?.perFile || {}).sort()) {
|
|
61
|
+
for (const fn of projectIR.perFile[file].functions || []) {
|
|
62
|
+
for (const l of limitsOfFunction(fn)) out.push({ file, qid: String(fn.qid).replace(/@\d+$/, ''), function: fn.name, ...l });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.kind < b.kind ? -1 : 1)));
|
|
66
|
+
}
|