@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 +34 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The capability runner (X-502, X-503, X-504): where a manifest stops being a
|
|
2
|
+
// description and becomes a boundary.
|
|
3
|
+
//
|
|
4
|
+
// `runCapabilityTask(bound, request, opts)` executes ONE structured command (an
|
|
5
|
+
// absolute executable plus an argument array, never a shell string) for one
|
|
6
|
+
// bound task, and what it can touch is decided by the operating system, not by
|
|
7
|
+
// this file or by a hook:
|
|
8
|
+
//
|
|
9
|
+
// filesystem reads are confined to the declared roots (plus the handful of
|
|
10
|
+
// system paths a process needs to start), writes to the declared
|
|
11
|
+
// write roots and a private per-run scratch directory, protected
|
|
12
|
+
// host paths are read-denied on top; a symbolic link or `..` that
|
|
13
|
+
// leaves the roots is stopped by the kernel
|
|
14
|
+
// commands the executable and arguments are checked against the manifest
|
|
15
|
+
// first (decide.js); the argument array reaches `exec` as an array,
|
|
16
|
+
// so command substitution and expansion in an argument stay text;
|
|
17
|
+
// shells, language runtimes and launchers are refused unless the
|
|
18
|
+
// manifest declares them scoped with their exact arguments
|
|
19
|
+
// network no network at all unless the manifest declares destinations; then
|
|
20
|
+
// exactly one door, the loopback mediation proxy (proxy.js); the
|
|
21
|
+
// proxy settings are in the environment of the whole process tree,
|
|
22
|
+
// and a socket opened any other way is refused by the kernel
|
|
23
|
+
// descendants inherit all of the above (the confinement is inherited by every
|
|
24
|
+
// child), are tracked, and are terminated on deadline,
|
|
25
|
+
// cancellation, output flood or exit, with the evidence returned
|
|
26
|
+
//
|
|
27
|
+
// Gate, in order, each step typed and none of them a warning:
|
|
28
|
+
// 1. the `capability-enforcement` feature (operator-only: a project file cannot
|
|
29
|
+
// turn it on), then the kill switches
|
|
30
|
+
// 2. the platform: only an advertised backend is `enforced`. A backend that is
|
|
31
|
+
// not advertised (macOS) is `unsupported` for isolation-required tasks
|
|
32
|
+
// unless the caller opts in with `allowUnadvertisedBackend`, in which case
|
|
33
|
+
// the run is labelled `host-proved` and nothing is claimed enforced
|
|
34
|
+
// 3. the binding, the secret-free environment, the command policy, protected
|
|
35
|
+
// paths
|
|
36
|
+
// 4. the active probes: any control the manifest depends on that is not
|
|
37
|
+
// `proved` BLOCKS the run, and the target is never executed
|
|
38
|
+
//
|
|
39
|
+
// Incomplete backend controls block; they are never a warning and never a quiet
|
|
40
|
+
// fallback to less. On Linux the namespace backend does not implement the
|
|
41
|
+
// controls this needs yet, so every Linux run is blocked with the missing control
|
|
42
|
+
// named, and the platform statement says `unverified`.
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import os from 'node:os';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { resolveAssuranceConfig, featureStatus, typed, limitValues } from '../posture/assurance/config.js';
|
|
47
|
+
import { detectBackend } from '../sandbox/capabilities.js';
|
|
48
|
+
import { runConfinedSupervised } from '../sandbox/supervise.js';
|
|
49
|
+
import { protectedReadPaths, assertNoSecretMaterial } from '../sandbox/trust-domains.js';
|
|
50
|
+
import { mediate } from './recovery.js';
|
|
51
|
+
import { reasonText } from './reasons.js';
|
|
52
|
+
import { canonicalPath, overlaps } from './paths.js';
|
|
53
|
+
import { probeCapabilityControls, requiredControlsFor, unmetControls, isAdvertisedBackend } from './probes.js';
|
|
54
|
+
import { startMediationProxy } from './proxy.js';
|
|
55
|
+
import { sanitizeLogText } from './outbound.js';
|
|
56
|
+
import { toCapabilityDecisionRecord } from './records.js';
|
|
57
|
+
import { buildCapabilityReport } from './report.js';
|
|
58
|
+
|
|
59
|
+
const FEATURE = 'capability-enforcement';
|
|
60
|
+
|
|
61
|
+
function blocked(code, reason, extra = {}) {
|
|
62
|
+
return typed('blocked', code, reason, { executed: false, outcome: 'not-run', ...extra });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// X-506: a denial carries the missing capability and a reviewable proposed manifest
|
|
66
|
+
// change. The proposal is data for an operator; nothing here applies it.
|
|
67
|
+
function denied(m, extra = {}) {
|
|
68
|
+
const d = m.decision;
|
|
69
|
+
return blocked('capability-denied', `${d.code}: ${d.reason}`, {
|
|
70
|
+
policyCode: d.code, decisions: [d], missing: m.missing, proposal: m.proposal,
|
|
71
|
+
attemptsRemaining: m.attemptsRemaining, exhausted: m.exhausted, ...extra,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scrub(text, canaries) {
|
|
76
|
+
return sanitizeLogText(text, canaries);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{manifest: object, binding: object}} bound from `bindManifest`
|
|
81
|
+
* @param {{executable: string, args?: string[], env?: object, cwd?: string}} request
|
|
82
|
+
* @param {object} opts
|
|
83
|
+
* @param {{taskId:string, revision:string, policyVersion:number}} opts.binding the identity the caller acts for
|
|
84
|
+
* @param {object} [opts.config] resolved assurance config; default resolves from `opts.scanRoot` and the environment
|
|
85
|
+
* @param {string} [opts.scanRoot]
|
|
86
|
+
* @param {string[]} [opts.labelDirs] sealed-label directories (never readable by the task)
|
|
87
|
+
* @param {string[]} [opts.evidenceDirs] authoritative-evidence directories (never readable by the task)
|
|
88
|
+
* @param {string[]} [opts.protectedPaths] further paths the task may never read
|
|
89
|
+
* @param {string[]} [opts.canaries] values that must not appear in arguments, environment, outbound payloads or returned output
|
|
90
|
+
* @param {boolean} [opts.allowUnadvertisedBackend] run on a host-proved, non-advertised backend (development)
|
|
91
|
+
* @param {AbortSignal} [opts.signal]
|
|
92
|
+
* @param {string} [opts.force] backend override (tests: 'disabled')
|
|
93
|
+
* @param {object} [opts.controlProbes] probe overrides (test seam)
|
|
94
|
+
* @param {(host:string)=>Promise<string[]>} [opts.resolve] name resolution seam for the proxy
|
|
95
|
+
* @param {string} [opts.home] home directory whose credential directories are protected
|
|
96
|
+
* @param {object} [opts.denialGuard] from recovery.js `createDenialGuard`: bounds repeated denials of one action (X-506.AC03)
|
|
97
|
+
*/
|
|
98
|
+
export async function runCapabilityTask(bound, request, opts = {}) {
|
|
99
|
+
const {
|
|
100
|
+
binding, scanRoot = null, labelDirs = [], evidenceDirs = [], protectedPaths: extraProtected = [], canaries = [],
|
|
101
|
+
allowUnadvertisedBackend = false, signal, force, controlProbes, resolve, home, denialGuard,
|
|
102
|
+
} = opts;
|
|
103
|
+
|
|
104
|
+
if (!request || typeof request !== 'object') return blocked('invalid-config', 'no task request');
|
|
105
|
+
|
|
106
|
+
// 1. feature gate
|
|
107
|
+
const config = opts.config || resolveAssuranceConfig({ scanRoot: scanRoot || undefined });
|
|
108
|
+
const gate = featureStatus(config, FEATURE);
|
|
109
|
+
let platformOptIn = false;
|
|
110
|
+
if (gate.status === 'unsupported' && gate.code === 'platform-unsupported' && allowUnadvertisedBackend) platformOptIn = true;
|
|
111
|
+
else if (gate.status !== 'ok') return { ...gate, executed: false, outcome: 'not-run' };
|
|
112
|
+
|
|
113
|
+
if (!bound?.manifest || !bound?.binding) return blocked('invalid-config', 'no bound capability manifest');
|
|
114
|
+
const ctx = { binding, canaries, protectedPaths: [] };
|
|
115
|
+
|
|
116
|
+
// 3a. a secret-free environment, before anything else looks at the request
|
|
117
|
+
const env = request && typeof request.env === 'object' && request.env ? request.env : {};
|
|
118
|
+
const secrets = assertNoSecretMaterial(env);
|
|
119
|
+
if (!secrets.ok) return blocked('capability-denied', `secret-looking environment variables are refused for tasks: ${secrets.offenders.join(', ')}`, { policyCode: 'secret-in-argument' });
|
|
120
|
+
for (const [k, v] of Object.entries(env)) {
|
|
121
|
+
if (typeof v !== 'string') return blocked('capability-denied', `environment variable ${k} is not a string`, { policyCode: 'args-invalid' });
|
|
122
|
+
if (canaries.some((c) => c && String(v).includes(c))) return blocked('capability-denied', `environment variable ${k} carries a registered canary`, { policyCode: 'secret-in-argument' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3b. command policy (also checks the binding)
|
|
126
|
+
const protectedPaths = protectedReadPaths({ ...(home ? { home } : {}), labelDirs, evidenceDirs, extra: extraProtected });
|
|
127
|
+
ctx.protectedPaths = protectedPaths;
|
|
128
|
+
const cmdM = mediate(bound, { kind: 'command', executable: request?.executable, args: request?.args ?? [] }, ctx, { guard: denialGuard });
|
|
129
|
+
const cmd = cmdM.decision;
|
|
130
|
+
if (cmdM.status !== 'ok') return denied(cmdM);
|
|
131
|
+
|
|
132
|
+
// 3c. the manifest's own roots may not contain protected paths
|
|
133
|
+
const m = bound.manifest;
|
|
134
|
+
const roots = [...m.filesystem.read, ...m.filesystem.write];
|
|
135
|
+
for (const r of roots) {
|
|
136
|
+
const cr = canonicalPath(r) ?? r;
|
|
137
|
+
const clash = protectedPaths.find((p) => overlaps(cr, canonicalPath(p) ?? p));
|
|
138
|
+
if (clash) return blocked('capability-denied', `protected-path: ${reasonText('protected-path')}`, { policyCode: 'protected-path', decisions: [] });
|
|
139
|
+
}
|
|
140
|
+
let cwdRequest = null;
|
|
141
|
+
if (request.cwd !== undefined) {
|
|
142
|
+
const cwdM = mediate(bound, { kind: 'filesystem-read', path: request.cwd }, ctx, { guard: denialGuard });
|
|
143
|
+
if (cwdM.status !== 'ok') return denied(cwdM);
|
|
144
|
+
cwdRequest = request.cwd;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2/4. backend and active probes
|
|
148
|
+
const backend = detectBackend({ force });
|
|
149
|
+
if (backend === 'disabled') {
|
|
150
|
+
return typed('blocked', 'missing-execution-backend', 'no confinement backend works on this host; the task was not executed', { executed: false, outcome: 'not-run', backend });
|
|
151
|
+
}
|
|
152
|
+
const advertised = isAdvertisedBackend(backend);
|
|
153
|
+
if (!advertised && !allowUnadvertisedBackend) {
|
|
154
|
+
return typed('unsupported', 'platform-unsupported',
|
|
155
|
+
`the ${backend} backend on ${process.platform} is not an advertised backend for enforced capability tasks; isolation-required tasks are unsupported here`,
|
|
156
|
+
{ executed: false, outcome: 'not-run', backend });
|
|
157
|
+
}
|
|
158
|
+
const probeReport = await probeCapabilityControls({ force, probes: controlProbes || {} });
|
|
159
|
+
const required = requiredControlsFor(m);
|
|
160
|
+
const unmet = unmetControls(probeReport, required);
|
|
161
|
+
if (unmet.length) {
|
|
162
|
+
return typed('blocked', 'missing-execution-backend',
|
|
163
|
+
`incomplete backend controls block the task: ${unmet.map((u) => `${u.control} is ${u.state}`).join('; ')}`,
|
|
164
|
+
{
|
|
165
|
+
executed: false, outcome: 'not-run', backend, unmet,
|
|
166
|
+
report: buildCapabilityReport({ bound, probeReport, level: 'none', required }),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const level = advertised ? 'enforced' : 'host-proved';
|
|
170
|
+
|
|
171
|
+
// 5. build the confinement and run
|
|
172
|
+
const scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-cap-run-')));
|
|
173
|
+
let proxy = null;
|
|
174
|
+
try {
|
|
175
|
+
const exeReal = fs.realpathSync(request.executable);
|
|
176
|
+
const limits = limitValues(config);
|
|
177
|
+
const timeoutMs = m.resources.timeoutMs ?? limits.timeoutMs;
|
|
178
|
+
const maxOutputBytes = m.resources.maxOutputBytes ?? limits.maxOutputBytes;
|
|
179
|
+
const taskEnv = { ...env };
|
|
180
|
+
if (m.network.length) {
|
|
181
|
+
proxy = await startMediationProxy({ bound, canaries, resolve, scanRoot });
|
|
182
|
+
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']) taskEnv[k] = proxy.url;
|
|
183
|
+
taskEnv.NO_PROXY = ''; taskEnv.no_proxy = '';
|
|
184
|
+
}
|
|
185
|
+
const cwd = cwdRequest || (m.filesystem.write[0] ?? scratch);
|
|
186
|
+
const r = await runConfinedSupervised([request.executable, ...(request.args ?? [])], {
|
|
187
|
+
root: scratch, cwd,
|
|
188
|
+
readRoots: [...m.filesystem.read, ...m.filesystem.write, exeReal],
|
|
189
|
+
writeRoots: m.filesystem.write,
|
|
190
|
+
denyReadPaths: protectedPaths,
|
|
191
|
+
env: taskEnv, networkProxyPort: proxy ? proxy.port : null,
|
|
192
|
+
timeoutMs, graceMs: 1000, maxOutputBytes, signal, force,
|
|
193
|
+
limits: m.resources.maxFileSizeKb ? { maxFileSizeKb: m.resources.maxFileSizeKb } : {},
|
|
194
|
+
});
|
|
195
|
+
const executed = r.supervised === true;
|
|
196
|
+
if (!executed) {
|
|
197
|
+
return typed('blocked', 'missing-execution-backend', `the confined run could not start: ${scrub(String(r.stderr || '').split('\n')[0], canaries)}`, {
|
|
198
|
+
executed: false, outcome: 'not-run', backend, report: buildCapabilityReport({ bound, probeReport, level: 'none', required }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const term = r.termination || { signalled: null, killedPids: [], survivors: [] };
|
|
202
|
+
let outcome = 'exited';
|
|
203
|
+
if (r.cancelled) outcome = 'cancelled';
|
|
204
|
+
else if (r.timedOut) outcome = 'timeout';
|
|
205
|
+
else if (r.outputCapped) outcome = 'output-limit';
|
|
206
|
+
const cleanup = { signalled: term.signalled, killedPids: [...term.killedPids], survivors: [...term.survivors], complete: term.survivors.length === 0 };
|
|
207
|
+
const report = buildCapabilityReport({ bound, probeReport, level, required });
|
|
208
|
+
const enforced = level === 'enforced';
|
|
209
|
+
const mk = (d, mediation) => toCapabilityDecisionRecord(d, { mediation, enforced, backend, probeDigest: probeReport.probeDigest });
|
|
210
|
+
const records = [mk(cmd, 'runner')];
|
|
211
|
+
for (const root of m.filesystem.read) records.push(mk({ ...cmd, kind: 'filesystem-read', subject: root }, 'runner'));
|
|
212
|
+
for (const root of m.filesystem.write) records.push(mk({ ...cmd, kind: 'filesystem-write', subject: root }, 'runner'));
|
|
213
|
+
if (proxy) {
|
|
214
|
+
for (const n of proxy.records) {
|
|
215
|
+
records.push(mk({
|
|
216
|
+
kind: 'network', decision: n.outcome, code: n.code, reason: reasonText(n.code), taskId: m.taskId,
|
|
217
|
+
subject: `${n.scheme ?? 'unknown'}://${n.destinationClass}#${n.hostDigest}:${n.port ?? 0}`,
|
|
218
|
+
}, 'proxy'));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const status = cleanup.complete ? 'ok' : 'degraded';
|
|
222
|
+
return typed(status, cleanup.complete ? null : 'limit-exceeded',
|
|
223
|
+
cleanup.complete ? `ran under ${level} confinement (${backend})` : 'processes survived termination', {
|
|
224
|
+
executed: true, outcome, level, enforced, backend, exitCode: r.exitCode, denied: r.denied === true,
|
|
225
|
+
timedOut: r.timedOut === true, cancelled: r.cancelled === true, outputCapped: r.outputCapped === true,
|
|
226
|
+
// Everything the task printed is data from an untrusted party.
|
|
227
|
+
output: { trust: 'untrusted', stdout: scrub(r.stdout, canaries), stderr: scrub(r.stderr, canaries) },
|
|
228
|
+
cleanup, decisions: [cmd], capabilityDecisions: records.filter((x) => x.ok).map((x) => x.record),
|
|
229
|
+
network: proxy ? { records: [...proxy.records], stats: { ...proxy.stats } } : null,
|
|
230
|
+
report,
|
|
231
|
+
});
|
|
232
|
+
} finally {
|
|
233
|
+
if (proxy) await proxy.close();
|
|
234
|
+
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// One secret scrubber for the capability layer: provider-shaped credentials
|
|
2
|
+
// (mcp/redact.js, which is the list the scanner's own detector mirrors), then
|
|
3
|
+
// assignment, bearer, connection-string and high-entropy-literal shapes
|
|
4
|
+
// (llm-validator/redact.js). Pure. The count is of replacements made, so a
|
|
5
|
+
// caller can ask "did this text carry a secret" without keeping the secret.
|
|
6
|
+
import { redactSecretShapes } from '../mcp/redact.js';
|
|
7
|
+
import { redactSecrets } from '../llm-validator/redact.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when the text carries a provider-shaped credential or a quoted credential
|
|
11
|
+
* assignment. The high-entropy-literal heuristic is deliberately NOT used here:
|
|
12
|
+
* an argument list is mostly paths and scripts, and refusing every long quoted
|
|
13
|
+
* path would make the policy unusable. Scrubbing for output and logs (below) is
|
|
14
|
+
* allowed to be broader than refusing an argument.
|
|
15
|
+
*/
|
|
16
|
+
export function detectSecretShapes(text) {
|
|
17
|
+
return typeof text === 'string' && redactSecretShapes(text).redactions > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function scrubSecretText(text) {
|
|
21
|
+
if (typeof text !== 'string' || !text) return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
22
|
+
const a = redactSecretShapes(text);
|
|
23
|
+
let b;
|
|
24
|
+
try { b = redactSecrets(a.text); } catch { return { text: '[unprintable]', redactions: a.redactions + 1 }; }
|
|
25
|
+
return { text: b.text, redactions: a.redactions + b.redactions };
|
|
26
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The MCP tool gate (X-505.AC01, X-505.AC03).
|
|
2
|
+
//
|
|
3
|
+
// Every tool call is checked against the SAME decision function the runner and
|
|
4
|
+
// the hooks use (`decide`), for the CURRENT task identity, before its handler
|
|
5
|
+
// runs. The identity is the one the operator bound to the server; nothing the
|
|
6
|
+
// calling agent sends can change it: tool arguments are closed schemas, and an
|
|
7
|
+
// identity named in the request metadata is compared with the bound one and
|
|
8
|
+
// refused when it differs (`identity-spoofed`).
|
|
9
|
+
//
|
|
10
|
+
// no policy, feature off the gate is inactive: behaviour is exactly what it
|
|
11
|
+
// was before this module existed
|
|
12
|
+
// feature on, no policy read tools pass; a mutating or externally
|
|
13
|
+
// communicating tool is refused (`identity-missing`),
|
|
14
|
+
// because there is no task to check it for
|
|
15
|
+
// policy bound every tool is checked: it must be a declared tool
|
|
16
|
+
// action (deny by default), a mutating one must also
|
|
17
|
+
// have the session root inside a declared write root,
|
|
18
|
+
// and a tool missing from the classification table is
|
|
19
|
+
// refused (`tool-unclassified`)
|
|
20
|
+
//
|
|
21
|
+
// A denial is synchronous and final for that call: no prompt, no waiting, no
|
|
22
|
+
// retry inside the gate. It carries the missing capability and a reviewable
|
|
23
|
+
// proposed change (recovery.js); repeated denials hit the finite limit there.
|
|
24
|
+
//
|
|
25
|
+
// This is POLICY at the tool boundary. The tool then runs in the MCP server
|
|
26
|
+
// process, so the decision is recorded as `in-process-policy` and never
|
|
27
|
+
// `enforced`; the runner is the only enforcement layer.
|
|
28
|
+
import { resolveAssuranceConfig } from '../posture/assurance/config.js';
|
|
29
|
+
import { reasonText, sanitizeSubject } from './reasons.js';
|
|
30
|
+
import { toolCapabilityFor, isMutatingOrExternal } from './tool-registry.js';
|
|
31
|
+
import { mediate, createDenialGuard } from './recovery.js';
|
|
32
|
+
import { toCapabilityDecisionRecord } from './records.js';
|
|
33
|
+
|
|
34
|
+
const FEATURE = 'capability-enforcement';
|
|
35
|
+
const MAX_KEPT = 500;
|
|
36
|
+
|
|
37
|
+
function synthetic(bound, kind, code, subject) {
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
decision: 'deny', code, reason: reasonText(code), kind, subject: sanitizeSubject(subject),
|
|
40
|
+
taskId: bound?.binding?.taskId ?? null, manifestDigest: bound?.binding?.digest ?? null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the OPERATOR enabled the feature (environment only; a project file can never enable it). */
|
|
45
|
+
export function featureEnabledByOperator(env = process.env) {
|
|
46
|
+
try { return resolveAssuranceConfig({ env }).features?.[FEATURE]?.enabled === true; } catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} o
|
|
51
|
+
* @param {string} o.sessionRoot
|
|
52
|
+
* @param {{bound: object, binding: object}|null} [o.policy] the task's bound manifest and the identity it acts for
|
|
53
|
+
* @param {object} [o.guard] denial guard (default: a fresh one with the default limits)
|
|
54
|
+
* @param {object} [o.env]
|
|
55
|
+
*/
|
|
56
|
+
export function createToolGate({ sessionRoot, policy = null, guard, env = process.env } = {}) {
|
|
57
|
+
let current = policy && policy.bound && policy.binding ? { bound: policy.bound, binding: policy.binding } : null;
|
|
58
|
+
const denialGuard = guard || createDenialGuard();
|
|
59
|
+
const kept = [];
|
|
60
|
+
|
|
61
|
+
function record(decision) {
|
|
62
|
+
const r = toCapabilityDecisionRecord(decision, { mediation: 'in-process-policy', enforced: false, backend: null });
|
|
63
|
+
if (r.ok) { kept.push(r.record); if (kept.length > MAX_KEPT) kept.shift(); }
|
|
64
|
+
return r.ok ? r.record : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refused(d) {
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
allowed: false, blocked: true, code: d.code, reason: d.reason, decision: d,
|
|
70
|
+
missing: Object.freeze({ capability: 'tool', code: d.code, detail: d.subject }),
|
|
71
|
+
proposal: null, attemptsRemaining: null, exhausted: false, record: record(d),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function blockedResult(m, extra = {}) {
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
allowed: false, blocked: true, code: m.decision.code, reason: m.decision.reason, decision: m.decision, missing: m.missing,
|
|
78
|
+
proposal: m.proposal, attemptsRemaining: m.attemptsRemaining, exhausted: m.exhausted, record: record(m.decision), ...extra,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const self = {
|
|
83
|
+
/** The gate is active when a policy is bound or the operator turned the feature on. */
|
|
84
|
+
active: () => current !== null || featureEnabledByOperator(env),
|
|
85
|
+
hasPolicy: () => current !== null,
|
|
86
|
+
/** Operator-side: replace the bound policy (a new policy version, a different task). Stale bindings are then refused. */
|
|
87
|
+
update(next) { current = next && next.bound && next.binding ? { bound: next.bound, binding: next.binding } : null; },
|
|
88
|
+
decisions: () => kept.slice(),
|
|
89
|
+
guard: denialGuard,
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} name the tool the agent asked for
|
|
93
|
+
* @param {{claimedTaskId?: string}} [req] identity claimed in the request, if any
|
|
94
|
+
*/
|
|
95
|
+
check(name, { claimedTaskId } = {}) {
|
|
96
|
+
if (!self.active()) return Object.freeze({ allowed: true, skipped: true, decision: null });
|
|
97
|
+
if (!current) {
|
|
98
|
+
if (!isMutatingOrExternal(name)) return Object.freeze({ allowed: true, skipped: true, decision: null });
|
|
99
|
+
return refused(synthetic(null, 'tool', 'identity-missing', String(name)));
|
|
100
|
+
}
|
|
101
|
+
const { bound, binding } = current;
|
|
102
|
+
if (claimedTaskId !== undefined && claimedTaskId !== binding.taskId) {
|
|
103
|
+
return refused(synthetic(bound, 'tool', 'identity-spoofed', `${String(name)} (claimed task ${sanitizeSubject(claimedTaskId, 60)})`));
|
|
104
|
+
}
|
|
105
|
+
const cap = toolCapabilityFor(name);
|
|
106
|
+
if (!cap) return refused(synthetic(bound, 'tool', 'tool-unclassified', String(name)));
|
|
107
|
+
const ctx = { binding };
|
|
108
|
+
const decisions = [];
|
|
109
|
+
for (const req of cap.requires) {
|
|
110
|
+
const action = req === 'tool' ? { kind: 'tool', tool: name } : { kind: 'filesystem-write', path: sessionRoot };
|
|
111
|
+
const m = mediate(bound, action, ctx, { guard: denialGuard });
|
|
112
|
+
if (m.status !== 'ok') return blockedResult(m, { checked: decisions.length });
|
|
113
|
+
decisions.push(m.decision); record(m.decision);
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze({ allowed: true, skipped: false, decision: decisions[0], decisions });
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return Object.freeze(self);
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The capability classification of every registered MCP tool (X-505.AC01).
|
|
2
|
+
//
|
|
3
|
+
// A tool that writes, runs a project command or reaches the network must say so,
|
|
4
|
+
// and the gate (tool-gate.js) checks what it declares before the handler runs. A
|
|
5
|
+
// tool that is not in this table is refused when the gate is active
|
|
6
|
+
// (`tool-unclassified`), so adding a tool to the MCP registry without classifying
|
|
7
|
+
// it fails closed and fails a test (test/capabilities/tools.test.js compares this
|
|
8
|
+
// table with the registry in both directions).
|
|
9
|
+
//
|
|
10
|
+
// effect 'read' reads local state only
|
|
11
|
+
// 'mutating' writes project or agent state, or runs project code
|
|
12
|
+
// 'external' reaches, or may reach, the network through a package
|
|
13
|
+
// manager or another tool the server process runs
|
|
14
|
+
// requires the capability checks made before the handler:
|
|
15
|
+
// 'tool' the tool is a declared tool action
|
|
16
|
+
// 'write:sessionRoot' the session root is inside a declared write root
|
|
17
|
+
//
|
|
18
|
+
// What this table does NOT do: it does not route a tool's own file or network
|
|
19
|
+
// activity through the capability runner. A tool runs in the MCP server process;
|
|
20
|
+
// the decision is made at the tool boundary, so it is policy (`in-process-policy`),
|
|
21
|
+
// never enforced isolation, and the report says so.
|
|
22
|
+
const read = (note) => Object.freeze({ effect: 'read', requires: Object.freeze(['tool']), note });
|
|
23
|
+
const mutating = (note, extra = []) => Object.freeze({ effect: 'mutating', requires: Object.freeze(['tool', 'write:sessionRoot', ...extra]), note });
|
|
24
|
+
const external = (note, extra = []) => Object.freeze({ effect: 'external', requires: Object.freeze(['tool', ...extra]), note });
|
|
25
|
+
|
|
26
|
+
export const TOOL_CAPABILITIES = Object.freeze({
|
|
27
|
+
scan_diff: read('scans files in memory'),
|
|
28
|
+
query_taint: read('reads the last verified scan'),
|
|
29
|
+
explain_finding: read('reads the last verified scan'),
|
|
30
|
+
find_rule_module: read('reads scanner source names'),
|
|
31
|
+
read_scratchpad: read('reads the agent scratchpad'),
|
|
32
|
+
read_agents_memory: read('reads the continual-learning file'),
|
|
33
|
+
lookup_cve: read('reads the local advisory caches'),
|
|
34
|
+
query_triage_memory: read('reads past triage decisions'),
|
|
35
|
+
query_findings_memory: read('reads accumulated scan memory'),
|
|
36
|
+
query_cache_telemetry: read('reads the session transcript statistics'),
|
|
37
|
+
synthesize_fix: read('returns a stored patch'),
|
|
38
|
+
dataflow_get_graph: read('reads the signed graph artifact'),
|
|
39
|
+
dataflow_get_node: read('reads the signed graph artifact'),
|
|
40
|
+
dataflow_get_edge: read('reads the signed graph artifact'),
|
|
41
|
+
dataflow_get_flow: read('reads the signed graph artifact'),
|
|
42
|
+
invariant_scenario_export: read('builds a read-only scenario export from a supplied contract, confined to the session root'),
|
|
43
|
+
portfolio_progress: read('builds a read-only progress view from a portfolio store, ledger and optional inputs, confined to the session root'),
|
|
44
|
+
apply_fix: mutating('writes verified patches into the project'),
|
|
45
|
+
verify_fix: mutating('runs the project linter and tests and appends fix metrics'),
|
|
46
|
+
append_scratchpad: mutating('writes under the agent scratchpad'),
|
|
47
|
+
append_agents_memory: mutating('appends to the continual-learning file'),
|
|
48
|
+
synthesize_sca_upgrade: external('runs a package manager dry-run, which may reach a registry'),
|
|
49
|
+
apply_sca_upgrade: external('runs the package manager and the project tests and rewrites manifests', ['write:sessionRoot']),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function toolCapabilityFor(name) {
|
|
53
|
+
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(TOOL_CAPABILITIES, name) ? TOOL_CAPABILITIES[name] : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tools whose effect is not read-only: the ones that must always be checked. */
|
|
57
|
+
export function isMutatingOrExternal(name) {
|
|
58
|
+
const c = toolCapabilityFor(name);
|
|
59
|
+
return c ? c.effect !== 'read' : true; // an unclassified tool is treated as the riskiest
|
|
60
|
+
}
|
package/src/dataflow/CLAUDE.md
CHANGED
|
@@ -16,6 +16,7 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
|
|
|
16
16
|
- Guarded by `bench/mutation/runner.mjs` (`npm run bench:mutation:check`), which scores verdict-flip correctness over metamorphic rewrites (verdict must hold) and adversarial near-misses (verdict must flip). Removing the family check makes it exit non-zero.
|
|
17
17
|
- **`match.receiver`** (optional, regex source string) constrains a call to call sites whose receiver chain has a matching segment. Callee matching is by *bare name*, so `callee: 'write'` otherwise matches `process.stdout.write` and `fh.write` as readily as `document.write`. Works for both sinks (via `matchSinkOrSanitizer`) and sources (via `matchSource`'s CALL-source path). `match.receiverBase` is an optional, independent second pattern (both `_receiverAllowed` checks must pass) — use it when `receiver` alone would still match an unrelated same-named local, e.g. `py-flask-args-get` requires *both* a property segment (`args`/`form`/`headers`/…) *and* a `request`/`req` segment, so `args = parse(); args.get(x)` doesn't fire just because some catalog entry happens to be named `get`.
|
|
18
18
|
- **`match.receiverExclude`** (optional, regex source string) is the only NEGATIVE form — `receiver`/`receiverBase` can only ever REQUIRE a match, which cannot express "fire on a bare call, but not on THIS receiver." `py-compile` needs exactly that: Python's dangerous `compile()` builtin (`compile(source, filename, mode)`, equivalent to `eval` when followed by `exec`) is called BARE, but the catalog's bare-name last-segment fallback (`_calleeIndexHits`) makes the same key also match `re.compile(pattern)` — an ordinary regex compiler with no code-execution capability. `receiverExclude: '^re$'` fixes it. Checked FIRST, independently of `receiver`/`receiverBase`, and a bare call (no receiver segments) can never match an exclude pattern — so setting only `receiverExclude` never blocks the no-receiver case it exists to preserve. Found via this project's own self-scan once `_calleeReceiverTainted` (below) started correctly propagating taint through `.get()`-style calls on a tainted dict/file-read chain — the sink was always mismatched; the engine fix just gave taint a path to reach it. Guarded by `test/catalog-code-injection.test.js`.
|
|
19
|
+
- **`match.receiverTypeExclude`** (optional, array of regex sources) is the type-level negative form: a CONFIDENTLY resolved receiver type matching it removes the entry (`_receiverTypeAllowed`); an unresolved type never excludes, so unknown stays conservative. `js-exec` uses `['^RegExp$']` because a regex's `.exec()` shares its method name with `child_process.exec` and is far more common on request data (it was a high-volume false positive). The type comes from three places: `catalog.js`'s `_inlineReceiverType` (a regex literal or `RegExp(...)` call as the receiver, applied inside `matchSinkOrSanitizer` so every caller gets it), CHA `classOfVar` (in-function `new RegExp`), and `engine.js`'s `_regexNamesByFile` (a file-wide name set: every assignment to the name anywhere in the file is a regex initialiser and it is never a parameter; built by `_buildRegexNames` at the start of `runTaintEngine`, because CHA cannot see a module-scope const from inside a handler). The name set is deliberately scope-blind, so a name bound to anything else anywhere in the file is not trusted. Guarded by `test/rx-exec-receiver.test.js` and the `rx-exec-*` cases in `bench/mutation/runner.mjs`.
|
|
19
20
|
- **`language`** is enforced by `_languageAllowed` (Phase 2) via a table (`_LANG_EXT` in `catalog.js`) covering all ten catalog languages — `js`, `py`, `cs`, `kt`, `go`, `php`, `rb`, `java`, `rs` by literal regex kept byte-identical to `ir/index.js`'s own dispatch regexes (pinned by `test/phase2-scoping.test.js`, which compares `.source` directly, not just "does this regex literal appear somewhere in the file"), and `cpp` by delegating to `ir/parser-cpp.js`'s `cppExtRe()` so it can't drift from the parser. A language with no table entry stays permissive (matches any file) — additive by design, so it can't regress a language before its entry exists.
|
|
20
21
|
- **Path feasibility.** Constant-folds `if` conditions to prune unreachable branches.
|
|
21
22
|
- **Per-flow source attribution.** Sources reported on a finding are the ones actually reaching the sink argument (via free-var matching in the sink expression), NOT the first source the worklist happened to see. Premortem-derived.
|
|
@@ -73,6 +74,35 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
|
|
|
73
74
|
|
|
74
75
|
`engine.js` `dropGuardedFindings(findings, fc)` runs after all detectors and drops a CWE-918 (SSRF) finding when the sink window has a host allow/deny check (deny/allow-list, `getHost`/`hostname` comparison, RFC1918/`169.254.169.254` prefix check, `ipaddress`/`getaddrinfo`/`ssrf-req-filter`), or a CWE-22 (path) finding when the window has a containment guard (`basename`/`GetFileName`/`secure_filename`/`send_from_directory`, or canonicalize+`startsWith`). It's the single source of truth so every emitter (regex, structural, per-language flow, PY-SAST, CSHARP, GO) is treated uniformly. The window is **comment-stripped** (a "no allow-list / 169.254…" vuln comment must not read as a guard). Opt out: `AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1`.
|
|
75
76
|
|
|
77
|
+
### Guard recognition must prove DOMINANCE (`guard-dominance.js`, QA-005.AC02)
|
|
78
|
+
|
|
79
|
+
`dropGuardedFindings` and the safe-sink-shape check used to clear a finding whenever guard-shaped TEXT sat within -25/+5 lines and shared an
|
|
80
|
+
identifier with the sink line. That answers "is there guard-looking text nearby", not "is every path to the sink forced through this guard". A guard
|
|
81
|
+
written AFTER the sink, one in a function nobody calls, one in a block that had already closed, a check whose failing branch only logs, a membership
|
|
82
|
+
test computed and never branched on, and a line that merely DEFINES the allow-list all cleared real findings.
|
|
83
|
+
|
|
84
|
+
`guardDominatesSink({ lines, guardIdx, sinkIdx, file })` now decides, over the same lines, and returns `{ dominates, reason }`. It is conservative
|
|
85
|
+
toward KEEPING a finding: anything it cannot place is `not proven`. Rules: the guard precedes the sink; a data declaration is not a check; a boolean
|
|
86
|
+
result needs a later branch on it; the guard's enclosing named function must contain the sink, or end before it with the sink's own code calling that
|
|
87
|
+
helper first and the guard unavoidable inside the helper; the guard's block must still be open at the sink; a conditional guard must either contain the
|
|
88
|
+
sink in its branch or have a failing branch that leaves (return, throw, raise, exit, abort, an HTTP error). Braces vs indentation comes from the file
|
|
89
|
+
extension, else a whole-file vote. Known limit: polarity is not judged (`if (allowed) { return }` reads like a guard), and this is not a control-flow
|
|
90
|
+
graph. A CWE-601 target guard (`IsLocalUrl`, a relative-path check, a host allow-list) joined the same pass.
|
|
91
|
+
|
|
92
|
+
`_guardWindow` also stopped treating the `//` in `"https://"` as a comment start (it erased the allow-list check on that line and a neighbouring
|
|
93
|
+
declaration stood in for it); `_stripCommentsKeepLines` is string-aware and keeps every newline so window indices are file indices.
|
|
94
|
+
Guard-recognized drops are written to the suppression ledger with `id`, `cwe`, `family`, `line` and `guard-recognized:<kind>:dominates@<guardLine>:<why>`.
|
|
95
|
+
Tests: `test/evaluation/guard-dominance.test.js`.
|
|
96
|
+
|
|
97
|
+
### Sink argument context and request-object sources (QA-006)
|
|
98
|
+
|
|
99
|
+
`match.requireStaticPrefix: { index, pattern }` (engine.js `_staticPrefixSatisfied`) gates a sink on the leading STATIC text of a partly tainted
|
|
100
|
+
argument, read from the leftmost literal of a concatenation or template; an unknown leading piece fails closed. It separates
|
|
101
|
+
`header("Location: " . $x)` (open redirect, `php-header-location`) from `header("X-Trace: " . $x)` (header injection only). Go's `http.Redirect` (target is
|
|
102
|
+
argument 2) and `c.Redirect(code, url)` (argument 1) are sinks. Laravel `$request->input()/query()/post()/all()/...` are sources (receiver must be a
|
|
103
|
+
request-named variable). `cs-aspnet-implicit-action-param` and `java-spring-implicit-mapped-param` are annotation sources fed by `ir/implicit-handler-params.js`.
|
|
104
|
+
Tests: `test/evaluation/engine-mechanisms.test.js`.
|
|
105
|
+
|
|
76
106
|
## Entry points
|
|
77
107
|
|
|
78
108
|
- `runTaintEngine(perFileIR, callGraph, opts)` — the public entry. Runs from `engine.js` when `AGENTIC_SECURITY_DEEP=1`. **R1 (PRD §5):** the CLI entry (`bin/agentic-security.js#cmdScan`) sets that env var by default for local/interactive scans (not in CI, and not when `--no-deep`/`AGENTIC_SECURITY_DEEP=0`), so the default `/scan --all` runs deep. In-process callers (tests, the cve-replay corpus) invoke `runScan()` directly without the CLI default and therefore stay deep-off unless they set the env var themselves (e.g. `test/deep-taint.test.js`).
|
package/src/dataflow/catalog.js
CHANGED
|
@@ -79,7 +79,7 @@ export const CATALOG = [
|
|
|
79
79
|
vuln: { name: 'SQL Injection (db.execute)', severity: 'critical', cwe: 'CWE-89',
|
|
80
80
|
remediation: 'Use parameterized queries: db.execute("SELECT * FROM t WHERE id = ?", [id]).' } },
|
|
81
81
|
// OS command.
|
|
82
|
-
{ kind: 'sink', id: 'js-exec', language: 'js', framework: 'node', match: { type: 'call', callee: 'exec'
|
|
82
|
+
{ kind: 'sink', id: 'js-exec', language: 'js', framework: 'node', match: { type: 'call', callee: 'exec', receiverTypeExclude: ['^RegExp$'] }, argIndex: 0,
|
|
83
83
|
vuln: { name: 'Command Injection (child_process.exec)', severity: 'critical', cwe: 'CWE-78',
|
|
84
84
|
remediation: 'Use execFile or spawn with an argv array instead of exec — exec invokes the shell. If shell features are required, escape with shell-escape, never string-concat user input.' } },
|
|
85
85
|
{ kind: 'sink', id: 'js-execSync', language: 'js', framework: 'node', match: { type: 'call', callee: 'execSync' }, argIndex: 0,
|
|
@@ -265,6 +265,10 @@ export const CATALOG = [
|
|
|
265
265
|
{ kind: 'source', id: 'cs-aspnet-fromform', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromForm' }, label: '[FromForm] (ASP.NET Core)' },
|
|
266
266
|
{ kind: 'source', id: 'cs-aspnet-fromroute', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromRoute' }, label: '[FromRoute] (ASP.NET Core)' },
|
|
267
267
|
{ kind: 'source', id: 'cs-aspnet-fromheader', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromHeader' }, label: '[FromHeader] (ASP.NET Core)' },
|
|
268
|
+
// QA-006: the IMPLICIT forms. A controller action's plain string parameter and a Spring-mapped method's plain String parameter are
|
|
269
|
+
// request-bound with no annotation at all; the IR frontends (ir/implicit-handler-params.js) tag them with these decorator names.
|
|
270
|
+
{ kind: 'source', id: 'cs-aspnet-implicit-action-param', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'ImplicitMvcActionParam' }, label: 'controller action parameter (ASP.NET model binding)' },
|
|
271
|
+
{ kind: 'source', id: 'java-spring-implicit-mapped-param', language: 'java', framework: 'spring', match: { type: 'annotation', name: 'ImplicitSpringMappedParam' }, label: 'mapped handler parameter (Spring request binding)' },
|
|
268
272
|
{ kind: 'source', id: 'js-nestjs-query', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Query' }, label: '@Query() (NestJS)', provenance: 'url-param' },
|
|
269
273
|
{ kind: 'source', id: 'js-nestjs-body', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Body' }, label: '@Body() (NestJS)', provenance: 'http-body' },
|
|
270
274
|
{ kind: 'source', id: 'js-nestjs-param', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Param' }, label: '@Param() (NestJS)', provenance: 'path-param' },
|
|
@@ -410,6 +414,11 @@ export const CATALOG = [
|
|
|
410
414
|
{ kind: 'source', id: 'php-symfony-files', language: 'php', framework: 'symfony', match: { type: 'member', object: '$request', prop: 'files' }, label: '$request->files (Symfony)' },
|
|
411
415
|
{ kind: 'source', id: 'php-symfony-content', language: 'php', framework: 'symfony', match: { type: 'call', callee: 'getContent' }, label: '$request->getContent() (Symfony)' },
|
|
412
416
|
{ kind: 'source', id: 'php-symfony-get', language: 'php', framework: 'symfony', match: { type: 'call', callee: 'get' }, label: '$request->get() (Symfony)' },
|
|
417
|
+
// QA-006: Laravel / Illuminate request accessors. `$request->input('x')` is the framework's primary way to read user input and had no
|
|
418
|
+
// source at all (only Symfony's property-shaped and `get` accessors did), so a controller reading its input the Laravel way was invisible.
|
|
419
|
+
// The receiver must be a request-named variable: `input`/`all`/`file` are common method names on unrelated objects.
|
|
420
|
+
...['input', 'query', 'post', 'all', 'cookie', 'header', 'file', 'json', 'string', 'str', 'only', 'except', 'collect', 'getContent'].map((m) => (
|
|
421
|
+
{ kind: 'source', id: `php-laravel-request-${m.toLowerCase()}`, language: 'php', framework: 'laravel', match: { type: 'call', callee: m, receiver: '^\\$?(?:request|req)$' }, label: `$request->${m}() (Laravel)` })),
|
|
413
422
|
|
|
414
423
|
// SARD PHP corpus: file/stream/process-output sources. This corpus's
|
|
415
424
|
// dominant "source" shape is NOT a superglobal at all — it's a value read
|
|
@@ -1383,6 +1392,10 @@ export const CATALOG = [
|
|
|
1383
1392
|
remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header. Prefer an allow-listed set of header values.' } },
|
|
1384
1393
|
{ kind: 'source', id: 'py-flask-request-get-data', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get_data' }, label: 'request.get_data() (Flask raw body)', provenance: 'http-body' },
|
|
1385
1394
|
{ kind: 'source', id: 'py-django-request-body', language: 'py', framework: 'django', match: { type: 'member', object: 'request', prop: 'body' }, label: 'request.body (Django raw body)', provenance: 'http-body' },
|
|
1395
|
+
// QA-006: header("Location: " . $x) is an open redirect, not only header injection. The CRLF entry below still applies to the same call.
|
|
1396
|
+
{ kind: 'sink', id: 'php-header-location', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'header', requireStaticPrefix: { index: 0, pattern: '^\\s*Location\\s*:' } }, argIndex: 0,
|
|
1397
|
+
vuln: { name: 'Open Redirect (header Location)', severity: 'medium', cwe: 'CWE-601',
|
|
1398
|
+
remediation: 'Validate the redirect target against an allow-list of relative paths or known hosts before putting it in a Location header.' } },
|
|
1386
1399
|
{ kind: 'sink', id: 'php-header', language: 'php', framework: 'stdlib', match: { type: 'call', callee: 'header' }, argIndex: 0,
|
|
1387
1400
|
vuln: { name: 'HTTP Response Splitting / CRLF Injection (header())', severity: 'high', cwe: 'CWE-113',
|
|
1388
1401
|
remediation: 'Strip/validate CR/LF from any user-controlled value before passing it to header().' } },
|
|
@@ -1424,6 +1437,14 @@ export const CATALOG = [
|
|
|
1424
1437
|
{ kind: 'sink', id: 'kt-servlet-setheader', language: 'kt', framework: 'servlet', match: { type: 'call', callee: 'setHeader' }, argIndex: 1,
|
|
1425
1438
|
vuln: { name: 'HTTP Response Splitting / Header Injection (HttpServletResponse.setHeader)', severity: 'high', cwe: 'CWE-113',
|
|
1426
1439
|
remediation: 'Strip/validate CR/LF from any user-controlled value before using it as a header value.' } },
|
|
1440
|
+
// QA-006: Go redirect helpers. net/http `http.Redirect(w, r, url, code)` takes the target as its THIRD argument; gin/echo
|
|
1441
|
+
// `c.Redirect(code, url)` take it second; fiber `c.Redirect(url)` first. None was a sink, so a request-derived target went unseen.
|
|
1442
|
+
{ kind: 'sink', id: 'go-http-redirect', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Redirect', receiver: '^http$' }, argIndex: 2,
|
|
1443
|
+
vuln: { name: 'Open Redirect (http.Redirect)', severity: 'medium', cwe: 'CWE-601',
|
|
1444
|
+
remediation: 'Only redirect to a relative path you validate (starts with "/" and not "//") or to a host on an allow-list.' } },
|
|
1445
|
+
{ kind: 'sink', id: 'go-ctx-redirect-second', language: 'go', framework: 'gin/echo', match: { type: 'call', callee: 'Redirect', receiver: '^(?:c|ctx|context)$' }, argIndex: 1,
|
|
1446
|
+
vuln: { name: 'Open Redirect (context.Redirect)', severity: 'medium', cwe: 'CWE-601',
|
|
1447
|
+
remediation: 'Only redirect to a relative path you validate or to a host on an allow-list.' } },
|
|
1427
1448
|
{ kind: 'sink', id: 'go-header-set', language: 'go', framework: 'net/http', match: { type: 'call', callee: 'Set', receiver: 'Header' }, argIndex: 1,
|
|
1428
1449
|
vuln: { name: 'HTTP Response Splitting / Header Injection (Header().Set)', severity: 'high', cwe: 'CWE-113',
|
|
1429
1450
|
remediation: 'net/http rejects raw CR/LF in header values since Go 1.x, but still validate/allow-list user-controlled header values.' } },
|
|
@@ -2360,6 +2381,13 @@ function _receiverAllowed(entry, calleeExpr) {
|
|
|
2360
2381
|
// vocabulary. Over-matching here is the safe direction: it only ever
|
|
2361
2382
|
// *permits* a match the pattern layer already made.
|
|
2362
2383
|
function _receiverTypeAllowed(entry, receiverType) {
|
|
2384
|
+
// `receiverTypeExclude` is the NEGATIVE counterpart of `receiverTypeIn`: a
|
|
2385
|
+
// confidently resolved receiver type that matches it removes the entry. An
|
|
2386
|
+
// unresolved type (null) never excludes, so the conservative behaviour is
|
|
2387
|
+
// unchanged whenever the engine cannot tell. `js-exec` uses it to drop a
|
|
2388
|
+
// RegExp's `.exec()`, which shares its method name with child_process.exec.
|
|
2389
|
+
const ex = entry.match && entry.match.receiverTypeExclude;
|
|
2390
|
+
if (ex && ex.length && receiverType && ex.some((p) => new RegExp(p).test(String(receiverType)))) return false;
|
|
2363
2391
|
const pats = entry.match && entry.match.receiverTypeIn;
|
|
2364
2392
|
if (!pats || !pats.length) return true;
|
|
2365
2393
|
if (!receiverType) return true;
|
|
@@ -2617,8 +2645,21 @@ export function matchSource(expr, file, receiverType) {
|
|
|
2617
2645
|
return null;
|
|
2618
2646
|
}
|
|
2619
2647
|
|
|
2648
|
+
// A receiver whose type is evident from the expression itself, with no
|
|
2649
|
+
// binding to resolve: a regex literal (`/x/.exec(s)`) or a direct construction
|
|
2650
|
+
// (`new RegExp(p).exec(s)`). Returns 'RegExp' or null. Callers that can also
|
|
2651
|
+
// resolve a bound variable pass their own `receiverType`, which wins.
|
|
2652
|
+
function _inlineReceiverType(calleeExpr) {
|
|
2653
|
+
const o = calleeExpr && typeof calleeExpr === 'object' && calleeExpr.kind === 'member' ? calleeExpr.object : null;
|
|
2654
|
+
if (!o) return null;
|
|
2655
|
+
if (o.kind === 'literal' && o.isRegex) return 'RegExp';
|
|
2656
|
+
if (o.kind === 'call' && o.callee && o.callee.kind === 'ident' && o.callee.name === 'RegExp') return 'RegExp';
|
|
2657
|
+
return null;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2620
2660
|
export function matchSinkOrSanitizer(calleeExpr, file, receiverType) {
|
|
2621
2661
|
if (!calleeExpr) return null;
|
|
2662
|
+
receiverType = receiverType || _inlineReceiverType(calleeExpr);
|
|
2622
2663
|
const raw = _calleeIndexHits(calleeExpr);
|
|
2623
2664
|
if (!raw.length) return null;
|
|
2624
2665
|
const hits = filterByProvenance(raw)
|