cap-mcp-guard 0.2.0 → 0.3.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/README.md +229 -163
- package/lib/adapters/cap.js +123 -79
- package/lib/core/interceptor.js +140 -15
- package/lib/index.js +1 -2
- package/lib/policy/config.js +191 -106
- package/lib/policy/evaluator.js +26 -11
- package/lib/policy/pseudonym.js +89 -0
- package/package.json +56 -54
- package/lib/adapters/odata.js +0 -366
- package/lib/core/edm.js +0 -241
- package/lib/core/odata-batch.js +0 -161
- package/lib/core/odata.js +0 -178
package/package.json
CHANGED
|
@@ -1,54 +1,56 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cap-mcp-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "lib/index.js",
|
|
6
|
-
"author": "Furkan Aksoy <furkanaksy838@gmail.com>",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/furkanaksy838/mcp.git"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/furkanaksy838/mcp#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/furkanaksy838/mcp/issues"
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"lib",
|
|
17
|
-
"cds-plugin.js"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"test": "jest"
|
|
21
|
-
},
|
|
22
|
-
"keywords": [
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
},
|
|
38
|
-
"
|
|
39
|
-
"@
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "cap-mcp-guard",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "A CDS plugin — the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking/pseudonymization, and OpenTelemetry-native observability for MCP-enabled applications.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"author": "Furkan Aksoy <furkanaksy838@gmail.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/furkanaksy838/mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/furkanaksy838/mcp#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/furkanaksy838/mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"cds-plugin.js"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "jest"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"CAP",
|
|
24
|
+
"CDS",
|
|
25
|
+
"cds-plugin",
|
|
26
|
+
"cap",
|
|
27
|
+
"sap",
|
|
28
|
+
"odata",
|
|
29
|
+
"mcp",
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"policy",
|
|
32
|
+
"opentelemetry"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@sap/cds": ">=7"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@opentelemetry/core": "^2.9.0",
|
|
43
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
44
|
+
"jest": "^30.4.2"
|
|
45
|
+
},
|
|
46
|
+
"jest": {
|
|
47
|
+
"testEnvironment": "node",
|
|
48
|
+
"testPathIgnorePatterns": [
|
|
49
|
+
"/node_modules/",
|
|
50
|
+
"/examples/"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@opentelemetry/api": "^1.9.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/lib/adapters/odata.js
DELETED
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const { buildContext } = require('../core/context');
|
|
6
|
-
const { resolveEntityPath, resolveOperation, isBatchRequest, collectMaskableGroups } = require('../core/odata');
|
|
7
|
-
const { parseMetadata, parseExpandOption } = require('../core/edm');
|
|
8
|
-
const { extractBoundary, parseBatchRequests, parseBatchResponses, serializeBatchResponses } = require('../core/odata-batch');
|
|
9
|
-
const { evaluate } = require('../policy/evaluator');
|
|
10
|
-
const { maskFields } = require('../policy/masking');
|
|
11
|
-
const { loadConfig } = require('../policy/config');
|
|
12
|
-
const { logAudit } = require('../audit/log');
|
|
13
|
-
const { exportSpan } = require('../otel/exporter');
|
|
14
|
-
|
|
15
|
-
const CONFIG_FILE_NAME = 'cap-mcp-guard.yaml';
|
|
16
|
-
const NOT_FOUND_PREFIX = `${CONFIG_FILE_NAME} not found`;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Loads cap-mcp-guard.yaml from `cwd` — same convention and same file
|
|
20
|
-
* lib/adapters/cap.js reads, so one policy file covers both a CAP app's
|
|
21
|
-
* own service layer and any OData traffic this middleware fronts. A
|
|
22
|
-
* missing config is a valid "not configured yet" state (pass-through); a
|
|
23
|
-
* config that exists but fails to parse propagates (see cap.js for the
|
|
24
|
-
* same reasoning).
|
|
25
|
-
*
|
|
26
|
-
* @param {string} [cwd] defaults to process.cwd()
|
|
27
|
-
*/
|
|
28
|
-
function resolvePolicyDefinition(cwd) {
|
|
29
|
-
const configPath = path.join(cwd || process.cwd(), CONFIG_FILE_NAME);
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
return loadConfig(configPath);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
if (err.message.startsWith(NOT_FOUND_PREFIX)) {
|
|
35
|
-
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
36
|
-
return { mode: 'observe', entities: {} };
|
|
37
|
-
}
|
|
38
|
-
throw err;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* OData has no MCP-runtime `_meta` bag to read (that's a CAP/CDS-side
|
|
44
|
-
* convention) — the wire-level equivalent is plain HTTP headers, which any
|
|
45
|
-
* proxying MCP runtime can set alongside a request it forwards.
|
|
46
|
-
*/
|
|
47
|
-
function extractTraceContext(req) {
|
|
48
|
-
const headers = (req && req.headers) || {};
|
|
49
|
-
return { traceparent: headers.traceparent, tracestate: headers.tracestate };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function extractAgentInfo(req) {
|
|
53
|
-
const headers = (req && req.headers) || {};
|
|
54
|
-
return {
|
|
55
|
-
agentId: headers['x-gen-ai-agent-id'],
|
|
56
|
-
agentName: headers['x-gen-ai-agent-name'],
|
|
57
|
-
model: headers['x-gen-ai-request-model']
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function getExpandTree(url) {
|
|
62
|
-
const query = (url || '').split('?')[1];
|
|
63
|
-
if (!query) return {};
|
|
64
|
-
|
|
65
|
-
const expand = new URLSearchParams(query).get('$expand');
|
|
66
|
-
return parseExpandOption(expand);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Same in-place-mutation strategy as lib/core/interceptor.js's applyMask:
|
|
71
|
-
* maskFields() itself stays pure, this is the one place that turns its
|
|
72
|
-
* (immutable) output into a real effect on the rows already referenced
|
|
73
|
-
* inside the response body (see extractResultRows / collectMaskableGroups).
|
|
74
|
-
*/
|
|
75
|
-
function applyMask(rows, fieldsToMask) {
|
|
76
|
-
const masked = maskFields(rows, fieldsToMask);
|
|
77
|
-
|
|
78
|
-
rows.forEach((row, i) => {
|
|
79
|
-
if (!row || typeof row !== 'object') return;
|
|
80
|
-
for (const field of fieldsToMask) {
|
|
81
|
-
if (Object.prototype.hasOwnProperty.call(row, field)) {
|
|
82
|
-
row[field] = masked[i][field];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Builds an Express-compatible middleware `(req, res, next)` that runs the
|
|
90
|
-
* same Context → Decision → mask/audit/OTel pipeline as
|
|
91
|
-
* lib/adapters/cap.js, driven off raw OData HTTP request/response instead
|
|
92
|
-
* of CAP's before()/after() hooks. Mount it in front of ANY OData V2/V4
|
|
93
|
-
* traffic — a hand-rolled OData service, a proxy route to SAP Gateway or
|
|
94
|
-
* S/4HANA, a CAP Java service, or this same CAP Node app's own OData
|
|
95
|
-
* endpoint — regardless of what produced it, since it only ever looks at
|
|
96
|
-
* the HTTP request/response, never at a CDS model.
|
|
97
|
-
*
|
|
98
|
-
* Entity resolution is URL-path-based (lib/core/odata.js's
|
|
99
|
-
* resolveEntityPath()). Without `metadataXml`/`edm`, it's a last-path-
|
|
100
|
-
* segment heuristic, same as before; pass either one to resolve deep
|
|
101
|
-
* paths (`Books(201)/author`) and `$expand`'ed nested entities
|
|
102
|
-
* (`?$expand=author`) against the real EDM, so masking reaches nested
|
|
103
|
-
* data too, not just the top-level rows.
|
|
104
|
-
*
|
|
105
|
-
* `$batch` requests are decoded on BOTH wire formats, each sub-request
|
|
106
|
-
* masked/audited/traced individually exactly like a standalone request:
|
|
107
|
-
* - OData V4 JSON batch (`{ requests: [...] }` request body /
|
|
108
|
-
* `{ responses: [...] }` response body) — requires `req.body` to
|
|
109
|
-
* already be the parsed JSON request (i.e. a JSON body-parser like
|
|
110
|
-
* `express.json()` ran before this middleware).
|
|
111
|
-
* - Classic OData V2 `multipart/mixed` batch (see lib/core/odata-batch.js)
|
|
112
|
-
* — requires `req.body` to already be the raw request Buffer/string
|
|
113
|
-
* (e.g. `express.raw({ type: 'multipart/mixed' })` ran before this
|
|
114
|
-
* middleware) and goes out via `res.send(rawBody)`, not `res.json()`.
|
|
115
|
-
* If a batch can't be decoded either way (wrong/missing `req.body`,
|
|
116
|
-
* missing boundary, malformed MIME), it still produces one Context/
|
|
117
|
-
* Decision/audit entry (with an undefined entity) so it isn't silently
|
|
118
|
-
* unaccounted for — it's just unmasked.
|
|
119
|
-
*
|
|
120
|
-
* Response bodies are only inspected when the downstream handler calls
|
|
121
|
-
* `res.json(body)` (always) or, for a batch request specifically,
|
|
122
|
-
* `res.send(rawBody)` too — this middleware wraps those methods for the
|
|
123
|
-
* life of the request. If `res.json` isn't a function (a bare Node `http`
|
|
124
|
-
* response, or a framework that never provides it), the middleware warns
|
|
125
|
-
* once and passes every request through unguarded rather than throwing.
|
|
126
|
-
*
|
|
127
|
-
* @param {object} [options]
|
|
128
|
-
* @param {object} [options.policyDefinition] see lib/policy/config.js —
|
|
129
|
-
* when omitted, loaded from cap-mcp-guard.yaml in options.cwd
|
|
130
|
-
* @param {string} [options.cwd] directory cap-mcp-guard.yaml is resolved
|
|
131
|
-
* from when policyDefinition isn't supplied — defaults to process.cwd()
|
|
132
|
-
* @param {string} [options.metadataXml] a `$metadata` EDMX/CSDL document
|
|
133
|
-
* (already fetched — this module never makes network calls itself),
|
|
134
|
-
* parsed once via lib/core/edm.js's parseMetadata()
|
|
135
|
-
* @param {object} [options.edm] an already-parsed EDM (see
|
|
136
|
-
* lib/core/edm.js parseMetadata()) — takes precedence over metadataXml
|
|
137
|
-
* @param {(context: object, req: object) => void} [options.onContext]
|
|
138
|
-
* called with the built context after each request completes
|
|
139
|
-
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
140
|
-
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
141
|
-
* logging entirely
|
|
142
|
-
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
143
|
-
* (`{ tracer }`); pass `false` to skip OTel span export entirely
|
|
144
|
-
* @param {(decision: object, context: object, req: object) => void} [options.onDecision]
|
|
145
|
-
* called in addition to the built-in audit log and OTel export, not
|
|
146
|
-
* instead of them
|
|
147
|
-
* @param {() => string} [options.now] injectable clock, forwarded to buildContext
|
|
148
|
-
* @returns {(req: object, res: object, next: Function) => void}
|
|
149
|
-
*/
|
|
150
|
-
function odataMcpGuard(options = {}) {
|
|
151
|
-
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(options.cwd);
|
|
152
|
-
const { onContext, onDecision: userOnDecision, audit, otel, now } = options;
|
|
153
|
-
const edm = options.edm || (options.metadataXml && parseMetadata(options.metadataXml));
|
|
154
|
-
const clockDeps = now ? { now } : {};
|
|
155
|
-
|
|
156
|
-
let warnedNoJson = false;
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* The full Context → Decision → mask/audit/OTel pipeline for one logical
|
|
160
|
-
* request — either the request as a whole, or (for a decoded $batch)
|
|
161
|
-
* one of its sub-requests. Mutates `rows` in place when enforcing.
|
|
162
|
-
*/
|
|
163
|
-
function runPipeline({ req, entity, operation, rows, rowCount, durationMs }) {
|
|
164
|
-
const context = buildContext(
|
|
165
|
-
{
|
|
166
|
-
...extractAgentInfo(req),
|
|
167
|
-
...extractTraceContext(req),
|
|
168
|
-
entity,
|
|
169
|
-
operation,
|
|
170
|
-
tenant: req.headers && req.headers['sap-tenantid'],
|
|
171
|
-
user: req.user && (req.user.id || req.user.name),
|
|
172
|
-
session: req.headers && req.headers['x-request-id'],
|
|
173
|
-
rowCount,
|
|
174
|
-
durationMs
|
|
175
|
-
},
|
|
176
|
-
clockDeps
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
if (typeof onContext === 'function') onContext(context, req);
|
|
180
|
-
if (!policyDefinition) return;
|
|
181
|
-
|
|
182
|
-
const decision = evaluate(context, policyDefinition);
|
|
183
|
-
|
|
184
|
-
if (decision.mode === 'enforce' && decision.fieldsToMask.length > 0) {
|
|
185
|
-
applyMask(rows, decision.fieldsToMask);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (audit !== false) logAudit(context, decision, audit);
|
|
189
|
-
if (otel !== false) exportSpan(context, decision, otel);
|
|
190
|
-
if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Runs one sub-request's worth of the pipeline, grouping $expand-ed nested entities too. */
|
|
194
|
-
function runSubRequestPipeline(req, url, method, responseBody, durationMs) {
|
|
195
|
-
const entity = resolveEntityPath(url, edm);
|
|
196
|
-
const operation = resolveOperation(method);
|
|
197
|
-
const expandTree = getExpandTree(url);
|
|
198
|
-
const groups = collectMaskableGroups(responseBody, entity, expandTree, edm);
|
|
199
|
-
|
|
200
|
-
if (groups.length === 0) {
|
|
201
|
-
runPipeline({ req, entity, operation, rows: [], rowCount: responseBody === undefined ? undefined : 0, durationMs });
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
for (const group of groups) {
|
|
206
|
-
runPipeline({ req, entity: group.entity, operation, rows: group.rows, rowCount: group.rows.length, durationMs });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Decodes and guards an OData V4 JSON batch: `req.body.requests` (each
|
|
212
|
-
* `{ id, method, url, ... }`) matched by `id` to `body.responses` (each
|
|
213
|
-
* `{ id, status, body, ... }`), so every sub-request is resolved,
|
|
214
|
-
* evaluated, and masked exactly like a standalone request would be.
|
|
215
|
-
* Returns false (nothing decoded — caller falls back to legacy
|
|
216
|
-
* pass-through) when either side isn't shaped like a V4 JSON batch.
|
|
217
|
-
*/
|
|
218
|
-
function runJsonBatchPipeline(req, body, durationMs) {
|
|
219
|
-
const requests = req.body && Array.isArray(req.body.requests) ? req.body.requests : undefined;
|
|
220
|
-
const responses = body && Array.isArray(body.responses) ? body.responses : undefined;
|
|
221
|
-
if (!requests || !responses) return false;
|
|
222
|
-
|
|
223
|
-
const requestById = new Map(requests.map((r) => [r.id, r]));
|
|
224
|
-
|
|
225
|
-
for (const responseEntry of responses) {
|
|
226
|
-
const subRequest = requestById.get(responseEntry.id);
|
|
227
|
-
if (!subRequest || !subRequest.url) continue;
|
|
228
|
-
|
|
229
|
-
runSubRequestPipeline(req, subRequest.url, subRequest.method, responseEntry.body, durationMs);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return true;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Zips a parsed batch request tree to its response tree by position —
|
|
237
|
-
* each top-level (or changeset-nested) request corresponds to exactly
|
|
238
|
-
* one response in the same slot, per OData $batch semantics. Mismatched
|
|
239
|
-
* shapes (a 'single' lined up against a 'changeset', or a length
|
|
240
|
-
* mismatch) are skipped rather than crashing the response.
|
|
241
|
-
*/
|
|
242
|
-
function zipClassicBatch(req, requestItems, responseItems, durationMs) {
|
|
243
|
-
const len = Math.min(requestItems.length, responseItems.length);
|
|
244
|
-
|
|
245
|
-
for (let i = 0; i < len; i++) {
|
|
246
|
-
const reqItem = requestItems[i];
|
|
247
|
-
const resItem = responseItems[i];
|
|
248
|
-
|
|
249
|
-
if (reqItem.kind === 'changeset' && resItem.kind === 'changeset') {
|
|
250
|
-
zipClassicBatch(req, reqItem.parts, resItem.parts, durationMs);
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (reqItem.kind !== 'single' || resItem.kind !== 'single') continue;
|
|
254
|
-
|
|
255
|
-
let parsedBody;
|
|
256
|
-
try {
|
|
257
|
-
parsedBody = resItem.body ? JSON.parse(resItem.body) : undefined;
|
|
258
|
-
} catch {
|
|
259
|
-
parsedBody = undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
runSubRequestPipeline(req, reqItem.url, reqItem.method, parsedBody, durationMs);
|
|
263
|
-
if (parsedBody !== undefined) resItem.body = JSON.stringify(parsedBody);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Decodes and guards a classic OData V2 `multipart/mixed` $batch: parses
|
|
269
|
-
* `req.body` (the raw request body — a JSON body-parser won't populate
|
|
270
|
-
* this for a multipart content-type, so the host needs something like
|
|
271
|
-
* `express.raw({ type: 'multipart/mixed' })` mounted first) and the raw
|
|
272
|
-
* response body about to be sent, zips requests to responses by
|
|
273
|
-
* position, masks each response's JSON in place, and returns the
|
|
274
|
-
* re-serialized response body. Returns false (caller falls back to
|
|
275
|
-
* legacy pass-through) when either side can't be decoded as multipart —
|
|
276
|
-
* missing boundary, `req.body` not pre-buffered, or malformed MIME.
|
|
277
|
-
*/
|
|
278
|
-
function runClassicBatchPipeline(req, res, rawResponseBody, durationMs) {
|
|
279
|
-
const reqBoundary = extractBoundary(req.headers && req.headers['content-type']);
|
|
280
|
-
const resBoundary = extractBoundary(typeof res.getHeader === 'function' ? res.getHeader('content-type') : undefined);
|
|
281
|
-
|
|
282
|
-
const rawRequestBody = req.body;
|
|
283
|
-
const requestIsBufferLike = typeof rawRequestBody === 'string' || Buffer.isBuffer(rawRequestBody);
|
|
284
|
-
const responseIsBufferLike = typeof rawResponseBody === 'string' || Buffer.isBuffer(rawResponseBody);
|
|
285
|
-
|
|
286
|
-
if (!reqBoundary || !resBoundary || !requestIsBufferLike || !responseIsBufferLike) return false;
|
|
287
|
-
|
|
288
|
-
let requestItems;
|
|
289
|
-
let responseItems;
|
|
290
|
-
try {
|
|
291
|
-
requestItems = parseBatchRequests(rawRequestBody, reqBoundary);
|
|
292
|
-
responseItems = parseBatchResponses(rawResponseBody, resBoundary);
|
|
293
|
-
} catch {
|
|
294
|
-
return false; // malformed multipart — don't crash the response, fall back to legacy pass-through
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
zipClassicBatch(req, requestItems, responseItems, durationMs);
|
|
298
|
-
return serializeBatchResponses(responseItems, resBoundary);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return function odataMcpGuardMiddleware(req, res, next) {
|
|
302
|
-
if (typeof res.json !== 'function') {
|
|
303
|
-
if (!warnedNoJson) {
|
|
304
|
-
console.warn('[cap-mcp-guard] res.json is not available; OData responses will pass through unguarded');
|
|
305
|
-
warnedNoJson = true;
|
|
306
|
-
}
|
|
307
|
-
return next();
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const startedAt = process.hrtime.bigint();
|
|
311
|
-
const operation = resolveOperation(req.method);
|
|
312
|
-
const batch = isBatchRequest(req.url);
|
|
313
|
-
const originalJson = res.json.bind(res);
|
|
314
|
-
|
|
315
|
-
// Shared between the res.json and res.send patches below: Express's
|
|
316
|
-
// real res.json() (captured as originalJson) internally calls
|
|
317
|
-
// this.send(...) — since that resolves to OUR patched res.send when
|
|
318
|
-
// `batch` is true, this flag stops the batch already handled by
|
|
319
|
-
// guardedJson from being processed a second time by guardedSend.
|
|
320
|
-
let handled = false;
|
|
321
|
-
|
|
322
|
-
res.json = function guardedJson(body) {
|
|
323
|
-
if (handled) return originalJson(body);
|
|
324
|
-
handled = true;
|
|
325
|
-
|
|
326
|
-
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
327
|
-
|
|
328
|
-
if (batch) {
|
|
329
|
-
if (!runJsonBatchPipeline(req, body, durationMs)) {
|
|
330
|
-
// classic multipart/mixed batch that didn't go through res.send,
|
|
331
|
-
// or req.body wasn't pre-parsed: still audited, nothing masked
|
|
332
|
-
runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
|
|
333
|
-
}
|
|
334
|
-
return originalJson(body);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
runSubRequestPipeline(req, req.url, req.method, body, durationMs);
|
|
338
|
-
return originalJson(body);
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
// Classic multipart/mixed $batch responses go out via res.send(rawBody),
|
|
342
|
-
// never res.json() — only patch res.send for batch requests, so the
|
|
343
|
-
// (already tested) non-batch path never touches it.
|
|
344
|
-
if (batch && typeof res.send === 'function') {
|
|
345
|
-
const originalSend = res.send.bind(res);
|
|
346
|
-
|
|
347
|
-
res.send = function guardedSend(bodyChunk) {
|
|
348
|
-
if (handled) return originalSend(bodyChunk);
|
|
349
|
-
handled = true;
|
|
350
|
-
|
|
351
|
-
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
352
|
-
const newBody = runClassicBatchPipeline(req, res, bodyChunk, durationMs);
|
|
353
|
-
|
|
354
|
-
if (newBody !== false) return originalSend(newBody);
|
|
355
|
-
|
|
356
|
-
// couldn't decode as classic multipart batch either: still audited
|
|
357
|
-
runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
|
|
358
|
-
return originalSend(bodyChunk);
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
next();
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
module.exports = { odataMcpGuard };
|