@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
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;
|
|
@@ -394,7 +421,8 @@ async function adjudicate(candidates, opts = {}) {
|
|
|
394
421
|
origin: 'context-intelligence', questionPack: c.questionPack, witnesses: c.witnesses, witnessCount: c.witnesses.length,
|
|
395
422
|
sourceSpanEvidence: c.sourceSpanEvidence, scopeIntegrity: c.scopeIntegrity, reviewStatus: c.reviewStatus,
|
|
396
423
|
uncertainties: c.uncertainties, recommendedValidation: c.recommendedValidation,
|
|
397
|
-
|
|
424
|
+
...(c.deterministic ? { deterministic: c.deterministic } : {}),
|
|
425
|
+
note: c.deterministic ? 'Derived from the code analysis and a deterministic counterexample attempt; no model produced this. Severity comes from the confirmation tier only.' : 'A model hypothesis checked for citations, not a proof. Severity comes from the confirmation tier only.',
|
|
398
426
|
},
|
|
399
427
|
};
|
|
400
428
|
};
|
|
@@ -414,10 +442,13 @@ async function adjudicate(candidates, opts = {}) {
|
|
|
414
442
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
415
443
|
|
|
416
444
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
445
|
+
/* harmony export */ IW: () => (/* binding */ safeLabel),
|
|
417
446
|
/* harmony export */ Qz: () => (/* binding */ safeRelPath),
|
|
447
|
+
/* harmony export */ bt: () => (/* binding */ cleanForTerminal),
|
|
418
448
|
/* harmony export */ dj: () => (/* binding */ canonicalJson),
|
|
419
449
|
/* harmony export */ fQ: () => (/* binding */ normalizeText),
|
|
420
450
|
/* harmony export */ iq: () => (/* binding */ sliceLines),
|
|
451
|
+
/* harmony export */ kH: () => (/* binding */ cmpCodeUnit),
|
|
421
452
|
/* harmony export */ qQ: () => (/* binding */ hashOf),
|
|
422
453
|
/* harmony export */ sc: () => (/* binding */ sha256),
|
|
423
454
|
/* harmony export */ v6: () => (/* binding */ readRepoFile),
|
|
@@ -443,6 +474,31 @@ function sha256(text) {
|
|
|
443
474
|
return node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(text).digest('hex');
|
|
444
475
|
}
|
|
445
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
|
|
479
|
+
* could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
|
|
480
|
+
*/
|
|
481
|
+
function cmpCodeUnit(a, b) {
|
|
482
|
+
const x = String(a); const y = String(b);
|
|
483
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
|
|
487
|
+
// repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
|
|
488
|
+
// in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
|
|
489
|
+
const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
490
|
+
|
|
491
|
+
/** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
|
|
492
|
+
function cleanForTerminal(text) {
|
|
493
|
+
return String(text).replace(UNSAFE_DISPLAY, '?');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
|
|
497
|
+
function safeLabel(text, max = 120) {
|
|
498
|
+
const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
|
|
499
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
500
|
+
}
|
|
501
|
+
|
|
446
502
|
/** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
|
|
447
503
|
function normalizeText(text) {
|
|
448
504
|
return String(text).replace(/\r\n?/g, '\n');
|
|
@@ -719,7 +775,8 @@ const BUILD = [
|
|
|
719
775
|
{ language: 'javascript/typescript', kind: 'http', frameworks: 'Express-style call registration (get/post/put/patch/delete/all, router.use, app.use)', status: 'verified', evidence: [T('build-js.test.js')], limits: 'Router mounts in another file and middleware factories are not followed (reported as router-mount-not-resolved); destructured and named imports are followed, re-exports and tsconfig path aliases are not.' },
|
|
720
776
|
{ language: 'python', kind: 'http', frameworks: 'decorator registration (Flask/FastAPI-style @app.route / @router.get), FastAPI Depends()/Security() guards', status: 'experimental', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Decorator guards are read from the decorator lines and Depends() guards from the signature (only dependencies named like an auth or user provider bind the principal); framework-level before_request hooks and Django URL conf are not modelled.' },
|
|
721
777
|
{ language: 'haskell', kind: 'http', frameworks: 'Scotty, WAI, Servant, Yesod (through the existing web model)', status: 'verified', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Static source analysis; the existing web model decides what a route is. Dynamic routes are reported unresolved.' },
|
|
722
|
-
{ language: '
|
|
778
|
+
{ language: 'php', kind: 'http', frameworks: 'Laravel Route:: registration (the entry point only)', status: 'experimental', evidence: ['test/engineering-excellence/e4-01-dci-audit.test.js'], limits: 'Measured by the E4-01 audit on one fixture: the routes resolve, but no control or sensitive operation was extracted from the handlers.' },
|
|
779
|
+
{ language: 'java/kotlin/c#/go/ruby/rust', kind: 'http', frameworks: 'whatever the engine route scan recognises', status: 'unsupported', evidence: [], limits: 'Measured by the E4-01 audit (docs/engineering-excellence/DCI_MATURITY.md) on one fixture per language (Spring, net/http, ASP.NET, Rails): no entry point was found, so no bundle is built.' },
|
|
723
780
|
{ language: 'any with a recognised surface', kind: 'webhook/queue/cron/upload/cli', frameworks: 'existing entry-point inventory', status: 'experimental', evidence: [T('build-js.test.js')], limits: 'Handler is the enclosing declaration of the surface; no framework wiring beyond that.' },
|
|
724
781
|
{ language: 'haskell', kind: 'compiler metadata import', frameworks: 'hie-export/1 JSON export of GHC declarations and references', status: 'experimental', evidence: [T('hie-adapter.test.js')], limits: 'Reads an export; never runs GHC or hiedb. The export is validated against the source snapshot. No export produced by a real GHC run is checked in.' },
|
|
725
782
|
{ language: 'any', kind: 'compiler metadata build', frameworks: 'automatic metadata generation', status: 'blocked', evidence: [], limits: 'Running a compiler on untrusted source needs a sandbox proof this build does not have (Template Haskell, custom Setup and plugins run code).' },
|
|
@@ -762,25 +819,29 @@ var state_dir = __webpack_require__(31174);
|
|
|
762
819
|
|
|
763
820
|
|
|
764
821
|
|
|
822
|
+
|
|
765
823
|
const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
|
|
766
824
|
const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
|
|
767
825
|
const num = (v) => (v === undefined || v === true ? undefined : Number(v));
|
|
768
826
|
|
|
827
|
+
/** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
|
|
828
|
+
function say(text) { console.log((0,canonical/* cleanForTerminal */.bt)(text)); }
|
|
829
|
+
|
|
769
830
|
function out(format, text, obj) {
|
|
770
|
-
if (format === 'json')
|
|
771
|
-
else if (format === 'jsonl')
|
|
772
|
-
else
|
|
831
|
+
if (format === 'json') say(JSON.stringify(obj, null, 2));
|
|
832
|
+
else if (format === 'jsonl') say(JSON.stringify(obj));
|
|
833
|
+
else say(text);
|
|
773
834
|
}
|
|
774
835
|
|
|
775
836
|
function usage() {
|
|
776
|
-
|
|
837
|
+
say([
|
|
777
838
|
'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
|
|
778
839
|
'',
|
|
779
840
|
' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
|
|
780
841
|
' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
|
|
781
842
|
' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
|
|
782
843
|
' 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]',
|
|
844
|
+
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
|
|
784
845
|
' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
|
|
785
846
|
' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
|
|
786
847
|
' status Coverage so far: entry points, bundles, review jobs',
|
|
@@ -802,7 +863,7 @@ async function doBuild(root, args, { write = true } = {}) {
|
|
|
802
863
|
if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
|
|
803
864
|
const events = [];
|
|
804
865
|
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)
|
|
866
|
+
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
867
|
if (!built.ok) return { ok: false, built, config, problems };
|
|
807
868
|
let persisted = null;
|
|
808
869
|
if (write) persisted = persistBuild(root, built, config);
|
|
@@ -895,7 +956,7 @@ async function cmdInspect(root, args, format) {
|
|
|
895
956
|
` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
|
|
896
957
|
];
|
|
897
958
|
if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
|
|
898
|
-
|
|
959
|
+
say(lines.join('\n'));
|
|
899
960
|
return 0;
|
|
900
961
|
}
|
|
901
962
|
|
|
@@ -995,7 +1056,7 @@ async function cmdExport(root, args, format) {
|
|
|
995
1056
|
if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
|
|
996
1057
|
const r = (0,store/* exportBundle */.Nl)(root, String(h));
|
|
997
1058
|
if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
|
|
998
|
-
|
|
1059
|
+
say(JSON.stringify(r.export, null, 2));
|
|
999
1060
|
return 0;
|
|
1000
1061
|
}
|
|
1001
1062
|
|
|
@@ -1043,7 +1104,7 @@ async function cmdReview(root, args, format) {
|
|
|
1043
1104
|
const r = await doBuild(root, args, { write: true });
|
|
1044
1105
|
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
1106
|
const { built } = r;
|
|
1046
|
-
const model = (0,review/* describeModel */.Vy)();
|
|
1107
|
+
const model = (0,review/* describeModel */.Vy)({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
|
|
1047
1108
|
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
1109
|
const loadJob = loadJobFrom(built, packs);
|
|
1049
1110
|
const ctxBase = { root: built.root, fileContents: built.fileContents, model };
|
|
@@ -1081,12 +1142,14 @@ async function cmdReview(root, args, format) {
|
|
|
1081
1142
|
try {
|
|
1082
1143
|
result = await (0,queue/* runQueue */.rj)(jobs, {
|
|
1083
1144
|
...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
|
|
1084
|
-
onEvent: jsonl ? (e) =>
|
|
1145
|
+
onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
|
|
1085
1146
|
limits: {
|
|
1086
1147
|
concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
|
|
1087
1148
|
maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
|
|
1088
1149
|
maxWallSeconds: num(flag(args, 'max-wall-seconds')) || config.review.maxWallSeconds,
|
|
1089
1150
|
maxTokensPerJob: config.review.maxTokensPerJob,
|
|
1151
|
+
maxTokensPerRepo: config.review.maxTokensPerRepo,
|
|
1152
|
+
anonymizePrompts: config.review.anonymizePrompts,
|
|
1090
1153
|
maxRetries: config.review.maxRetries,
|
|
1091
1154
|
jobTimeoutMs: (num(flag(args, 'job-timeout-seconds')) || 120) * 1000,
|
|
1092
1155
|
maxCostUsd: num(flag(args, 'max-cost-usd')) ?? config.review.maxCostUsd,
|
|
@@ -1199,7 +1262,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
1199
1262
|
maxFiles: 3000,
|
|
1200
1263
|
semanticMetadata: { mode: 'off', haskellHieDir: null },
|
|
1201
1264
|
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 },
|
|
1265
|
+
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3, maxTokensPerRepo: null, anonymizePrompts: false },
|
|
1203
1266
|
replay: { contentAddressed: true, retention: 'inherit' },
|
|
1204
1267
|
});
|
|
1205
1268
|
|
|
@@ -1240,6 +1303,7 @@ function normalizeConfig(raw) {
|
|
|
1240
1303
|
},
|
|
1241
1304
|
review: {
|
|
1242
1305
|
enabled: rv.enabled === true,
|
|
1306
|
+
allowRemote: rv.allowRemote === true,
|
|
1243
1307
|
questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
|
|
1244
1308
|
concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
|
|
1245
1309
|
maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
|
|
@@ -1247,6 +1311,8 @@ function normalizeConfig(raw) {
|
|
|
1247
1311
|
maxCostUsd: Number.isFinite(rv.maxCostUsd) && rv.maxCostUsd >= 0 ? rv.maxCostUsd : null,
|
|
1248
1312
|
maxTokensPerJob: clamp(rv.maxTokensPerJob, 1000, 400000, d.review.maxTokensPerJob),
|
|
1249
1313
|
maxRetries: clamp(rv.maxRetries, 0, 3, d.review.maxRetries),
|
|
1314
|
+
maxTokensPerRepo: Number.isFinite(rv.maxTokensPerRepo) && rv.maxTokensPerRepo >= 1000 ? Math.trunc(rv.maxTokensPerRepo) : null,
|
|
1315
|
+
anonymizePrompts: rv.anonymizePrompts === true,
|
|
1250
1316
|
},
|
|
1251
1317
|
replay: { contentAddressed: rp.contentAddressed !== false, retention: typeof rp.retention === 'string' ? rp.retention : 'inherit' },
|
|
1252
1318
|
};
|
|
@@ -1273,12 +1339,13 @@ function loadConfig(root) {
|
|
|
1273
1339
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1274
1340
|
|
|
1275
1341
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1342
|
+
/* harmony export */ $E: () => (/* binding */ blankComments),
|
|
1276
1343
|
/* harmony export */ TM: () => (/* binding */ looksLikeTelemetry),
|
|
1277
1344
|
/* harmony export */ gs: () => (/* binding */ signatureText),
|
|
1278
1345
|
/* harmony export */ kl: () => (/* binding */ analyzeSymbolText),
|
|
1279
1346
|
/* harmony export */ qr: () => (/* binding */ dependencyParams)
|
|
1280
1347
|
/* harmony export */ });
|
|
1281
|
-
/* unused harmony exports CONTROL_KINDS, isSecuritySignificant */
|
|
1348
|
+
/* unused harmony exports CONTROL_KINDS, ownerBinding, isSecuritySignificant */
|
|
1282
1349
|
//
|
|
1283
1350
|
// Security-relevant facts inside ONE symbol's source: authentication / authorization / ownership / tenant controls,
|
|
1284
1351
|
// and the sensitive operations they have to precede.
|
|
@@ -1328,7 +1395,46 @@ function signatureText(lines, declLine, maxLines = 12) {
|
|
|
1328
1395
|
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
1396
|
return t;
|
|
1330
1397
|
}
|
|
1331
|
-
|
|
1398
|
+
// An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
|
|
1399
|
+
// qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
|
|
1400
|
+
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';
|
|
1401
|
+
const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
|
|
1402
|
+
const TENANT_WORD = /tenant|org|workspace|team|account/i;
|
|
1403
|
+
// a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
|
|
1404
|
+
const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
|
|
1405
|
+
// an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
|
|
1406
|
+
const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
|
|
1407
|
+
const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
|
|
1408
|
+
const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
|
|
1412
|
+
* same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
|
|
1413
|
+
* `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
|
|
1414
|
+
* principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
|
|
1415
|
+
*/
|
|
1416
|
+
function ownerBinding(text, extra = null) {
|
|
1417
|
+
let best = null;
|
|
1418
|
+
const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
|
|
1419
|
+
const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
|
|
1420
|
+
for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
|
|
1421
|
+
// the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
|
|
1422
|
+
const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
|
|
1423
|
+
if (!OWNER_KEY_RE.test(forKey)) continue;
|
|
1424
|
+
const pr = principalIn(clause, extra);
|
|
1425
|
+
if (pr) take(pr, TENANT_WORD.test(clause));
|
|
1426
|
+
}
|
|
1427
|
+
for (const m of text.match(STRING_LITERAL) || []) {
|
|
1428
|
+
const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
|
|
1429
|
+
if (placeholder) {
|
|
1430
|
+
const pr = principalIn(text, extra);
|
|
1431
|
+
if (pr) take(pr, TENANT_WORD.test(m));
|
|
1432
|
+
}
|
|
1433
|
+
if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
|
|
1434
|
+
}
|
|
1435
|
+
return best;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1332
1438
|
const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
|
|
1333
1439
|
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
1440
|
|
|
@@ -1339,6 +1445,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
|
|
|
1339
1445
|
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
1446
|
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
1447
|
|
|
1448
|
+
// Names that answer a question rather than enforce a rule.
|
|
1449
|
+
const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
|
|
1450
|
+
function bareCallStatement(trimmedLine, name) {
|
|
1451
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1452
|
+
return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1342
1455
|
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
1456
|
|
|
1344
1457
|
function kindOfAuthName(name) {
|
|
@@ -1374,6 +1487,51 @@ function clip(s) { return s.trim().slice(0, 160); }
|
|
|
1374
1487
|
* lines the FILE's lines (0-based array)
|
|
1375
1488
|
* Returns [{ symbolId, line, category:'control'|'sensitive', kind, subkind, evidence, basis, rejectionSeen, text }]
|
|
1376
1489
|
*/
|
|
1490
|
+
/**
|
|
1491
|
+
* Blank the comments (and Python docstrings) of a source file, keeping every character position and newline, so line numbers do not move.
|
|
1492
|
+
* The analysis looks for calls and comparisons in code; a comment that SAYS `ownerId: req.user.id` or `requireUser(req)` is prose, and prose is the
|
|
1493
|
+
* easiest thing for a hostile or merely careless author to write. Strings are left alone (the principal and SQL rules already read around them).
|
|
1494
|
+
*/
|
|
1495
|
+
function blankComments(text, file = '') {
|
|
1496
|
+
const src = String(text || '');
|
|
1497
|
+
const hash = /\.(?:py|rb|sh|php|pl|r)$/i.test(file);
|
|
1498
|
+
const dashes = /\.l?hs$/i.test(file);
|
|
1499
|
+
const cLike = !dashes && !/\.(?:py|rb|sh|pl|r)$/i.test(file);
|
|
1500
|
+
const out = src.split('');
|
|
1501
|
+
const n = src.length;
|
|
1502
|
+
let i = 0;
|
|
1503
|
+
const blank = (a, b) => { for (let k = a; k < b && k < n; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
1504
|
+
while (i < n) {
|
|
1505
|
+
const c = src[i]; const d = src[i + 1];
|
|
1506
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
1507
|
+
if (/\.py$/i.test(file) && src.startsWith(c.repeat(3), i)) {
|
|
1508
|
+
const end = src.indexOf(c.repeat(3), i + 3); const stop = end < 0 ? n : end + 3;
|
|
1509
|
+
blank(i, stop); i = stop; continue; // a docstring or triple-quoted block is prose
|
|
1510
|
+
}
|
|
1511
|
+
let j = i + 1;
|
|
1512
|
+
while (j < n && src[j] !== c && !(src[j] === '\n' && c !== '`')) { if (src[j] === '\\') j++; j++; }
|
|
1513
|
+
i = j + 1; continue;
|
|
1514
|
+
}
|
|
1515
|
+
if (cLike && c === '/' && d === '*') { const end = src.indexOf('*/', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
|
|
1516
|
+
if (cLike && c === '/' && d === '/' && (i === 0 || /[\s;,)}{\]]/.test(src[i - 1]))) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
1517
|
+
if (hash && c === '#' && !(file.endsWith('.php') && d === '[')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
1518
|
+
if (dashes && c === '-' && d === '-' && !/[!#$%&*+./<=>?@\\^|~:]/.test(src[i + 2] || ' ')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
1519
|
+
if (dashes && c === '{' && d === '-') { const end = src.indexOf('-}', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
|
|
1520
|
+
i++;
|
|
1521
|
+
}
|
|
1522
|
+
return out.join('');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function bodyAfterSignature(raw) {
|
|
1526
|
+
const arrow = raw.indexOf('=>');
|
|
1527
|
+
let rest = null;
|
|
1528
|
+
if (arrow >= 0) rest = raw.slice(arrow + 2);
|
|
1529
|
+
else { const m = /\)\s*\{(.*)$/.exec(raw); if (m) rest = m[1]; }
|
|
1530
|
+
if (rest === null) return '';
|
|
1531
|
+
const brace = /^\s*\{(.*)$/.exec(rest);
|
|
1532
|
+
return brace ? brace[1] : rest;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1377
1535
|
function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
1378
1536
|
const out = [];
|
|
1379
1537
|
const seen = new Set();
|
|
@@ -1406,15 +1564,22 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1406
1564
|
const calls = [];
|
|
1407
1565
|
const re = /([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)\s*\(/g;
|
|
1408
1566
|
let m;
|
|
1409
|
-
|
|
1567
|
+
// A one-line handler (`app.get('/x', guard, async (req, res) => { res.json(await db.docs.findOne({ id })); })`) is its own declaration
|
|
1568
|
+
// line, so skipping the whole line also skipped its body. Only the SIGNATURE is skipped: the calls after the arrow or the opening brace count.
|
|
1569
|
+
const scanText = isDeclLine ? bodyAfterSignature(raw) : raw;
|
|
1570
|
+
while ((m = re.exec(scanText))) calls.push(m[1]);
|
|
1410
1571
|
if (haskell && !isDeclLine) {
|
|
1411
1572
|
const hre = /\b([A-Z][\w.]*\.[a-z][\w']*|[a-z][\w']*)\b/g;
|
|
1412
1573
|
while ((m = hre.exec(raw))) if (AUTH_NAME.test(m[1]) || HS_SENSITIVE.test(m[1])) calls.push(m[1]);
|
|
1413
1574
|
}
|
|
1414
1575
|
for (const name of calls) {
|
|
1415
1576
|
if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1577
|
+
const lastSeg = name.split(/[.:]/).pop();
|
|
1578
|
+
// A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
|
|
1579
|
+
// so it guards nothing, whatever the lines around it say.
|
|
1580
|
+
const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
|
|
1581
|
+
const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
|
|
1582
|
+
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
1583
|
}
|
|
1419
1584
|
const last = name.split(/[.:]/).pop();
|
|
1420
1585
|
const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
|
|
@@ -1430,9 +1595,9 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1430
1595
|
push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
|
|
1431
1596
|
// a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
|
|
1432
1597
|
const stmt = statementAt(lines, ln);
|
|
1433
|
-
const pr = sub.startsWith('data-')
|
|
1598
|
+
const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
|
|
1434
1599
|
if (pr) {
|
|
1435
|
-
push({ line: ln, category: 'control', kind:
|
|
1600
|
+
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
1601
|
}
|
|
1437
1602
|
}
|
|
1438
1603
|
}
|
|
@@ -1440,17 +1605,17 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
1440
1605
|
const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
|
|
1441
1606
|
push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
|
|
1442
1607
|
const stmt = statementAt(lines, ln);
|
|
1443
|
-
const pr =
|
|
1608
|
+
const pr = ownerBinding(stmt, extraPrincipal);
|
|
1444
1609
|
if (pr) {
|
|
1445
|
-
push({ line: ln, category: 'control', kind:
|
|
1610
|
+
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
1611
|
}
|
|
1447
1612
|
}
|
|
1448
1613
|
|
|
1449
1614
|
// a resource field compared with the principal: the ownership check itself
|
|
1450
|
-
const cmpPr = COMPARE.test(raw) &&
|
|
1615
|
+
const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
|
|
1451
1616
|
if (cmpPr) {
|
|
1452
1617
|
const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
|
|
1453
|
-
push({ line: ln, category: 'control', kind:
|
|
1618
|
+
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
1619
|
}
|
|
1455
1620
|
}
|
|
1456
1621
|
// A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
|
|
@@ -1805,7 +1970,7 @@ async function collectEntrypoints({ fileContents, graph, config = {} }) {
|
|
|
1805
1970
|
const n = (ord.get(stem) || 0) + 1;
|
|
1806
1971
|
ord.set(stem, n);
|
|
1807
1972
|
r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
|
|
1808
|
-
r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}
|
|
1973
|
+
r.label = (0,_canonical_js__WEBPACK_IMPORTED_MODULE_0__/* .safeLabel */ .IW)(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
|
|
1809
1974
|
void hname;
|
|
1810
1975
|
let disposition;
|
|
1811
1976
|
const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
|
|
@@ -2911,6 +3076,8 @@ async function runQueue(jobs, ctx) {
|
|
|
2911
3076
|
maxCalls: Math.min(HARD_LIMITS.maxCalls, Math.max(1, ctx.limits.maxCalls || 100)),
|
|
2912
3077
|
maxWallMs: Math.min(HARD_LIMITS.maxWallSeconds, Math.max(1, ctx.limits.maxWallSeconds || 900)) * 1000,
|
|
2913
3078
|
maxTokensPerJob: ctx.limits.maxTokensPerJob || 40000,
|
|
3079
|
+
maxTokensPerRepo: Number.isFinite(ctx.limits.maxTokensPerRepo) ? ctx.limits.maxTokensPerRepo : null,
|
|
3080
|
+
anonymizePrompts: ctx.limits.anonymizePrompts === true,
|
|
2914
3081
|
maxRetries: Math.min(HARD_LIMITS.maxRetries, Math.max(0, ctx.limits.maxRetries ?? 3)),
|
|
2915
3082
|
jobTimeoutMs: ctx.limits.jobTimeoutMs || 120000,
|
|
2916
3083
|
maxCostUsd: Number.isFinite(ctx.limits.maxCostUsd) ? ctx.limits.maxCostUsd : null,
|
|
@@ -2924,6 +3091,7 @@ async function runQueue(jobs, ctx) {
|
|
|
2924
3091
|
const results = new Map();
|
|
2925
3092
|
let calls = 0;
|
|
2926
3093
|
let estCost = 0;
|
|
3094
|
+
let tokensUsed = 0;
|
|
2927
3095
|
let cooldownUntil = 0;
|
|
2928
3096
|
let budgetStop = null;
|
|
2929
3097
|
let persistChain = Promise.resolve();
|
|
@@ -2973,6 +3141,7 @@ async function runQueue(jobs, ctx) {
|
|
|
2973
3141
|
if (ctx.signal && ctx.signal.aborted) return 'cancelled';
|
|
2974
3142
|
if (now() - started >= L.maxWallMs) return 'budget-exhausted:wall-time';
|
|
2975
3143
|
if (calls >= L.maxCalls) return 'budget-exhausted:max-calls';
|
|
3144
|
+
if (L.maxTokensPerRepo !== null && tokensUsed >= L.maxTokensPerRepo) return 'budget-exhausted:max-tokens-per-repo';
|
|
2976
3145
|
if (L.maxCostUsd !== null && L.costPerCallUsd !== null && estCost + L.costPerCallUsd > L.maxCostUsd) return 'budget-exhausted:max-cost';
|
|
2977
3146
|
return null;
|
|
2978
3147
|
};
|
|
@@ -2996,8 +3165,9 @@ async function runQueue(jobs, ctx) {
|
|
|
2996
3165
|
state.jobs[job.key] = { state: 'running', reason: null, attempts, label: job.label };
|
|
2997
3166
|
const out = await (0,_review_js__WEBPACK_IMPORTED_MODULE_2__/* .runReviewOnce */ .z6)({
|
|
2998
3167
|
pack: loadedJob.pack, entrypoints: loadedJob.entrypoints, manifests: loadedJob.manifests, packageIndexes: job.packageIndexes, packageTexts: loadedJob.packageTexts,
|
|
2999
|
-
fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs },
|
|
3168
|
+
fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs, anonymizePrompts: L.anonymizePrompts },
|
|
3000
3169
|
});
|
|
3170
|
+
if (out.usage) tokensUsed += (out.usage.inputTokensEstimated || 0) + (out.usage.outputTokensEstimated || 0);
|
|
3001
3171
|
if (out.state === 'skipped_by_policy') calls--; // nothing was sent
|
|
3002
3172
|
if (out.state === 'failed' && out.retryable && attempts <= L.maxRetries) {
|
|
3003
3173
|
// exponential backoff with jitter; the shared cooldown slows every worker, which is what a 429 storm needs
|
|
@@ -3134,7 +3304,8 @@ function importBatch(root, batchId, resultsText, ctx) {
|
|
|
3134
3304
|
/* harmony export */ });
|
|
3135
3305
|
/* unused harmony exports DEFAULT_LIMITS, PROTECTION_DIMENSIONS */
|
|
3136
3306
|
/* harmony import */ var _controls_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17202);
|
|
3137
|
-
/* harmony import */ var
|
|
3307
|
+
/* harmony import */ var _canonical_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44652);
|
|
3308
|
+
/* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44287);
|
|
3138
3309
|
//
|
|
3139
3310
|
// Deterministic, bounded reachability from an entry point's handler.
|
|
3140
3311
|
//
|
|
@@ -3151,6 +3322,7 @@ function importBatch(root, batchId, resultsText, ctx) {
|
|
|
3151
3322
|
|
|
3152
3323
|
|
|
3153
3324
|
|
|
3325
|
+
|
|
3154
3326
|
const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
|
|
3155
3327
|
|
|
3156
3328
|
function indexGraph(graph) {
|
|
@@ -3172,7 +3344,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
3172
3344
|
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
3173
3345
|
const { byId, edgesFrom } = indexGraph(graph);
|
|
3174
3346
|
const linesCache = new Map();
|
|
3175
|
-
const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
|
|
3347
|
+
const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (0,_controls_js__WEBPACK_IMPORTED_MODULE_0__/* .blankComments */ .$E)(fileContents[f] || '', f).split('\n')); return linesCache.get(f); };
|
|
3176
3348
|
|
|
3177
3349
|
const visited = [];
|
|
3178
3350
|
const seen = new Set();
|
|
@@ -3207,7 +3379,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
3207
3379
|
}
|
|
3208
3380
|
|
|
3209
3381
|
// 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) ||
|
|
3382
|
+
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
3383
|
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
3384
|
}
|
|
3213
3385
|
if (root.symbolId) {
|
|
@@ -3239,7 +3411,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
3239
3411
|
if (e.to) {
|
|
3240
3412
|
if (cur.depth + 1 > lim.maxDepth) { capped = true; continue; }
|
|
3241
3413
|
enqueue(e.to, cur.depth + 1, 'callee', via, [...cur.path, e.to], 'exact');
|
|
3242
|
-
} else if (
|
|
3414
|
+
} else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_2__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
|
|
3243
3415
|
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
3416
|
// Candidates are shown to the model as CANDIDATES (low confidence); the unknown stays material regardless.
|
|
3245
3417
|
if (e.candidates.length && e.candidates.length <= 3 && cur.depth + 1 <= lim.maxDepth) {
|
|
@@ -3300,11 +3472,14 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3300
3472
|
for (const dim of PROTECTION_DIMENSIONS) {
|
|
3301
3473
|
const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
|
|
3302
3474
|
const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
|
|
3303
|
-
|
|
3475
|
+
// A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
|
|
3476
|
+
const effective = (f) => f.rejectionSeen !== false;
|
|
3477
|
+
const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
|
|
3304
3478
|
const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
|
|
3305
3479
|
let state; let why;
|
|
3306
3480
|
if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
|
|
3307
3481
|
else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
|
|
3482
|
+
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
3483
|
else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
|
|
3309
3484
|
else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
|
|
3310
3485
|
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 +3487,8 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3312
3487
|
else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
|
|
3313
3488
|
dims[dim] = {
|
|
3314
3489
|
state, why, firstSensitiveLine: firstSens,
|
|
3490
|
+
// How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
|
|
3491
|
+
strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
|
|
3315
3492
|
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
3493
|
};
|
|
3317
3494
|
}
|
|
@@ -3333,7 +3510,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3333
3510
|
/* harmony export */ sj: () => (/* binding */ verifyReview),
|
|
3334
3511
|
/* harmony export */ z6: () => (/* binding */ runReviewOnce)
|
|
3335
3512
|
/* harmony export */ });
|
|
3336
|
-
/* unused harmony exports CONCLUSIONS, LIKELIHOODS, resolveCitation, isRetryableError */
|
|
3513
|
+
/* unused harmony exports CONCLUSIONS, LIKELIHOODS, isLoopbackEndpoint, stripUntrustedCommands, pseudonymizer, claimGrounded, resolveCitation, isRetryableError */
|
|
3337
3514
|
/* harmony import */ var _util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17097);
|
|
3338
3515
|
/* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11723);
|
|
3339
3516
|
/* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(45712);
|
|
@@ -3371,7 +3548,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
|
|
|
3371
3548
|
const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
|
|
3372
3549
|
const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
|
|
3373
3550
|
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 =
|
|
3551
|
+
const MAX_CITATION_SPAN = 60;
|
|
3375
3552
|
const MAX_STR = 1200;
|
|
3376
3553
|
const MAX_ITEMS = 24;
|
|
3377
3554
|
|
|
@@ -3409,8 +3586,10 @@ function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash }) {
|
|
|
3409
3586
|
for (const g of pack.guidance) lines.push(`- ${g}`);
|
|
3410
3587
|
lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
|
|
3411
3588
|
entrypoints.forEach((ep, i) => {
|
|
3412
|
-
|
|
3413
|
-
|
|
3589
|
+
// The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
|
|
3590
|
+
// into the instruction text around it.
|
|
3591
|
+
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}`);
|
|
3592
|
+
lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
|
|
3414
3593
|
lines.push(fenced.text, '');
|
|
3415
3594
|
});
|
|
3416
3595
|
const prompt = lines.join('\n');
|
|
@@ -3446,25 +3625,103 @@ function redactPackageText(text, { scanRoot }) {
|
|
|
3446
3625
|
}
|
|
3447
3626
|
|
|
3448
3627
|
/** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
|
|
3449
|
-
function
|
|
3628
|
+
function isLoopbackEndpoint(endpoint) {
|
|
3629
|
+
if (!endpoint) return false;
|
|
3630
|
+
const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
|
|
3631
|
+
if (!parts.length) return false;
|
|
3632
|
+
return parts.every(u => {
|
|
3633
|
+
try {
|
|
3634
|
+
const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
3635
|
+
return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
3636
|
+
} catch { return false; }
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
function describeModel({ allowRemote = false } = {}) {
|
|
3641
|
+
let m;
|
|
3450
3642
|
try {
|
|
3451
3643
|
const r = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_3__.resolveProvider)({ role: 'hunt' });
|
|
3452
|
-
if (r.ok)
|
|
3644
|
+
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
3645
|
} catch { /* fall through */ }
|
|
3454
|
-
|
|
3455
|
-
|
|
3646
|
+
if (!m) {
|
|
3647
|
+
const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
|
|
3648
|
+
m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
|
|
3649
|
+
}
|
|
3650
|
+
// `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.
|
|
3651
|
+
const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
|
|
3652
|
+
return { ...m, remote, remoteApproved: allowRemote === true };
|
|
3456
3653
|
}
|
|
3457
3654
|
|
|
3458
3655
|
/** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
|
|
3459
3656
|
function checkEgressForFiles(files, { scanRoot, model }) {
|
|
3460
3657
|
if (!model.endpoint) return { allowed: true, evaluated: false };
|
|
3658
|
+
// Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
|
|
3659
|
+
// other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
|
|
3660
|
+
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
3661
|
for (const file of files) {
|
|
3462
|
-
|
|
3463
|
-
|
|
3662
|
+
// 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
|
|
3663
|
+
// reason, and nothing is sent. This is the only place that decides whether a prompt may leave the machine.
|
|
3664
|
+
let d;
|
|
3665
|
+
try { 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 }); }
|
|
3666
|
+
catch (err) { return { allowed: false, reason: `egress-policy-error: ${String((err && err.message) || err).slice(0, 120)}`, file, evaluated: true, failedClosed: true }; }
|
|
3667
|
+
if (!d || d.allowed !== true) return { allowed: false, reason: (d && d.reason) || 'egress-policy-returned-no-decision', file, evaluated: true, ...(d ? {} : { failedClosed: true }) };
|
|
3464
3668
|
}
|
|
3465
3669
|
return { allowed: true, evaluated: true };
|
|
3466
3670
|
}
|
|
3467
3671
|
|
|
3672
|
+
// ---- model output hygiene -------------------------------------------------------------------------------------
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* 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
|
|
3676
|
+
* these strings reach reports, tickets and fix suggestions that people copy from, so shell-looking content is removed and counted, never passed on.
|
|
3677
|
+
*/
|
|
3678
|
+
const FENCE = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
|
|
3679
|
+
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)';
|
|
3680
|
+
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');
|
|
3681
|
+
function stripUntrustedCommands(text) {
|
|
3682
|
+
if (typeof text !== 'string' || !text) return { text: text ?? '', stripped: 0 };
|
|
3683
|
+
let n = 0;
|
|
3684
|
+
let out = text.replace(FENCE, () => { n++; return '[command block removed]'; });
|
|
3685
|
+
out = out.replace(INLINE_CMD, () => { n++; return '[command removed]'; });
|
|
3686
|
+
return { text: out, stripped: n };
|
|
3687
|
+
}
|
|
3688
|
+
function sanitizeReviewStrings(review) {
|
|
3689
|
+
let total = 0;
|
|
3690
|
+
const walk = (v, key) => {
|
|
3691
|
+
if (typeof v === 'string') {
|
|
3692
|
+
if (['promptHash', 'responseHash', 'contextHash', 'entrypointId', 'file', 'role', 'kind', 'conclusion', 'likelihood', 'questionPack', 'status'].includes(key)) return v;
|
|
3693
|
+
const r = stripUntrustedCommands(v); total += r.stripped; return r.text;
|
|
3694
|
+
}
|
|
3695
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, key));
|
|
3696
|
+
if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) o[k] = walk(x, k); return o; }
|
|
3697
|
+
return v;
|
|
3698
|
+
};
|
|
3699
|
+
const out = walk(review, '');
|
|
3700
|
+
if (total) out.untrustedCommandsStripped = total;
|
|
3701
|
+
return out;
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
/** 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. */
|
|
3705
|
+
function provenanceOf(model, promptHash) {
|
|
3706
|
+
let host = null; try { host = model.endpoint ? new URL(model.endpoint).hostname : null; } catch { host = null; }
|
|
3707
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
3708
|
+
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 };
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
// ---- prompt anonymization ---------------------------------------------------------------------------------------
|
|
3712
|
+
|
|
3713
|
+
/** Replace each real file path with a stable pseudonym (`f_<hash>.<ext>`). The mapping stays in memory; only the pseudonyms leave. */
|
|
3714
|
+
function pseudonymizer(files) {
|
|
3715
|
+
const toPseudo = new Map(); const toReal = new Map();
|
|
3716
|
+
for (const f of [...new Set(files)].sort()) {
|
|
3717
|
+
const ext = (/\.[A-Za-z0-9]{1,6}$/.exec(f) || [''])[0];
|
|
3718
|
+
const p = `f_${(0,_canonical_js__WEBPACK_IMPORTED_MODULE_5__/* .sha256 */ .sc)(f).slice(0, 10)}${ext}`;
|
|
3719
|
+
toPseudo.set(f, p); toReal.set(p, f);
|
|
3720
|
+
}
|
|
3721
|
+
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; };
|
|
3722
|
+
return { toPseudo: (t) => swap(t, toPseudo), toReal: (t) => swap(t, toReal), size: toPseudo.size };
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3468
3725
|
// ---- response parsing and verification ------------------------------------------------------------------------
|
|
3469
3726
|
|
|
3470
3727
|
function extractJson(raw) {
|
|
@@ -3493,14 +3750,29 @@ function normalizeCitations(list) {
|
|
|
3493
3750
|
return { items, malformed };
|
|
3494
3751
|
}
|
|
3495
3752
|
|
|
3496
|
-
|
|
3753
|
+
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']);
|
|
3754
|
+
|
|
3755
|
+
/**
|
|
3756
|
+
* A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
|
|
3757
|
+
* meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
|
|
3758
|
+
* real lines while describing something else, and an empty claim, from being accepted as evidence.
|
|
3759
|
+
*/
|
|
3760
|
+
function claimGrounded(claim, citedText) {
|
|
3761
|
+
const hay = String(citedText).toLowerCase();
|
|
3762
|
+
const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
|
|
3763
|
+
return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
/** 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
3767
|
function resolveCitation(c, segments, fileContents) {
|
|
3498
3768
|
if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
|
|
3499
3769
|
const text = fileContents[c.file];
|
|
3500
3770
|
if (typeof text !== 'string') return 'file-not-in-snapshot';
|
|
3501
|
-
|
|
3771
|
+
const fileLines = text.split('\n');
|
|
3772
|
+
if (c.endLine > fileLines.length) return 'beyond-end-of-file';
|
|
3502
3773
|
const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
|
|
3503
|
-
|
|
3774
|
+
if (!inside) return 'outside-provided-context';
|
|
3775
|
+
return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
|
|
3504
3776
|
}
|
|
3505
3777
|
|
|
3506
3778
|
function decline(base, reasons, extra = {}) {
|
|
@@ -3589,13 +3861,24 @@ function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileC
|
|
|
3589
3861
|
out.downgradedFrom = 'no_issue_observed';
|
|
3590
3862
|
out.conclusion = 'insufficient_evidence';
|
|
3591
3863
|
out.downgradeReason = 'no-mitigation-cited';
|
|
3864
|
+
} else if (dim) {
|
|
3865
|
+
// The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
|
|
3866
|
+
// a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
|
|
3867
|
+
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)));
|
|
3868
|
+
const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
|
|
3869
|
+
if (!recognised || onlyInferred) {
|
|
3870
|
+
out.downgradedFrom = 'no_issue_observed';
|
|
3871
|
+
out.conclusion = 'insufficient_evidence';
|
|
3872
|
+
out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
|
|
3873
|
+
}
|
|
3592
3874
|
}
|
|
3593
3875
|
}
|
|
3594
3876
|
|
|
3595
3877
|
// how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
|
|
3596
3878
|
if (dim) {
|
|
3597
3879
|
const states = manifests.map(m => m.protection[dim]?.state);
|
|
3598
|
-
|
|
3880
|
+
// `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
|
|
3881
|
+
const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
|
|
3599
3882
|
const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
|
|
3600
3883
|
out.deterministicProtection = { dimension: dim, states };
|
|
3601
3884
|
out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
|
|
@@ -3647,7 +3930,12 @@ async function runReviewOnce({ pack, entrypoints, manifests, packageIndexes, pac
|
|
|
3647
3930
|
const badRedact = redacted.find(r => !r.ok);
|
|
3648
3931
|
if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
|
|
3649
3932
|
|
|
3650
|
-
|
|
3933
|
+
// Policy-driven anonymization: file paths are replaced by pseudonyms before the prompt is built, and mapped back in the answer before its citations
|
|
3934
|
+
// are checked against the real snapshot. The model never sees a real path, and what is hashed is what was sent.
|
|
3935
|
+
const anon = limits.anonymizePrompts ? pseudonymizer(files) : null;
|
|
3936
|
+
const sendTexts = anon ? redacted.map(r => anon.toPseudo(r.text)) : redacted.map(r => r.text);
|
|
3937
|
+
const sendEntrypoints = anon ? entrypoints.map(e => JSON.parse(anon.toPseudo(JSON.stringify(e)))) : entrypoints;
|
|
3938
|
+
const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints: sendEntrypoints, packageTexts: sendTexts, contextHash: manifests[0].contextHash });
|
|
3651
3939
|
const est = (0,_packer_js__WEBPACK_IMPORTED_MODULE_6__/* .estimateTokens */ .bP)(prompt);
|
|
3652
3940
|
if (est.tokens > limits.maxTokensPerJob) return { state: 'skipped_by_policy', reason: `prompt is about ${est.tokens} tokens, over the ${limits.maxTokensPerJob} per-job limit` };
|
|
3653
3941
|
|
|
@@ -3659,7 +3947,9 @@ async function runReviewOnce({ pack, entrypoints, manifests, packageIndexes, pac
|
|
|
3659
3947
|
return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
|
|
3660
3948
|
}
|
|
3661
3949
|
const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
|
|
3662
|
-
const
|
|
3950
|
+
const rawReal = anon && typeof raw === 'string' ? anon.toReal(raw) : raw;
|
|
3951
|
+
const review0 = verifyReview({ raw: rawReal, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
|
|
3952
|
+
const review = { ...sanitizeReviewStrings(review0), modelProvenance: provenanceOf(model, promptHash), ...(anon ? { promptAnonymized: true } : {}) };
|
|
3663
3953
|
if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
|
|
3664
3954
|
return { state: 'succeeded', reason: null, review, usage };
|
|
3665
3955
|
}
|