@clear-capabilities/agentic-security-scanner 0.172.0 → 0.172.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/1336.index.js +101 -101
- package/dist/7405.index.js +23 -6
- package/dist/9382.index.js +205 -39
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/cli.js +14 -10
- package/src/context-intelligence/config.js +2 -1
- package/src/context-intelligence/controls.js +59 -9
- 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/reachability.js +8 -2
- package/src/context-intelligence/review.js +61 -12
package/dist/9382.index.js
CHANGED
|
@@ -155,15 +155,42 @@ function loadHieMetadata(rootReal, hieDir, { fileContents }) {
|
|
|
155
155
|
return { status: 'ok', tool: doc.tool, compiler: doc.compiler, declarations, references, exportHash: (0,canonical/* hashOf */.qQ)({ declarations, references }) };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function uncorroboratedReferences(graph, references, fileContents) {
|
|
159
|
+
const byName = new Map();
|
|
160
|
+
for (const sym of graph.symbols) {
|
|
161
|
+
if (sym.kind !== 'function' || sym.id.includes('::<anon>@')) continue;
|
|
162
|
+
if (!byName.has(sym.name)) byName.set(sym.name, []);
|
|
163
|
+
byName.get(sym.name).push(sym);
|
|
164
|
+
}
|
|
165
|
+
const bad = [];
|
|
166
|
+
for (const r of references) {
|
|
167
|
+
const owners = byName.get(r.from) || [];
|
|
168
|
+
if (owners.length !== 1) continue; // an ambiguous or unknown name is already counted as unmatched and adds nothing
|
|
169
|
+
const sym = owners[0];
|
|
170
|
+
const text = fileContents[sym.file];
|
|
171
|
+
const callee = String(r.to).split(/[.:]/).pop();
|
|
172
|
+
const lineText = typeof text === 'string' ? text.split('\n')[r.line - 1] : undefined;
|
|
173
|
+
const inside = r.line >= sym.startLine && r.line <= sym.endLine;
|
|
174
|
+
if (!inside || lineText === undefined || !callee || !lineText.includes(callee)) bad.push(`${r.from}->${r.to}@${r.line}`);
|
|
175
|
+
}
|
|
176
|
+
return bad;
|
|
177
|
+
}
|
|
178
|
+
|
|
158
179
|
/**
|
|
159
180
|
* Merge an imported export into the static graph. Returns { graph, semantic } where `semantic` carries the provenance
|
|
160
181
|
* and the static-versus-compiler difference report.
|
|
161
182
|
*/
|
|
162
183
|
function mergeHieIntoGraph(graph, meta, fileContents) {
|
|
163
|
-
void fileContents;
|
|
164
184
|
if (meta.status !== 'ok') {
|
|
165
185
|
return { graph, semantic: { source: ['static-ir'], status: meta.status, reason: meta.reason || null, detail: meta.detail || null, diff: null } };
|
|
166
186
|
}
|
|
187
|
+
// The export is untrusted even when its file hashes match: it is checked into the repository it describes. A reference
|
|
188
|
+
// is accepted only if the line it names, inside the declaration it names, actually mentions the callee. One that does
|
|
189
|
+
// not degrades the whole import, so a crafted export cannot add edges that the source does not contain.
|
|
190
|
+
const uncorroborated = uncorroboratedReferences(graph, meta.references, fileContents || {});
|
|
191
|
+
if (uncorroborated.length) {
|
|
192
|
+
return { graph, semantic: { source: ['static-ir'], status: 'degraded', reason: 'reference-not-in-source', detail: uncorroborated.slice(0, 5).join(', '), diff: null } };
|
|
193
|
+
}
|
|
167
194
|
const idsByName = new Map();
|
|
168
195
|
for (const s of graph.symbols) {
|
|
169
196
|
if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
|
|
@@ -414,10 +441,13 @@ async function adjudicate(candidates, opts = {}) {
|
|
|
414
441
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
415
442
|
|
|
416
443
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
444
|
+
/* harmony export */ IW: () => (/* binding */ safeLabel),
|
|
417
445
|
/* harmony export */ Qz: () => (/* binding */ safeRelPath),
|
|
446
|
+
/* harmony export */ bt: () => (/* binding */ cleanForTerminal),
|
|
418
447
|
/* harmony export */ dj: () => (/* binding */ canonicalJson),
|
|
419
448
|
/* harmony export */ fQ: () => (/* binding */ normalizeText),
|
|
420
449
|
/* harmony export */ iq: () => (/* binding */ sliceLines),
|
|
450
|
+
/* harmony export */ kH: () => (/* binding */ cmpCodeUnit),
|
|
421
451
|
/* harmony export */ qQ: () => (/* binding */ hashOf),
|
|
422
452
|
/* harmony export */ sc: () => (/* binding */ sha256),
|
|
423
453
|
/* harmony export */ v6: () => (/* binding */ readRepoFile),
|
|
@@ -443,6 +473,31 @@ function sha256(text) {
|
|
|
443
473
|
return node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(text).digest('hex');
|
|
444
474
|
}
|
|
445
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
|
|
478
|
+
* could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
|
|
479
|
+
*/
|
|
480
|
+
function cmpCodeUnit(a, b) {
|
|
481
|
+
const x = String(a); const y = String(b);
|
|
482
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
|
|
486
|
+
// repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
|
|
487
|
+
// in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
|
|
488
|
+
const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
489
|
+
|
|
490
|
+
/** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
|
|
491
|
+
function cleanForTerminal(text) {
|
|
492
|
+
return String(text).replace(UNSAFE_DISPLAY, '?');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
|
|
496
|
+
function safeLabel(text, max = 120) {
|
|
497
|
+
const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
|
|
498
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
499
|
+
}
|
|
500
|
+
|
|
446
501
|
/** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
|
|
447
502
|
function normalizeText(text) {
|
|
448
503
|
return String(text).replace(/\r\n?/g, '\n');
|
|
@@ -762,25 +817,29 @@ var state_dir = __webpack_require__(31174);
|
|
|
762
817
|
|
|
763
818
|
|
|
764
819
|
|
|
820
|
+
|
|
765
821
|
const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
|
|
766
822
|
const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
|
|
767
823
|
const num = (v) => (v === undefined || v === true ? undefined : Number(v));
|
|
768
824
|
|
|
825
|
+
/** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
|
|
826
|
+
function say(text) { console.log((0,canonical/* cleanForTerminal */.bt)(text)); }
|
|
827
|
+
|
|
769
828
|
function out(format, text, obj) {
|
|
770
|
-
if (format === 'json')
|
|
771
|
-
else if (format === 'jsonl')
|
|
772
|
-
else
|
|
829
|
+
if (format === 'json') say(JSON.stringify(obj, null, 2));
|
|
830
|
+
else if (format === 'jsonl') say(JSON.stringify(obj));
|
|
831
|
+
else say(text);
|
|
773
832
|
}
|
|
774
833
|
|
|
775
834
|
function usage() {
|
|
776
|
-
|
|
835
|
+
say([
|
|
777
836
|
'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
|
|
778
837
|
'',
|
|
779
838
|
' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
|
|
780
839
|
' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
|
|
781
840
|
' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
|
|
782
841
|
' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
|
|
783
|
-
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--concurrency N]',
|
|
842
|
+
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
|
|
784
843
|
' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
|
|
785
844
|
' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
|
|
786
845
|
' status Coverage so far: entry points, bundles, review jobs',
|
|
@@ -802,7 +861,7 @@ async function doBuild(root, args, { write = true } = {}) {
|
|
|
802
861
|
if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
|
|
803
862
|
const events = [];
|
|
804
863
|
const jsonl = flag(args, 'format') === 'jsonl';
|
|
805
|
-
const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl)
|
|
864
|
+
const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
|
|
806
865
|
if (!built.ok) return { ok: false, built, config, problems };
|
|
807
866
|
let persisted = null;
|
|
808
867
|
if (write) persisted = persistBuild(root, built, config);
|
|
@@ -895,7 +954,7 @@ async function cmdInspect(root, args, format) {
|
|
|
895
954
|
` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
|
|
896
955
|
];
|
|
897
956
|
if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
|
|
898
|
-
|
|
957
|
+
say(lines.join('\n'));
|
|
899
958
|
return 0;
|
|
900
959
|
}
|
|
901
960
|
|
|
@@ -995,7 +1054,7 @@ async function cmdExport(root, args, format) {
|
|
|
995
1054
|
if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
|
|
996
1055
|
const r = (0,store/* exportBundle */.Nl)(root, String(h));
|
|
997
1056
|
if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
|
|
998
|
-
|
|
1057
|
+
say(JSON.stringify(r.export, null, 2));
|
|
999
1058
|
return 0;
|
|
1000
1059
|
}
|
|
1001
1060
|
|
|
@@ -1043,7 +1102,7 @@ async function cmdReview(root, args, format) {
|
|
|
1043
1102
|
const r = await doBuild(root, args, { write: true });
|
|
1044
1103
|
if (!r.ok) { out(format, `context review: build failed (${r.built.reason})`, { ok: false, reason: r.built.reason }); return r.built.reason === 'too-many-files' ? 2 : 1; }
|
|
1045
1104
|
const { built } = r;
|
|
1046
|
-
const model = (0,review/* describeModel */.Vy)();
|
|
1105
|
+
const model = (0,review/* describeModel */.Vy)({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
|
|
1047
1106
|
const { jobs, droppedByJobLimit } = (0,queue/* planJobs */.KR)({ bundles: built.bundles, packs, model, maxJobs: Math.min(queue/* HARD_LIMITS */.BS.maxJobs, num(flag(args, 'max-jobs')) || queue/* HARD_LIMITS */.BS.maxJobs) });
|
|
1048
1107
|
const loadJob = loadJobFrom(built, packs);
|
|
1049
1108
|
const ctxBase = { root: built.root, fileContents: built.fileContents, model };
|
|
@@ -1081,7 +1140,7 @@ async function cmdReview(root, args, format) {
|
|
|
1081
1140
|
try {
|
|
1082
1141
|
result = await (0,queue/* runQueue */.rj)(jobs, {
|
|
1083
1142
|
...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
|
|
1084
|
-
onEvent: jsonl ? (e) =>
|
|
1143
|
+
onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
|
|
1085
1144
|
limits: {
|
|
1086
1145
|
concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
|
|
1087
1146
|
maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
|
|
@@ -1199,7 +1258,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
1199
1258
|
maxFiles: 3000,
|
|
1200
1259
|
semanticMetadata: { mode: 'off', haskellHieDir: null },
|
|
1201
1260
|
context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
|
|
1202
|
-
review: { enabled: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
1261
|
+
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
1203
1262
|
replay: { contentAddressed: true, retention: 'inherit' },
|
|
1204
1263
|
});
|
|
1205
1264
|
|
|
@@ -1240,6 +1299,7 @@ function normalizeConfig(raw) {
|
|
|
1240
1299
|
},
|
|
1241
1300
|
review: {
|
|
1242
1301
|
enabled: rv.enabled === true,
|
|
1302
|
+
allowRemote: rv.allowRemote === true,
|
|
1243
1303
|
questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
|
|
1244
1304
|
concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
|
|
1245
1305
|
maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
|
|
@@ -1278,7 +1338,7 @@ function loadConfig(root) {
|
|
|
1278
1338
|
/* harmony export */ kl: () => (/* binding */ analyzeSymbolText),
|
|
1279
1339
|
/* harmony export */ qr: () => (/* binding */ dependencyParams)
|
|
1280
1340
|
/* harmony export */ });
|
|
1281
|
-
/* unused harmony exports CONTROL_KINDS, isSecuritySignificant */
|
|
1341
|
+
/* unused harmony exports CONTROL_KINDS, ownerBinding, isSecuritySignificant */
|
|
1282
1342
|
//
|
|
1283
1343
|
// Security-relevant facts inside ONE symbol's source: authentication / authorization / ownership / tenant controls,
|
|
1284
1344
|
// and the sensitive operations they have to precede.
|
|
@@ -1328,7 +1388,46 @@ function signatureText(lines, declLine, maxLines = 12) {
|
|
|
1328
1388
|
for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
|
|
1329
1389
|
return t;
|
|
1330
1390
|
}
|
|
1331
|
-
|
|
1391
|
+
// An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
|
|
1392
|
+
// qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
|
|
1393
|
+
const OWNER_KEY = '\\w*?(?:[Oo]wner|[Tt]enant|[Oo]rg(?:ani[sz]ation)?|[Aa]ccount|[Cc]reated_?[Bb]y|[Aa]uthor|[Tt]eam|[Ww]orkspace)(?:_?(?:[Ii]d|ID))?\\b|[Uu]ser_?(?:[Ii]d|ID)\\b';
|
|
1394
|
+
const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
|
|
1395
|
+
const TENANT_WORD = /tenant|org|workspace|team|account/i;
|
|
1396
|
+
// a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
|
|
1397
|
+
const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
|
|
1398
|
+
// an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
|
|
1399
|
+
const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
|
|
1400
|
+
const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
|
|
1401
|
+
const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
|
|
1405
|
+
* same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
|
|
1406
|
+
* `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
|
|
1407
|
+
* principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
|
|
1408
|
+
*/
|
|
1409
|
+
function ownerBinding(text, extra = null) {
|
|
1410
|
+
let best = null;
|
|
1411
|
+
const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
|
|
1412
|
+
const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
|
|
1413
|
+
for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
|
|
1414
|
+
// the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
|
|
1415
|
+
const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
|
|
1416
|
+
if (!OWNER_KEY_RE.test(forKey)) continue;
|
|
1417
|
+
const pr = principalIn(clause, extra);
|
|
1418
|
+
if (pr) take(pr, TENANT_WORD.test(clause));
|
|
1419
|
+
}
|
|
1420
|
+
for (const m of text.match(STRING_LITERAL) || []) {
|
|
1421
|
+
const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
|
|
1422
|
+
if (placeholder) {
|
|
1423
|
+
const pr = principalIn(text, extra);
|
|
1424
|
+
if (pr) take(pr, TENANT_WORD.test(m));
|
|
1425
|
+
}
|
|
1426
|
+
if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
|
|
1427
|
+
}
|
|
1428
|
+
return best;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1332
1431
|
const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
|
|
1333
1432
|
const REJECT = /(?:\breturn\b|\bthrow\b|\braise\b|\babort\b|\bexit\b|\b40[0-9]\b|\bforbidden\b|\bunauthori[sz]ed\b|\bHttpError\b|\breject\b|\bnot_?allowed\b|\bdeny\b|\bdenied\b|throwError|\bfinish\b|\bhalt\b|\bPermissionDenied\b|\bAccessDenied\b|status\()/i;
|
|
1334
1433
|
|
|
@@ -1339,6 +1438,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
|
|
|
1339
1438
|
const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
|
|
1340
1439
|
const HS_SENSITIVE = /(?:^|\.)(?:runDB|selectList|selectFirst|getBy|getJust|insert_?|insertEntity|update|updateWhere|delete|deleteWhere|replace|upsert|query|query_|execute|execute_|readFile|writeFile|callProcess|readProcess)$|^(?:Database\.|System\.Process\.|System\.Directory\.)/;
|
|
1341
1440
|
|
|
1441
|
+
// Names that answer a question rather than enforce a rule.
|
|
1442
|
+
const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
|
|
1443
|
+
function bareCallStatement(trimmedLine, name) {
|
|
1444
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1445
|
+
return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1342
1448
|
const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
|
|
1343
1449
|
|
|
1344
1450
|
function kindOfAuthName(name) {
|
|
@@ -1413,8 +1519,12 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1413
1519
|
}
|
|
1414
1520
|
for (const name of calls) {
|
|
1415
1521
|
if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1522
|
+
const lastSeg = name.split(/[.:]/).pop();
|
|
1523
|
+
// A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
|
|
1524
|
+
// so it guards nothing, whatever the lines around it say.
|
|
1525
|
+
const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
|
|
1526
|
+
const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
|
|
1527
|
+
push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: discarded ? 'call-name-result-discarded' : 'call-name', rejectionSeen: rej, text: clip(raw) });
|
|
1418
1528
|
}
|
|
1419
1529
|
const last = name.split(/[.:]/).pop();
|
|
1420
1530
|
const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
|
|
@@ -1430,9 +1540,9 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1430
1540
|
push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
|
|
1431
1541
|
// a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
|
|
1432
1542
|
const stmt = statementAt(lines, ln);
|
|
1433
|
-
const pr = sub.startsWith('data-')
|
|
1543
|
+
const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
|
|
1434
1544
|
if (pr) {
|
|
1435
|
-
push({ line: ln, category: 'control', kind:
|
|
1545
|
+
push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
|
|
1436
1546
|
}
|
|
1437
1547
|
}
|
|
1438
1548
|
}
|
|
@@ -1440,17 +1550,17 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1440
1550
|
const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
|
|
1441
1551
|
push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
|
|
1442
1552
|
const stmt = statementAt(lines, ln);
|
|
1443
|
-
const pr =
|
|
1553
|
+
const pr = ownerBinding(stmt, extraPrincipal);
|
|
1444
1554
|
if (pr) {
|
|
1445
|
-
push({ line: ln, category: 'control', kind:
|
|
1555
|
+
push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
|
|
1446
1556
|
}
|
|
1447
1557
|
}
|
|
1448
1558
|
|
|
1449
1559
|
// a resource field compared with the principal: the ownership check itself
|
|
1450
|
-
const cmpPr = COMPARE.test(raw) &&
|
|
1560
|
+
const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
|
|
1451
1561
|
if (cmpPr) {
|
|
1452
1562
|
const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
|
|
1453
|
-
push({ line: ln, category: 'control', kind:
|
|
1563
|
+
push({ line: ln, category: 'control', kind: cmpPr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr.level === 'qualified' ? 'observed' : 'inferred', basis: cmpPr.level === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
|
|
1454
1564
|
}
|
|
1455
1565
|
}
|
|
1456
1566
|
// A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
|
|
@@ -1805,7 +1915,7 @@ async function collectEntrypoints({ fileContents, graph, config = {} }) {
|
|
|
1805
1915
|
const n = (ord.get(stem) || 0) + 1;
|
|
1806
1916
|
ord.set(stem, n);
|
|
1807
1917
|
r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
|
|
1808
|
-
r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}
|
|
1918
|
+
r.label = (0,_canonical_js__WEBPACK_IMPORTED_MODULE_0__/* .safeLabel */ .IW)(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
|
|
1809
1919
|
void hname;
|
|
1810
1920
|
let disposition;
|
|
1811
1921
|
const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
|
|
@@ -3134,7 +3244,8 @@ function importBatch(root, batchId, resultsText, ctx) {
|
|
|
3134
3244
|
/* harmony export */ });
|
|
3135
3245
|
/* unused harmony exports DEFAULT_LIMITS, PROTECTION_DIMENSIONS */
|
|
3136
3246
|
/* harmony import */ var _controls_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17202);
|
|
3137
|
-
/* harmony import */ var
|
|
3247
|
+
/* harmony import */ var _canonical_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44652);
|
|
3248
|
+
/* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44287);
|
|
3138
3249
|
//
|
|
3139
3250
|
// Deterministic, bounded reachability from an entry point's handler.
|
|
3140
3251
|
//
|
|
@@ -3151,6 +3262,7 @@ function importBatch(root, batchId, resultsText, ctx) {
|
|
|
3151
3262
|
|
|
3152
3263
|
|
|
3153
3264
|
|
|
3265
|
+
|
|
3154
3266
|
const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
|
|
3155
3267
|
|
|
3156
3268
|
function indexGraph(graph) {
|
|
@@ -3207,7 +3319,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
3207
3319
|
}
|
|
3208
3320
|
|
|
3209
3321
|
// middleware runs before the handler: it is enqueued first and ordered at line -1
|
|
3210
|
-
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) ||
|
|
3322
|
+
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || (0,_canonical_js__WEBPACK_IMPORTED_MODULE_1__/* .cmpCodeUnit */ .kH)(a.name, b.name))) {
|
|
3211
3323
|
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');
|
|
3212
3324
|
}
|
|
3213
3325
|
if (root.symbolId) {
|
|
@@ -3239,7 +3351,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
3239
3351
|
if (e.to) {
|
|
3240
3352
|
if (cur.depth + 1 > lim.maxDepth) { capped = true; continue; }
|
|
3241
3353
|
enqueue(e.to, cur.depth + 1, 'callee', via, [...cur.path, e.to], 'exact');
|
|
3242
|
-
} else if (
|
|
3354
|
+
} else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_2__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
|
|
3243
3355
|
unknowns.push({ type: e.kind, site: `${sym.file}:${e.line}`, from: cur.symbolId, calleeName: e.calleeName, reason: e.unresolvedReason, material: true, candidates: e.candidates });
|
|
3244
3356
|
// Candidates are shown to the model as CANDIDATES (low confidence); the unknown stays material regardless.
|
|
3245
3357
|
if (e.candidates.length && e.candidates.length <= 3 && cur.depth + 1 <= lim.maxDepth) {
|
|
@@ -3300,11 +3412,14 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3300
3412
|
for (const dim of PROTECTION_DIMENSIONS) {
|
|
3301
3413
|
const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
|
|
3302
3414
|
const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
|
|
3303
|
-
|
|
3415
|
+
// A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
|
|
3416
|
+
const effective = (f) => f.rejectionSeen !== false;
|
|
3417
|
+
const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
|
|
3304
3418
|
const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
|
|
3305
3419
|
let state; let why;
|
|
3306
3420
|
if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
|
|
3307
3421
|
else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
|
|
3422
|
+
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'; }
|
|
3308
3423
|
else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
|
|
3309
3424
|
else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
|
|
3310
3425
|
else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
|
|
@@ -3312,6 +3427,8 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3312
3427
|
else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
|
|
3313
3428
|
dims[dim] = {
|
|
3314
3429
|
state, why, firstSensitiveLine: firstSens,
|
|
3430
|
+
// How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
|
|
3431
|
+
strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
|
|
3315
3432
|
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 })),
|
|
3316
3433
|
};
|
|
3317
3434
|
}
|
|
@@ -3333,7 +3450,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3333
3450
|
/* harmony export */ sj: () => (/* binding */ verifyReview),
|
|
3334
3451
|
/* harmony export */ z6: () => (/* binding */ runReviewOnce)
|
|
3335
3452
|
/* harmony export */ });
|
|
3336
|
-
/* unused harmony exports CONCLUSIONS, LIKELIHOODS, resolveCitation, isRetryableError */
|
|
3453
|
+
/* unused harmony exports CONCLUSIONS, LIKELIHOODS, isLoopbackEndpoint, claimGrounded, resolveCitation, isRetryableError */
|
|
3337
3454
|
/* harmony import */ var _util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17097);
|
|
3338
3455
|
/* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11723);
|
|
3339
3456
|
/* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(45712);
|
|
@@ -3371,7 +3488,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3371
3488
|
const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
|
|
3372
3489
|
const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
|
|
3373
3490
|
const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
|
|
3374
|
-
const MAX_CITATION_SPAN =
|
|
3491
|
+
const MAX_CITATION_SPAN = 60;
|
|
3375
3492
|
const MAX_STR = 1200;
|
|
3376
3493
|
const MAX_ITEMS = 24;
|
|
3377
3494
|
|
|
@@ -3409,8 +3526,10 @@ function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash }) {
|
|
|
3409
3526
|
for (const g of pack.guidance) lines.push(`- ${g}`);
|
|
3410
3527
|
lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
|
|
3411
3528
|
entrypoints.forEach((ep, i) => {
|
|
3412
|
-
|
|
3413
|
-
|
|
3529
|
+
// The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
|
|
3530
|
+
// into the instruction text around it.
|
|
3531
|
+
const fenced = (0,_util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__/* .fenceUntrusted */ .C0)(`ENTRY POINT LABEL (data): ${(0,_canonical_js__WEBPACK_IMPORTED_MODULE_5__/* .safeLabel */ .IW)(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
|
|
3532
|
+
lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
|
|
3414
3533
|
lines.push(fenced.text, '');
|
|
3415
3534
|
});
|
|
3416
3535
|
const prompt = lines.join('\n');
|
|
@@ -3446,18 +3565,39 @@ function redactPackageText(text, { scanRoot }) {
|
|
|
3446
3565
|
}
|
|
3447
3566
|
|
|
3448
3567
|
/** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
|
|
3449
|
-
function
|
|
3568
|
+
function isLoopbackEndpoint(endpoint) {
|
|
3569
|
+
if (!endpoint) return false;
|
|
3570
|
+
const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
|
|
3571
|
+
if (!parts.length) return false;
|
|
3572
|
+
return parts.every(u => {
|
|
3573
|
+
try {
|
|
3574
|
+
const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
3575
|
+
return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
3576
|
+
} catch { return false; }
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function describeModel({ allowRemote = false } = {}) {
|
|
3581
|
+
let m;
|
|
3450
3582
|
try {
|
|
3451
3583
|
const r = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_3__.resolveProvider)({ role: 'hunt' });
|
|
3452
|
-
if (r.ok)
|
|
3584
|
+
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 };
|
|
3453
3585
|
} catch { /* fall through */ }
|
|
3454
|
-
|
|
3455
|
-
|
|
3586
|
+
if (!m) {
|
|
3587
|
+
const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
|
|
3588
|
+
m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
|
|
3589
|
+
}
|
|
3590
|
+
// `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.
|
|
3591
|
+
const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
|
|
3592
|
+
return { ...m, remote, remoteApproved: allowRemote === true };
|
|
3456
3593
|
}
|
|
3457
3594
|
|
|
3458
3595
|
/** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
|
|
3459
3596
|
function checkEgressForFiles(files, { scanRoot, model }) {
|
|
3460
3597
|
if (!model.endpoint) return { allowed: true, evaluated: false };
|
|
3598
|
+
// Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
|
|
3599
|
+
// other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
|
|
3600
|
+
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 };
|
|
3461
3601
|
for (const file of files) {
|
|
3462
3602
|
const d = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateEgress */ .nn)({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
|
|
3463
3603
|
if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
|
|
@@ -3493,14 +3633,29 @@ function normalizeCitations(list) {
|
|
|
3493
3633
|
return { items, malformed };
|
|
3494
3634
|
}
|
|
3495
3635
|
|
|
3496
|
-
|
|
3636
|
+
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']);
|
|
3637
|
+
|
|
3638
|
+
/**
|
|
3639
|
+
* A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
|
|
3640
|
+
* meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
|
|
3641
|
+
* real lines while describing something else, and an empty claim, from being accepted as evidence.
|
|
3642
|
+
*/
|
|
3643
|
+
function claimGrounded(claim, citedText) {
|
|
3644
|
+
const hay = String(citedText).toLowerCase();
|
|
3645
|
+
const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
|
|
3646
|
+
return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
|
|
3497
3650
|
function resolveCitation(c, segments, fileContents) {
|
|
3498
3651
|
if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
|
|
3499
3652
|
const text = fileContents[c.file];
|
|
3500
3653
|
if (typeof text !== 'string') return 'file-not-in-snapshot';
|
|
3501
|
-
|
|
3654
|
+
const fileLines = text.split('\n');
|
|
3655
|
+
if (c.endLine > fileLines.length) return 'beyond-end-of-file';
|
|
3502
3656
|
const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
|
|
3503
|
-
|
|
3657
|
+
if (!inside) return 'outside-provided-context';
|
|
3658
|
+
return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
|
|
3504
3659
|
}
|
|
3505
3660
|
|
|
3506
3661
|
function decline(base, reasons, extra = {}) {
|
|
@@ -3589,13 +3744,24 @@ function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileC
|
|
|
3589
3744
|
out.downgradedFrom = 'no_issue_observed';
|
|
3590
3745
|
out.conclusion = 'insufficient_evidence';
|
|
3591
3746
|
out.downgradeReason = 'no-mitigation-cited';
|
|
3747
|
+
} else if (dim) {
|
|
3748
|
+
// The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
|
|
3749
|
+
// a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
|
|
3750
|
+
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)));
|
|
3751
|
+
const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
|
|
3752
|
+
if (!recognised || onlyInferred) {
|
|
3753
|
+
out.downgradedFrom = 'no_issue_observed';
|
|
3754
|
+
out.conclusion = 'insufficient_evidence';
|
|
3755
|
+
out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
|
|
3756
|
+
}
|
|
3592
3757
|
}
|
|
3593
3758
|
}
|
|
3594
3759
|
|
|
3595
3760
|
// how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
|
|
3596
3761
|
if (dim) {
|
|
3597
3762
|
const states = manifests.map(m => m.protection[dim]?.state);
|
|
3598
|
-
|
|
3763
|
+
// `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
|
|
3764
|
+
const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
|
|
3599
3765
|
const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
|
|
3600
3766
|
out.deterministicProtection = { dimension: dim, states };
|
|
3601
3767
|
out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
|