@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -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
+ }
@@ -73,6 +73,35 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
73
73
 
74
74
  `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
75
 
76
+ ### Guard recognition must prove DOMINANCE (`guard-dominance.js`, QA-005.AC02)
77
+
78
+ `dropGuardedFindings` and the safe-sink-shape check used to clear a finding whenever guard-shaped TEXT sat within -25/+5 lines and shared an
79
+ 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
80
+ 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
81
+ test computed and never branched on, and a line that merely DEFINES the allow-list all cleared real findings.
82
+
83
+ `guardDominatesSink({ lines, guardIdx, sinkIdx, file })` now decides, over the same lines, and returns `{ dominates, reason }`. It is conservative
84
+ 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
85
+ 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
86
+ 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
87
+ 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
88
+ 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
89
+ graph. A CWE-601 target guard (`IsLocalUrl`, a relative-path check, a host allow-list) joined the same pass.
90
+
91
+ `_guardWindow` also stopped treating the `//` in `"https://"` as a comment start (it erased the allow-list check on that line and a neighbouring
92
+ declaration stood in for it); `_stripCommentsKeepLines` is string-aware and keeps every newline so window indices are file indices.
93
+ Guard-recognized drops are written to the suppression ledger with `id`, `cwe`, `family`, `line` and `guard-recognized:<kind>:dominates@<guardLine>:<why>`.
94
+ Tests: `test/evaluation/guard-dominance.test.js`.
95
+
96
+ ### Sink argument context and request-object sources (QA-006)
97
+
98
+ `match.requireStaticPrefix: { index, pattern }` (engine.js `_staticPrefixSatisfied`) gates a sink on the leading STATIC text of a partly tainted
99
+ argument, read from the leftmost literal of a concatenation or template; an unknown leading piece fails closed. It separates
100
+ `header("Location: " . $x)` (open redirect, `php-header-location`) from `header("X-Trace: " . $x)` (header injection only). Go's `http.Redirect` (target is
101
+ argument 2) and `c.Redirect(code, url)` (argument 1) are sinks. Laravel `$request->input()/query()/post()/all()/...` are sources (receiver must be a
102
+ request-named variable). `cs-aspnet-implicit-action-param` and `java-spring-implicit-mapped-param` are annotation sources fed by `ir/implicit-handler-params.js`.
103
+ Tests: `test/evaluation/engine-mechanisms.test.js`.
104
+
76
105
  ## Entry points
77
106
 
78
107
  - `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`).
@@ -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.' } },
@@ -983,6 +983,25 @@ function _literalArgSatisfied(argExprs, requireLiteralArg) {
983
983
  return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
984
984
  }
985
985
 
986
+ // QA-006: `match.requireStaticPrefix` -- the leading STATIC text of an argument that is partly tainted.
987
+ // `header("Location: " . $next)` is an open redirect while `header("X-Trace: " . $v)` is only header injection;
988
+ // both are the same callee, told apart by the literal text the tainted value is appended to. The prefix is
989
+ // read from the leftmost literal of a concatenation or template; a leading non-literal means the prefix is
990
+ // unknown and the requirement is NOT satisfied (fails closed, like requireLiteralArg).
991
+ function _leadingLiteral(expr) {
992
+ if (!expr) return null;
993
+ if (expr.kind === 'literal') return typeof expr.value === 'string' ? expr.value : String(expr.value ?? '');
994
+ if (expr.kind === 'binary') return _leadingLiteral(expr.left);
995
+ if (expr.kind === 'tpl') { const p0 = (expr.parts || [])[0]; return p0 && p0.kind === 'literal' ? String(p0.value ?? '') : null; }
996
+ return null;
997
+ }
998
+ function _staticPrefixSatisfied(argExprs, requireStaticPrefix) {
999
+ const { index, pattern } = requireStaticPrefix;
1000
+ const lead = _leadingLiteral((argExprs || [])[index]);
1001
+ // Some frontends keep the literal's own quote characters in its value (PHP does); the prefix is the text inside them.
1002
+ return lead !== null && new RegExp(pattern, 'i').test(lead.replace(/^\s*["'`]/, ''));
1003
+ }
1004
+
986
1005
  // SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
987
1006
  // coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
988
1007
  // (intval/floatval/etc, where the ENTIRE call's return value is the coerced
@@ -1111,6 +1130,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
1111
1130
  // precision annotator in this codebase take when evidence is
1112
1131
  // simply unavailable rather than affirmatively clean.
1113
1132
  if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
1133
+ if (e.match && e.match.requireStaticPrefix && !_staticPrefixSatisfied(argExprs, e.match.requireStaticPrefix)) continue;
1114
1134
  // `match.requireKeyword` — the KEYWORD-argument analogue of the
1115
1135
  // positional gate above, for languages where the dangerous form is
1116
1136
  // selected by a named argument rather than a positional literal.