@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,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,8 @@
|
|
|
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
|
+
import { cmpCodeUnit } from './canonical.js';
|
|
15
16
|
import { MATERIAL_REASONS } from './graph-schema.js';
|
|
16
17
|
|
|
17
18
|
export const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
|
|
@@ -35,7 +36,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
35
36
|
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
36
37
|
const { byId, edgesFrom } = indexGraph(graph);
|
|
37
38
|
const linesCache = new Map();
|
|
38
|
-
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); };
|
|
39
40
|
|
|
40
41
|
const visited = [];
|
|
41
42
|
const seen = new Set();
|
|
@@ -70,7 +71,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// middleware runs before the handler: it is enqueued first and ordered at line -1
|
|
73
|
-
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) ||
|
|
74
|
+
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || cmpCodeUnit(a.name, b.name))) {
|
|
74
75
|
if (mw.symbolId && byId.has(mw.symbolId)) enqueue(mw.symbolId, 0, mw.inherited ? 'inherited-middleware' : 'middleware', -1, [mw.symbolId], mw.resolution === 'resolved' ? 'exact' : 'name-match');
|
|
75
76
|
}
|
|
76
77
|
if (root.symbolId) {
|
|
@@ -163,11 +164,14 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
|
|
|
163
164
|
for (const dim of PROTECTION_DIMENSIONS) {
|
|
164
165
|
const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
|
|
165
166
|
const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
|
|
166
|
-
|
|
167
|
+
// A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
|
|
168
|
+
const effective = (f) => f.rejectionSeen !== false;
|
|
169
|
+
const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
|
|
167
170
|
const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
|
|
168
171
|
let state; let why;
|
|
169
172
|
if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
|
|
170
173
|
else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
|
|
174
|
+
else if (ctl.length && ctl.every(f => !effective(f))) { state = 'evaluated_and_ineffective'; why = 'a check is called but nothing in view acts on its answer'; }
|
|
171
175
|
else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
|
|
172
176
|
else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
|
|
173
177
|
else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
|
|
@@ -175,6 +179,8 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
|
|
|
175
179
|
else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
|
|
176
180
|
dims[dim] = {
|
|
177
181
|
state, why, firstSensitiveLine: firstSens,
|
|
182
|
+
// How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
|
|
183
|
+
strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
|
|
178
184
|
evidence: ctl.slice(0, 6).map(f => ({ file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, order: firstSens === null ? 'no-sensitive-op' : f.effLine < firstSens ? 'before-sensitive' : f.effLine > firstSens ? 'after-sensitive' : 'same-step', rejectionSeen: f.rejectionSeen })),
|
|
179
185
|
};
|
|
180
186
|
}
|
|
@@ -20,14 +20,14 @@ import { redactPayload } from '../egress/redact.js';
|
|
|
20
20
|
import { evaluateEgress } from '../egress/policy.js';
|
|
21
21
|
import { resolveProvider } from '../llm-validator/providers.js';
|
|
22
22
|
import { resolveLlmInvokeWithDecision } from '../discovery/llm-invoke.js';
|
|
23
|
-
import { hashOf, safeRelPath, sha256 } from './canonical.js';
|
|
23
|
+
import { hashOf, safeLabel, safeRelPath, sha256 } from './canonical.js';
|
|
24
24
|
import { estimateTokens } from './packer.js';
|
|
25
25
|
import { REVIEW_SCHEMA_VERSION, packRef } from './prompts/packs.js';
|
|
26
26
|
|
|
27
27
|
export const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
|
|
28
28
|
export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
|
|
29
29
|
const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
|
|
30
|
-
const MAX_CITATION_SPAN =
|
|
30
|
+
const MAX_CITATION_SPAN = 60;
|
|
31
31
|
const MAX_STR = 1200;
|
|
32
32
|
const MAX_ITEMS = 24;
|
|
33
33
|
|
|
@@ -65,8 +65,10 @@ export function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash
|
|
|
65
65
|
for (const g of pack.guidance) lines.push(`- ${g}`);
|
|
66
66
|
lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
|
|
67
67
|
entrypoints.forEach((ep, i) => {
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
|
|
69
|
+
// into the instruction text around it.
|
|
70
|
+
const fenced = fenceUntrusted(`ENTRY POINT LABEL (data): ${safeLabel(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
|
|
71
|
+
lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
|
|
70
72
|
lines.push(fenced.text, '');
|
|
71
73
|
});
|
|
72
74
|
const prompt = lines.join('\n');
|
|
@@ -102,25 +104,103 @@ export function redactPackageText(text, { scanRoot }) {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
|
|
105
|
-
export function
|
|
107
|
+
export function isLoopbackEndpoint(endpoint) {
|
|
108
|
+
if (!endpoint) return false;
|
|
109
|
+
const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
|
|
110
|
+
if (!parts.length) return false;
|
|
111
|
+
return parts.every(u => {
|
|
112
|
+
try {
|
|
113
|
+
const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
114
|
+
return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
115
|
+
} catch { return false; }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function describeModel({ allowRemote = false } = {}) {
|
|
120
|
+
let m;
|
|
106
121
|
try {
|
|
107
122
|
const r = resolveProvider({ role: 'hunt' });
|
|
108
|
-
if (r.ok)
|
|
123
|
+
if (r.ok) m = { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
|
|
109
124
|
} catch { /* fall through */ }
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
if (!m) {
|
|
126
|
+
const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
|
|
127
|
+
m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
|
|
128
|
+
}
|
|
129
|
+
// `remote` is true for any endpoint that is not loopback; an endpoint-less provider is judged remote too, since its host is not visible here.
|
|
130
|
+
const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
|
|
131
|
+
return { ...m, remote, remoteApproved: allowRemote === true };
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
/** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
|
|
115
135
|
export function checkEgressForFiles(files, { scanRoot, model }) {
|
|
116
136
|
if (!model.endpoint) return { allowed: true, evaluated: false };
|
|
137
|
+
// Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
|
|
138
|
+
// other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
|
|
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 };
|
|
117
140
|
for (const file of files) {
|
|
118
|
-
|
|
119
|
-
|
|
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 }) };
|
|
120
147
|
}
|
|
121
148
|
return { allowed: true, evaluated: true };
|
|
122
149
|
}
|
|
123
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
|
+
|
|
124
204
|
// ---- response parsing and verification ------------------------------------------------------------------------
|
|
125
205
|
|
|
126
206
|
function extractJson(raw) {
|
|
@@ -149,14 +229,29 @@ function normalizeCitations(list) {
|
|
|
149
229
|
return { items, malformed };
|
|
150
230
|
}
|
|
151
231
|
|
|
152
|
-
|
|
232
|
+
const CLAIM_STOPWORDS = new Set(['this', 'that', 'with', 'from', 'which', 'where', 'when', 'does', 'have', 'been', 'would', 'could', 'should', 'into', 'over', 'only', 'also', 'shows', 'show', 'line', 'lines', 'code', 'file', 'function']);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
|
|
236
|
+
* meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
|
|
237
|
+
* real lines while describing something else, and an empty claim, from being accepted as evidence.
|
|
238
|
+
*/
|
|
239
|
+
export function claimGrounded(claim, citedText) {
|
|
240
|
+
const hay = String(citedText).toLowerCase();
|
|
241
|
+
const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
|
|
242
|
+
return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
|
|
153
246
|
export function resolveCitation(c, segments, fileContents) {
|
|
154
247
|
if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
|
|
155
248
|
const text = fileContents[c.file];
|
|
156
249
|
if (typeof text !== 'string') return 'file-not-in-snapshot';
|
|
157
|
-
|
|
250
|
+
const fileLines = text.split('\n');
|
|
251
|
+
if (c.endLine > fileLines.length) return 'beyond-end-of-file';
|
|
158
252
|
const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
|
|
159
|
-
|
|
253
|
+
if (!inside) return 'outside-provided-context';
|
|
254
|
+
return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
|
|
160
255
|
}
|
|
161
256
|
|
|
162
257
|
function decline(base, reasons, extra = {}) {
|
|
@@ -245,13 +340,24 @@ export function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes
|
|
|
245
340
|
out.downgradedFrom = 'no_issue_observed';
|
|
246
341
|
out.conclusion = 'insufficient_evidence';
|
|
247
342
|
out.downgradeReason = 'no-mitigation-cited';
|
|
343
|
+
} else if (dim) {
|
|
344
|
+
// The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
|
|
345
|
+
// a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
|
|
346
|
+
const recognised = mit.items.some(c => manifests.some(m => ((m.protection[dim] || {}).evidence || []).some(e => e.file === c.file && e.line >= c.startLine && e.line <= c.endLine)));
|
|
347
|
+
const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
|
|
348
|
+
if (!recognised || onlyInferred) {
|
|
349
|
+
out.downgradedFrom = 'no_issue_observed';
|
|
350
|
+
out.conclusion = 'insufficient_evidence';
|
|
351
|
+
out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
|
|
352
|
+
}
|
|
248
353
|
}
|
|
249
354
|
}
|
|
250
355
|
|
|
251
356
|
// how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
|
|
252
357
|
if (dim) {
|
|
253
358
|
const states = manifests.map(m => m.protection[dim]?.state);
|
|
254
|
-
|
|
359
|
+
// `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
|
|
360
|
+
const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
|
|
255
361
|
const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
|
|
256
362
|
out.deterministicProtection = { dimension: dim, states };
|
|
257
363
|
out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
|
|
@@ -303,7 +409,12 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
|
|
|
303
409
|
const badRedact = redacted.find(r => !r.ok);
|
|
304
410
|
if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
|
|
305
411
|
|
|
306
|
-
|
|
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 });
|
|
307
418
|
const est = estimateTokens(prompt);
|
|
308
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` };
|
|
309
420
|
|
|
@@ -315,7 +426,9 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
|
|
|
315
426
|
return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
|
|
316
427
|
}
|
|
317
428
|
const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
|
|
318
|
-
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 } : {}) };
|
|
319
432
|
if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
|
|
320
433
|
return { state: 'succeeded', reason: null, review, usage };
|
|
321
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
|
+
}
|