@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
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
e7ecb2875e67c227bbc9a9643742632dbf486accded83c1ada02043d1c5307d1 agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.172.
|
|
3
|
+
"version": "0.172.1",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"@babel/core": "^8.0.5",
|
|
44
44
|
"@babel/preset-react": "^8.0.1",
|
|
45
45
|
"@babel/preset-typescript": "^8.0.1",
|
|
46
|
-
"js-yaml": "^5.
|
|
46
|
+
"js-yaml": "^5.5.0",
|
|
47
47
|
"safe-regex": "^2.1.1"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
50
|
"tree-sitter-wasms": "0.1.13",
|
|
51
51
|
"web-tree-sitter": "0.20.8",
|
|
52
|
-
"z3-solver": "^5.3.
|
|
52
|
+
"z3-solver": "^5.3.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/node": "^26.6.5",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"verification:replay-example": "node ../scripts/verification-replay-example.mjs",
|
|
122
122
|
"test:lifecycle": "node --test test/ci-hang-protection.test.js test/ci-test-shards.test.js test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/trust-boundary.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs test/workflow-confinement.test.js test/check-bundle-source.test.js test/ship.test.js",
|
|
123
123
|
"test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
|
|
124
|
-
"test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/git-diff-candidates.test.js",
|
|
124
|
+
"test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js test/context-intelligence/build-js.test.js test/context-intelligence/build-python-haskell.test.js test/context-intelligence/hie-adapter.test.js test/context-intelligence/store.test.js test/context-intelligence/review-queue.test.js test/context-intelligence/packer-egress.test.js test/context-intelligence/evaluation.test.js test/context-intelligence/queue-diff.test.js test/context-intelligence/cli.test.js test/context-intelligence/resilience.test.js test/context-intelligence/frameworks.test.js test/context-intelligence/skew-git-rls.test.js test/context-intelligence/premortem.test.js test/context-intelligence/git-diff-candidates.test.js",
|
|
125
125
|
"eval:independent": "node ../bench/independent-eval/runner.mjs",
|
|
126
126
|
"eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
|
|
127
127
|
"smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
|
|
@@ -10,6 +10,10 @@ Deterministic Context Intelligence (the `context` command). Opt-in and additive:
|
|
|
10
10
|
- Model output cannot set severity. A citation outside the bundle invalidates the review. Severity comes from the existing confirmation tier.
|
|
11
11
|
- Store writes are atomic and refuse links and unsafe paths. Stored packages are re-checked against their hashes.
|
|
12
12
|
- A job with no verified result is missing coverage, never a clean result.
|
|
13
|
+
- Names from the repository (route strings, file names) are data: sanitised before they are printed, never placed in prompt text outside the fence.
|
|
14
|
+
- Source reaches a non-loopback endpoint only with an explicit approval, and the egress policy still applies after it.
|
|
15
|
+
- A citation is checked for range AND for being about the cited lines. `no_issue_observed` needs a control the deterministic pass also found.
|
|
16
|
+
- Orderings that reach a hashed value use `cmpCodeUnit`, never the locale.
|
|
13
17
|
- Evaluation is synthetic. Scorecards must say so and must not state an accuracy claim. Holdout use is ledgered.
|
|
14
18
|
|
|
15
19
|
## Where things are
|
|
@@ -85,15 +85,42 @@ export function loadHieMetadata(rootReal, hieDir, { fileContents }) {
|
|
|
85
85
|
return { status: 'ok', tool: doc.tool, compiler: doc.compiler, declarations, references, exportHash: hashOf({ declarations, references }) };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function uncorroboratedReferences(graph, references, fileContents) {
|
|
89
|
+
const byName = new Map();
|
|
90
|
+
for (const sym of graph.symbols) {
|
|
91
|
+
if (sym.kind !== 'function' || sym.id.includes('::<anon>@')) continue;
|
|
92
|
+
if (!byName.has(sym.name)) byName.set(sym.name, []);
|
|
93
|
+
byName.get(sym.name).push(sym);
|
|
94
|
+
}
|
|
95
|
+
const bad = [];
|
|
96
|
+
for (const r of references) {
|
|
97
|
+
const owners = byName.get(r.from) || [];
|
|
98
|
+
if (owners.length !== 1) continue; // an ambiguous or unknown name is already counted as unmatched and adds nothing
|
|
99
|
+
const sym = owners[0];
|
|
100
|
+
const text = fileContents[sym.file];
|
|
101
|
+
const callee = String(r.to).split(/[.:]/).pop();
|
|
102
|
+
const lineText = typeof text === 'string' ? text.split('\n')[r.line - 1] : undefined;
|
|
103
|
+
const inside = r.line >= sym.startLine && r.line <= sym.endLine;
|
|
104
|
+
if (!inside || lineText === undefined || !callee || !lineText.includes(callee)) bad.push(`${r.from}->${r.to}@${r.line}`);
|
|
105
|
+
}
|
|
106
|
+
return bad;
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
/**
|
|
89
110
|
* Merge an imported export into the static graph. Returns { graph, semantic } where `semantic` carries the provenance
|
|
90
111
|
* and the static-versus-compiler difference report.
|
|
91
112
|
*/
|
|
92
113
|
export function mergeHieIntoGraph(graph, meta, fileContents) {
|
|
93
|
-
void fileContents;
|
|
94
114
|
if (meta.status !== 'ok') {
|
|
95
115
|
return { graph, semantic: { source: ['static-ir'], status: meta.status, reason: meta.reason || null, detail: meta.detail || null, diff: null } };
|
|
96
116
|
}
|
|
117
|
+
// The export is untrusted even when its file hashes match: it is checked into the repository it describes. A reference
|
|
118
|
+
// is accepted only if the line it names, inside the declaration it names, actually mentions the callee. One that does
|
|
119
|
+
// not degrades the whole import, so a crafted export cannot add edges that the source does not contain.
|
|
120
|
+
const uncorroborated = uncorroboratedReferences(graph, meta.references, fileContents || {});
|
|
121
|
+
if (uncorroborated.length) {
|
|
122
|
+
return { graph, semantic: { source: ['static-ir'], status: 'degraded', reason: 'reference-not-in-source', detail: uncorroborated.slice(0, 5).join(', '), diff: null } };
|
|
123
|
+
}
|
|
97
124
|
const idsByName = new Map();
|
|
98
125
|
for (const s of graph.symbols) {
|
|
99
126
|
if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
|
|
@@ -14,6 +14,31 @@ export function sha256(text) {
|
|
|
14
14
|
return crypto.createHash('sha256').update(text).digest('hex');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
|
|
19
|
+
* could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
|
|
20
|
+
*/
|
|
21
|
+
export function cmpCodeUnit(a, b) {
|
|
22
|
+
const x = String(a); const y = String(b);
|
|
23
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
|
|
27
|
+
// repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
|
|
28
|
+
// in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
|
|
29
|
+
const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
30
|
+
|
|
31
|
+
/** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
|
|
32
|
+
export function cleanForTerminal(text) {
|
|
33
|
+
return String(text).replace(UNSAFE_DISPLAY, '?');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
|
|
37
|
+
export function safeLabel(text, max = 120) {
|
|
38
|
+
const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
|
|
39
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
/** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
|
|
18
43
|
export function normalizeText(text) {
|
|
19
44
|
return String(text).replace(/\r\n?/g, '\n');
|
|
@@ -16,6 +16,7 @@ import { readStoreJson, writeStoreJson, readManifest, readPackageText, verifySto
|
|
|
16
16
|
import { diffManifestSets, cacheReport } from './diff.js';
|
|
17
17
|
import { readGitSnapshot, readSnapshot } from './files.js';
|
|
18
18
|
import { QUESTION_PACKS, packById } from './prompts/packs.js';
|
|
19
|
+
import { cleanForTerminal } from './canonical.js';
|
|
19
20
|
import { describeModel, resolveInvoker, buildReviewPrompt, redactPackageText, checkEgressForFiles } from './review.js';
|
|
20
21
|
import { planJobs, runQueue, exportBatch, importBatch, HARD_LIMITS } from './queue.js';
|
|
21
22
|
import { reviewsToCandidates, adjudicate } from './candidates.js';
|
|
@@ -26,21 +27,24 @@ const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
|
|
|
26
27
|
const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
|
|
27
28
|
const num = (v) => (v === undefined || v === true ? undefined : Number(v));
|
|
28
29
|
|
|
30
|
+
/** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
|
|
31
|
+
function say(text) { console.log(cleanForTerminal(text)); }
|
|
32
|
+
|
|
29
33
|
function out(format, text, obj) {
|
|
30
|
-
if (format === 'json')
|
|
31
|
-
else if (format === 'jsonl')
|
|
32
|
-
else
|
|
34
|
+
if (format === 'json') say(JSON.stringify(obj, null, 2));
|
|
35
|
+
else if (format === 'jsonl') say(JSON.stringify(obj));
|
|
36
|
+
else say(text);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
function usage() {
|
|
36
|
-
|
|
40
|
+
say([
|
|
37
41
|
'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
|
|
38
42
|
'',
|
|
39
43
|
' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
|
|
40
44
|
' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
|
|
41
45
|
' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
|
|
42
46
|
' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
|
|
43
|
-
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--concurrency N]',
|
|
47
|
+
' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
|
|
44
48
|
' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
|
|
45
49
|
' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
|
|
46
50
|
' status Coverage so far: entry points, bundles, review jobs',
|
|
@@ -62,7 +66,7 @@ async function doBuild(root, args, { write = true } = {}) {
|
|
|
62
66
|
if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
|
|
63
67
|
const events = [];
|
|
64
68
|
const jsonl = flag(args, 'format') === 'jsonl';
|
|
65
|
-
const built = await buildContext(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl)
|
|
69
|
+
const built = await buildContext(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
|
|
66
70
|
if (!built.ok) return { ok: false, built, config, problems };
|
|
67
71
|
let persisted = null;
|
|
68
72
|
if (write) persisted = persistBuild(root, built, config);
|
|
@@ -155,7 +159,7 @@ async function cmdInspect(root, args, format) {
|
|
|
155
159
|
` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
|
|
156
160
|
];
|
|
157
161
|
if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
|
|
158
|
-
|
|
162
|
+
say(lines.join('\n'));
|
|
159
163
|
return 0;
|
|
160
164
|
}
|
|
161
165
|
|
|
@@ -255,7 +259,7 @@ async function cmdExport(root, args, format) {
|
|
|
255
259
|
if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
|
|
256
260
|
const r = exportBundle(root, String(h));
|
|
257
261
|
if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
|
|
258
|
-
|
|
262
|
+
say(JSON.stringify(r.export, null, 2));
|
|
259
263
|
return 0;
|
|
260
264
|
}
|
|
261
265
|
|
|
@@ -303,7 +307,7 @@ async function cmdReview(root, args, format) {
|
|
|
303
307
|
const r = await doBuild(root, args, { write: true });
|
|
304
308
|
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; }
|
|
305
309
|
const { built } = r;
|
|
306
|
-
const model = describeModel();
|
|
310
|
+
const model = describeModel({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
|
|
307
311
|
const { jobs, droppedByJobLimit } = planJobs({ bundles: built.bundles, packs, model, maxJobs: Math.min(HARD_LIMITS.maxJobs, num(flag(args, 'max-jobs')) || HARD_LIMITS.maxJobs) });
|
|
308
312
|
const loadJob = loadJobFrom(built, packs);
|
|
309
313
|
const ctxBase = { root: built.root, fileContents: built.fileContents, model };
|
|
@@ -341,7 +345,7 @@ async function cmdReview(root, args, format) {
|
|
|
341
345
|
try {
|
|
342
346
|
result = await runQueue(jobs, {
|
|
343
347
|
...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
|
|
344
|
-
onEvent: jsonl ? (e) =>
|
|
348
|
+
onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
|
|
345
349
|
limits: {
|
|
346
350
|
concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
|
|
347
351
|
maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
|
|
@@ -22,7 +22,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
22
22
|
maxFiles: 3000,
|
|
23
23
|
semanticMetadata: { mode: 'off', haskellHieDir: null },
|
|
24
24
|
context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
|
|
25
|
-
review: { enabled: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
25
|
+
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
26
26
|
replay: { contentAddressed: true, retention: 'inherit' },
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -63,6 +63,7 @@ export function normalizeConfig(raw) {
|
|
|
63
63
|
},
|
|
64
64
|
review: {
|
|
65
65
|
enabled: rv.enabled === true,
|
|
66
|
+
allowRemote: rv.allowRemote === true,
|
|
66
67
|
questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
|
|
67
68
|
concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
|
|
68
69
|
maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
|
|
@@ -47,7 +47,46 @@ export function signatureText(lines, declLine, maxLines = 12) {
|
|
|
47
47
|
for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
|
|
48
48
|
return t;
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
// An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
|
|
51
|
+
// qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
|
|
52
|
+
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';
|
|
53
|
+
const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
|
|
54
|
+
const TENANT_WORD = /tenant|org|workspace|team|account/i;
|
|
55
|
+
// a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
|
|
56
|
+
const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
|
|
57
|
+
// an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
|
|
58
|
+
const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
|
|
59
|
+
const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
|
|
60
|
+
const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
|
|
64
|
+
* same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
|
|
65
|
+
* `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
|
|
66
|
+
* principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
|
|
67
|
+
*/
|
|
68
|
+
export function ownerBinding(text, extra = null) {
|
|
69
|
+
let best = null;
|
|
70
|
+
const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
|
|
71
|
+
const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
|
|
72
|
+
for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
|
|
73
|
+
// the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
|
|
74
|
+
const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
|
|
75
|
+
if (!OWNER_KEY_RE.test(forKey)) continue;
|
|
76
|
+
const pr = principalIn(clause, extra);
|
|
77
|
+
if (pr) take(pr, TENANT_WORD.test(clause));
|
|
78
|
+
}
|
|
79
|
+
for (const m of text.match(STRING_LITERAL) || []) {
|
|
80
|
+
const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
|
|
81
|
+
if (placeholder) {
|
|
82
|
+
const pr = principalIn(text, extra);
|
|
83
|
+
if (pr) take(pr, TENANT_WORD.test(m));
|
|
84
|
+
}
|
|
85
|
+
if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
|
|
52
91
|
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;
|
|
53
92
|
|
|
@@ -58,6 +97,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
|
|
|
58
97
|
const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
|
|
59
98
|
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\.)/;
|
|
60
99
|
|
|
100
|
+
// Names that answer a question rather than enforce a rule.
|
|
101
|
+
const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
|
|
102
|
+
function bareCallStatement(trimmedLine, name) {
|
|
103
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
104
|
+
return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
|
|
62
108
|
|
|
63
109
|
function kindOfAuthName(name) {
|
|
@@ -132,8 +178,12 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
132
178
|
}
|
|
133
179
|
for (const name of calls) {
|
|
134
180
|
if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
|
|
135
|
-
const
|
|
136
|
-
|
|
181
|
+
const lastSeg = name.split(/[.:]/).pop();
|
|
182
|
+
// A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
|
|
183
|
+
// so it guards nothing, whatever the lines around it say.
|
|
184
|
+
const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
|
|
185
|
+
const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
|
|
186
|
+
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) });
|
|
137
187
|
}
|
|
138
188
|
const last = name.split(/[.:]/).pop();
|
|
139
189
|
const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
|
|
@@ -149,9 +199,9 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
149
199
|
push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
|
|
150
200
|
// a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
|
|
151
201
|
const stmt = statementAt(lines, ln);
|
|
152
|
-
const pr = sub.startsWith('data-')
|
|
202
|
+
const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
|
|
153
203
|
if (pr) {
|
|
154
|
-
push({ line: ln, category: 'control', kind:
|
|
204
|
+
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) });
|
|
155
205
|
}
|
|
156
206
|
}
|
|
157
207
|
}
|
|
@@ -159,17 +209,17 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
159
209
|
const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
|
|
160
210
|
push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
|
|
161
211
|
const stmt = statementAt(lines, ln);
|
|
162
|
-
const pr =
|
|
212
|
+
const pr = ownerBinding(stmt, extraPrincipal);
|
|
163
213
|
if (pr) {
|
|
164
|
-
push({ line: ln, category: 'control', kind:
|
|
214
|
+
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) });
|
|
165
215
|
}
|
|
166
216
|
}
|
|
167
217
|
|
|
168
218
|
// a resource field compared with the principal: the ownership check itself
|
|
169
|
-
const cmpPr = COMPARE.test(raw) &&
|
|
219
|
+
const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
|
|
170
220
|
if (cmpPr) {
|
|
171
221
|
const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
|
|
172
|
-
push({ line: ln, category: 'control', kind:
|
|
222
|
+
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) });
|
|
173
223
|
}
|
|
174
224
|
}
|
|
175
225
|
// A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// not resolve, a handler matched by name only, a router whose mount point is not visible
|
|
13
13
|
// unresolved no handler could be identified; the manifest still exists and says so
|
|
14
14
|
// excluded_by_policy deliberately not processed (operator exclusion, or the entry-point cap)
|
|
15
|
-
import { hashOf } from './canonical.js';
|
|
15
|
+
import { hashOf, safeLabel } from './canonical.js';
|
|
16
16
|
import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
|
|
17
17
|
import { dependencyParams, signatureText } from './controls.js';
|
|
18
18
|
import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
|
|
@@ -313,7 +313,7 @@ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
|
|
|
313
313
|
const n = (ord.get(stem) || 0) + 1;
|
|
314
314
|
ord.set(stem, n);
|
|
315
315
|
r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
|
|
316
|
-
r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}
|
|
316
|
+
r.label = safeLabel(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
|
|
317
317
|
void hname;
|
|
318
318
|
let disposition;
|
|
319
319
|
const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
|
|
@@ -92,6 +92,8 @@ export function metricsFor(units, protocolHash, name) {
|
|
|
92
92
|
precision: precisionOf(c), recall: recallOf(c), f1: f1Of(c),
|
|
93
93
|
precisionInterval: pn > 0 ? wilsonInterval(c.tp, pn) : { status: 'unmeasured', reason: 'nothing was reported' },
|
|
94
94
|
f1Interval: f1,
|
|
95
|
+
// A point estimate with no interval around it reads as a finding. It is reportable only when the interval is measured.
|
|
96
|
+
reportable: f1 && f1.status === 'measured',
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -127,6 +129,9 @@ function readLedger(root) {
|
|
|
127
129
|
const pct = (x) => (x === null || x === undefined ? 'n/a' : (x * 100).toFixed(0) + '%');
|
|
128
130
|
const interval = (i) => (!i || i.status !== 'measured' ? `unmeasured (${i && i.reason ? i.reason.split(',')[0] : 'no data'})` : `${pct(i.low)} to ${pct(i.high)}`);
|
|
129
131
|
|
|
132
|
+
// Precision, recall and F1 are printed only when their interval is measured; otherwise the counts stand alone.
|
|
133
|
+
const shown = (m, x) => (m.reportable ? pct(x) : 'withheld');
|
|
134
|
+
|
|
130
135
|
export function renderMarkdown(card) {
|
|
131
136
|
const L = [];
|
|
132
137
|
L.push('# Context Intelligence evaluation scorecard', '');
|
|
@@ -141,18 +146,20 @@ export function renderMarkdown(card) {
|
|
|
141
146
|
for (const s of card.systems) {
|
|
142
147
|
if (s.status !== 'measured') { L.push(`| ${s.system} | ${s.status}: ${s.reason} | | | | | | | | | | |`); continue; }
|
|
143
148
|
const m = s.metrics;
|
|
144
|
-
L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${
|
|
149
|
+
L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${shown(m, m.precision)} | ${shown(m, m.recall)} | ${shown(m, m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
|
|
145
150
|
}
|
|
146
151
|
L.push('', '## Economics (estimated; no price supplied)', '', '| system | median ctx tokens | P95 | P99 | model calls | calls per true positive | wall ms |', '|---|---|---|---|---|---|---|');
|
|
147
|
-
for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null ? '
|
|
152
|
+
for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null || !s.metrics || !s.metrics.reportable ? 'withheld' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
|
|
153
|
+
L.push('', 'Precision, recall, F1 and calls per true positive are withheld while their interval is unmeasured (fewer than 10 independent groups). The counts above are what was observed; they are not an estimate of anything.');
|
|
148
154
|
L.push('', `Replay: \`${card.replay}\``, '');
|
|
149
155
|
if (card.leakage.length) { L.push('## Leakage hits', ...card.leakage.map(h => `- ${h.where} ${h.file || ''} ${h.term}`), ''); }
|
|
150
156
|
return L.join('\n');
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export function renderCsv(card) {
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
// The marker is a column, not a banner: a CSV is the format that gets pasted into a spreadsheet without its header text.
|
|
161
|
+
const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars', 'synthetic']];
|
|
162
|
+
for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars, card.synthetic === false ? false : true]);
|
|
156
163
|
return rows.map(r => r.join(',')).join('\n') + '\n';
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -189,6 +189,16 @@ function stripUntrustedKeepUser(t) { return stripUntrusted(t).replace(/""/g, '""
|
|
|
189
189
|
|
|
190
190
|
function cite(l, role, claim) { return { role, file: l.file, startLine: l.n, endLine: l.n, claim }; }
|
|
191
191
|
|
|
192
|
+
/** A reader that cites a line says what is on it: the claim quotes the cited text, as a careful model would. */
|
|
193
|
+
function quoteCited(obj, blocks) {
|
|
194
|
+
const text = (c) => {
|
|
195
|
+
for (const b of blocks) if (b.file === c.file) { const l = b.lines.find(x => x.n === c.startLine); if (l) return l.text.trim().slice(0, 100); }
|
|
196
|
+
return '';
|
|
197
|
+
};
|
|
198
|
+
const fix = (list) => (Array.isArray(list) ? list.map(c => ({ ...c, claim: `${c.claim}: ${text(c)}` })) : list);
|
|
199
|
+
return { ...obj, evidence: fix(obj.evidence), observedMitigations: fix(obj.observedMitigations) };
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
/** The decision for a pack, from the observations. Returns the review JSON object. */
|
|
193
203
|
export function decide(packId, obs, ctx) {
|
|
194
204
|
const base = { observedMitigations: [], attackerControl: [], uncertainties: [], likelihood: 'low' };
|
|
@@ -237,7 +247,7 @@ export function scriptedReader() {
|
|
|
237
247
|
const invoke = async (prompt) => {
|
|
238
248
|
if (/^QUESTION PACK: /m.test(prompt)) {
|
|
239
249
|
const packId = /^QUESTION PACK: ([\w-]+)\//m.exec(prompt)[1];
|
|
240
|
-
const label = /^ENTRY POINT
|
|
250
|
+
const label = (/^ENTRY POINT LABEL \(data\): (.*)$/m.exec(prompt) || [])[1] || '';
|
|
241
251
|
const { blocks, unresolved, notIncluded, limit } = blocksFromReviewPrompt(prompt);
|
|
242
252
|
const routes = findRoutes(blocks);
|
|
243
253
|
const [method, ...rest] = label.split(' ');
|
|
@@ -246,7 +256,7 @@ export function scriptedReader() {
|
|
|
246
256
|
if (!route) return JSON.stringify({ evidence: [], observedMitigations: [], conclusion: 'insufficient_evidence', invariant: 'unspecified', uncertainties: ['the registration is not in the provided context'] });
|
|
247
257
|
const obs = analyseRoute(route, blocks);
|
|
248
258
|
const complete = !unresolved && !notIncluded && !limit;
|
|
249
|
-
return JSON.stringify(decide(packId, obs, { complete, unresolved }));
|
|
259
|
+
return JSON.stringify(quoteCited(decide(packId, obs, { complete, unresolved }), blocks));
|
|
250
260
|
}
|
|
251
261
|
// hunt prompt: report the candidates for the lens, with a location
|
|
252
262
|
const lens = /Your lens is ([\w ]+)\./.exec(prompt);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// * lose a control. Middleware and every callee are walked, and each visited symbol's controls are collected by the
|
|
13
13
|
// same classifier the packer uses to refuse pruning.
|
|
14
14
|
import { analyzeSymbolText } from './controls.js';
|
|
15
|
+
import { cmpCodeUnit } from './canonical.js';
|
|
15
16
|
import { MATERIAL_REASONS } from './graph-schema.js';
|
|
16
17
|
|
|
17
18
|
export const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
|
|
@@ -70,7 +71,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// middleware runs before the handler: it is enqueued first and ordered at line -1
|
|
73
|
-
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) ||
|
|
74
|
+
for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || cmpCodeUnit(a.name, b.name))) {
|
|
74
75
|
if (mw.symbolId && byId.has(mw.symbolId)) enqueue(mw.symbolId, 0, mw.inherited ? 'inherited-middleware' : 'middleware', -1, [mw.symbolId], mw.resolution === 'resolved' ? 'exact' : 'name-match');
|
|
75
76
|
}
|
|
76
77
|
if (root.symbolId) {
|
|
@@ -163,11 +164,14 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
|
|
|
163
164
|
for (const dim of PROTECTION_DIMENSIONS) {
|
|
164
165
|
const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
|
|
165
166
|
const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
|
|
166
|
-
|
|
167
|
+
// A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
|
|
168
|
+
const effective = (f) => f.rejectionSeen !== false;
|
|
169
|
+
const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
|
|
167
170
|
const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
|
|
168
171
|
let state; let why;
|
|
169
172
|
if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
|
|
170
173
|
else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
|
|
174
|
+
else if (ctl.length && ctl.every(f => !effective(f))) { state = 'evaluated_and_ineffective'; why = 'a check is called but nothing in view acts on its answer'; }
|
|
171
175
|
else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
|
|
172
176
|
else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
|
|
173
177
|
else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
|
|
@@ -175,6 +179,8 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
|
|
|
175
179
|
else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
|
|
176
180
|
dims[dim] = {
|
|
177
181
|
state, why, firstSensitiveLine: firstSens,
|
|
182
|
+
// How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
|
|
183
|
+
strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
|
|
178
184
|
evidence: ctl.slice(0, 6).map(f => ({ file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, order: firstSens === null ? 'no-sensitive-op' : f.effLine < firstSens ? 'before-sensitive' : f.effLine > firstSens ? 'after-sensitive' : 'same-step', rejectionSeen: f.rejectionSeen })),
|
|
179
185
|
};
|
|
180
186
|
}
|