cap-mcp-guard 0.2.0 → 0.3.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.
@@ -1,161 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * A minimal reader/writer for the classic OData V2 `$batch` wire format:
5
- * a `multipart/mixed` body whose parts are either a single embedded HTTP
6
- * request/response (`Content-Type: application/http`) or a nested
7
- * `multipart/mixed` "changeset" grouping several write operations
8
- * atomically. Not a general-purpose MIME implementation — just enough
9
- * structure to zip a batch's requests to its responses by position and
10
- * mask each response's JSON body in place.
11
- *
12
- * Responses are re-serialized canonically (consistent CRLF line endings,
13
- * consistent header ordering per part) rather than byte-for-byte
14
- * preserved — that's syntactically equivalent multipart/mixed, which is
15
- * all any compliant OData client relies on; it does not reproduce the
16
- * exact original bytes.
17
- *
18
- * Pure — no I/O, no HTTP framework dependency.
19
- */
20
-
21
- function toText(value) {
22
- return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
23
- }
24
-
25
- /**
26
- * @param {string} contentType a Content-Type header value
27
- * @returns {string|undefined} the `boundary` parameter, unquoted
28
- */
29
- function extractBoundary(contentType) {
30
- const m = /boundary=("?)([^;"]+)\1/i.exec(contentType || '');
31
- return m ? m[2] : undefined;
32
- }
33
-
34
- /** Splits a multipart body into its raw part strings, boundary markers stripped. */
35
- function splitMultipart(rawBody, boundary) {
36
- const text = toText(rawBody);
37
- const marker = `--${boundary}`;
38
- const segments = text.split(marker);
39
-
40
- const parts = [];
41
- for (let i = 1; i < segments.length; i++) {
42
- const segment = segments[i];
43
- if (segment.startsWith('--')) break; // the closing "--boundary--" marker
44
-
45
- const trimmed = segment.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
46
- if (trimmed.length) parts.push(trimmed);
47
- }
48
- return parts;
49
- }
50
-
51
- function splitHeadersAndBody(text) {
52
- const m = /\r?\n\r?\n/.exec(text);
53
- if (!m) return { headerBlock: text, body: '' };
54
- return { headerBlock: text.slice(0, m.index), body: text.slice(m.index + m[0].length) };
55
- }
56
-
57
- function parseHeaderBlock(headerBlock) {
58
- const headers = {};
59
- for (const line of headerBlock.split(/\r?\n/)) {
60
- const m = /^([^:]+):\s*(.*)$/.exec(line);
61
- if (m) headers[m[1].trim().toLowerCase()] = m[2].trim();
62
- }
63
- return headers;
64
- }
65
-
66
- /** One embedded `application/http` part: a request-line/status-line, headers, and a body. */
67
- function parseHttpEnvelope(text, { isResponse }) {
68
- const { headerBlock, body } = splitHeadersAndBody(text);
69
- const [firstLine, ...headerLines] = headerBlock.split(/\r?\n/);
70
-
71
- if (isResponse) {
72
- const m = /^HTTP\/[\d.]+\s+(\d+)\s*(.*)$/.exec((firstLine || '').trim());
73
- return {
74
- kind: 'single',
75
- status: m ? Number(m[1]) : 200,
76
- statusText: m ? m[2].trim() : 'OK',
77
- headerLines: headerLines.filter((l) => l.trim().length > 0),
78
- body
79
- };
80
- }
81
-
82
- const m = /^(\S+)\s+(\S+)\s+HTTP\/[\d.]+$/.exec((firstLine || '').trim());
83
- return {
84
- kind: 'single',
85
- method: m ? m[1] : undefined,
86
- url: m ? m[2] : undefined,
87
- headerLines: headerLines.filter((l) => l.trim().length > 0),
88
- body
89
- };
90
- }
91
-
92
- /** Parses one top-level (or changeset-nested) batch part, recursing into changesets. */
93
- function parseBatchPart(rawPart, { isResponse }) {
94
- const { headerBlock, body } = splitHeadersAndBody(rawPart);
95
- const headers = parseHeaderBlock(headerBlock);
96
- const contentType = headers['content-type'] || '';
97
-
98
- if (/multipart\/mixed/i.test(contentType)) {
99
- const nestedBoundary = extractBoundary(contentType);
100
- return {
101
- kind: 'changeset',
102
- boundary: nestedBoundary,
103
- parts: nestedBoundary ? splitMultipart(body, nestedBoundary).map((p) => parseBatchPart(p, { isResponse })) : []
104
- };
105
- }
106
-
107
- return parseHttpEnvelope(body, { isResponse });
108
- }
109
-
110
- /**
111
- * @param {string|Buffer} rawBody the $batch request body
112
- * @param {string} boundary from the request's Content-Type header
113
- * @returns {object[]} tree of `{ kind: 'single', method, url, ... } | { kind: 'changeset', boundary, parts }`
114
- */
115
- function parseBatchRequests(rawBody, boundary) {
116
- return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: false }));
117
- }
118
-
119
- /**
120
- * @param {string|Buffer} rawBody the $batch response body
121
- * @param {string} boundary from the response's Content-Type header
122
- * @returns {object[]} tree of `{ kind: 'single', status, statusText, headerLines, body } | { kind: 'changeset', boundary, parts }`
123
- */
124
- function parseBatchResponses(rawBody, boundary) {
125
- return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: true }));
126
- }
127
-
128
- function serializeEnvelope(envelope) {
129
- const startLine = `HTTP/1.1 ${envelope.status} ${envelope.statusText}`;
130
- const headerText = envelope.headerLines.map((l) => `${l}\r\n`).join('');
131
- return `${startLine}\r\n${headerText}\r\n${envelope.body}`;
132
- }
133
-
134
- /**
135
- * Re-serializes a (possibly mutated) response tree — see parseBatchResponses()
136
- * — back into a valid `multipart/mixed` body using `boundary`.
137
- *
138
- * @param {object[]} items as returned by parseBatchResponses()
139
- * @param {string} boundary
140
- * @returns {string}
141
- */
142
- function serializeBatchResponses(items, boundary) {
143
- let out = '';
144
-
145
- for (const item of items) {
146
- out += `--${boundary}\r\n`;
147
-
148
- if (item.kind === 'changeset') {
149
- out += `Content-Type: multipart/mixed; boundary=${item.boundary}\r\n\r\n`;
150
- out += serializeBatchResponses(item.parts, item.boundary);
151
- } else {
152
- out += `Content-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n\r\n`;
153
- out += `${serializeEnvelope(item)}\r\n`;
154
- }
155
- }
156
-
157
- out += `--${boundary}--\r\n`;
158
- return out;
159
- }
160
-
161
- module.exports = { extractBoundary, splitMultipart, parseBatchRequests, parseBatchResponses, serializeBatchResponses };
package/lib/core/odata.js DELETED
@@ -1,178 +0,0 @@
1
- 'use strict';
2
-
3
- const { resolveEntityForPath, resolveExpandTarget } = require('./edm');
4
-
5
- const ODATA_SYSTEM_SEGMENTS = new Set(['$metadata', '$batch']);
6
-
7
- const METHOD_TO_OPERATION = {
8
- GET: 'READ',
9
- POST: 'CREATE',
10
- PUT: 'UPDATE',
11
- PATCH: 'UPDATE',
12
- MERGE: 'UPDATE',
13
- DELETE: 'DELETE'
14
- };
15
-
16
- /**
17
- * Resolves the OData entity-set (or navigation property) name a request
18
- * targets, from its URL path alone — no service metadata ($metadata/CSDL)
19
- * is consulted. Works for both OData V2 (`/sap/opu/odata/sap/ZGW_SRV/Products`)
20
- * and V4 (`/odata/v4/browse/Books(201)`) path conventions: it's the last
21
- * path segment, with any `(key)` predicate stripped.
22
- *
23
- * Returns undefined for OData system resources ($metadata, $batch) since
24
- * those don't map to a single configurable entity.
25
- *
26
- * Pure function, no HTTP framework dependency.
27
- *
28
- * @param {string} url request path or full URL (query string is ignored)
29
- * @returns {string|undefined}
30
- */
31
- function resolveEntitySet(url) {
32
- if (!url) return undefined;
33
-
34
- const pathOnly = url.split('?')[0];
35
- const segments = pathOnly.split('/').filter(Boolean);
36
- if (segments.length === 0) return undefined;
37
-
38
- const last = segments[segments.length - 1].replace(/\(.*$/, '');
39
- if (!last || ODATA_SYSTEM_SEGMENTS.has(last) || last.startsWith('$')) return undefined;
40
-
41
- return last;
42
- }
43
-
44
- /**
45
- * Maps an HTTP method onto the same CQN-style operation vocabulary the
46
- * guard already uses for CAP requests (READ/CREATE/UPDATE/DELETE, see
47
- * lib/adapters/cap.js's resolveOperation) — so one cap-mcp-guard.yaml
48
- * applies unchanged whether traffic arrives via CAP's before/after hooks
49
- * or raw OData HTTP.
50
- *
51
- * @param {string} method
52
- * @returns {string|undefined}
53
- */
54
- function resolveOperation(method) {
55
- return METHOD_TO_OPERATION[(method || '').toUpperCase()];
56
- }
57
-
58
- /**
59
- * @param {string} url
60
- * @returns {boolean} true for a $batch request (OData V2 and V4 both use
61
- * this convention). Its body is a multipart mix of sub-requests this
62
- * module does not decode, so per-entity masking does not reach inside it
63
- * — see README "Limitations".
64
- */
65
- function isBatchRequest(url) {
66
- if (!url) return false;
67
- const pathOnly = url.split('?')[0];
68
- return pathOnly.split('/').filter(Boolean).pop() === '$batch';
69
- }
70
-
71
- /**
72
- * Extracts the row objects an OData JSON response body carries, regardless
73
- * of whether it's a V2 collection (`{ d: { results: [...] } }`), a V2
74
- * single entity (`{ d: {...} }`), a V4 collection (`{ value: [...] }`), or
75
- * a V4 single entity (a bare entity object carrying `@odata.context`).
76
- *
77
- * The returned rows are the SAME object references found inside `body` —
78
- * mutating a returned row mutates the response body in place, mirroring
79
- * how CAP's `after` handlers mutate results in place (see
80
- * lib/core/interceptor.js's applyMask).
81
- *
82
- * @param {*} body parsed JSON response body
83
- * @returns {object[]} row objects (empty array if the shape is unrecognized)
84
- */
85
- function extractResultRows(body) {
86
- if (!body || typeof body !== 'object') return [];
87
-
88
- if (Array.isArray(body.value)) return body.value;
89
- if (body.d && Array.isArray(body.d.results)) return body.d.results;
90
- if (body.d && typeof body.d === 'object') return [body.d];
91
- if ('@odata.context' in body) return [body];
92
-
93
- return [];
94
- }
95
-
96
- /**
97
- * Resolves the FULL request path (every segment, not just the last) into
98
- * a policy-config-friendly entity name. Without `edm`, this is exactly
99
- * resolveEntitySet()'s single-last-segment heuristic — unchanged behavior.
100
- * With `edm` (see lib/core/edm.js's parseMetadata()), the whole path is
101
- * walked hop-by-hop through navigation properties, so a deep path like
102
- * `Books(201)/author` resolves to `author`'s real target entity instead
103
- * of the literal string `"author"`.
104
- *
105
- * @param {string} url
106
- * @param {object} [edm] see lib/core/edm.js parseMetadata()
107
- * @returns {string|undefined}
108
- */
109
- function resolveEntityPath(url, edm) {
110
- if (!edm) return resolveEntitySet(url);
111
- if (!url) return undefined;
112
-
113
- const pathOnly = url.split('?')[0];
114
- const segments = pathOnly
115
- .split('/')
116
- .filter(Boolean)
117
- .map((s) => s.replace(/\(.*$/, ''))
118
- .filter((s) => s && !ODATA_SYSTEM_SEGMENTS.has(s) && !s.startsWith('$'));
119
-
120
- return resolveEntityForPath(edm, segments);
121
- }
122
-
123
- function nestedRowsFor(row, navName) {
124
- const value = row && typeof row === 'object' ? row[navName] : undefined;
125
-
126
- if (Array.isArray(value)) return value;
127
- if (value && Array.isArray(value.results)) return value.results; // OData V2 expand shape
128
- if (value && typeof value === 'object' && !('__deferred' in value)) return [value];
129
-
130
- return [];
131
- }
132
-
133
- /**
134
- * Walks a parsed OData response body and groups every row it contains —
135
- * the top-level collection/entity plus every `$expand`'ed nested
136
- * entity/collection reachable through `expandTree` — by the entity name
137
- * masking rules should be looked up under. Each group's rows are the SAME
138
- * object references found inside `body` (see extractResultRows), so
139
- * masking a group's rows mutates the response body in place.
140
- *
141
- * Without an `edm`, a nested entity's name is just its nav-property name
142
- * as it appears in `$expand` — good enough when cap-mcp-guard.yaml happens
143
- * to key an entity by that same name, but not a real entity-set/type
144
- * resolution. Pass `edm` (see lib/core/edm.js) to resolve nav properties
145
- * to their real target entity instead.
146
- *
147
- * @param {*} body parsed JSON response body
148
- * @param {string} rootEntity entity name for the top-level rows
149
- * @param {object} [expandTree] see lib/core/edm.js parseExpandOption()
150
- * @param {object} [edm] see lib/core/edm.js parseMetadata()
151
- * @returns {{ entity: string, rows: object[] }[]}
152
- */
153
- function collectMaskableGroups(body, rootEntity, expandTree = {}, edm) {
154
- const groups = [];
155
-
156
- function walk(rows, entityName, tree) {
157
- if (rows.length) groups.push({ entity: entityName, rows });
158
-
159
- for (const [navName, subTree] of Object.entries(tree)) {
160
- const nestedEntity = edm ? resolveExpandTarget(edm, entityName, navName) : navName;
161
- const nestedRows = rows.flatMap((row) => nestedRowsFor(row, navName));
162
-
163
- walk(nestedRows, nestedEntity, subTree);
164
- }
165
- }
166
-
167
- walk(extractResultRows(body), rootEntity, expandTree);
168
- return groups;
169
- }
170
-
171
- module.exports = {
172
- resolveEntitySet,
173
- resolveOperation,
174
- isBatchRequest,
175
- extractResultRows,
176
- resolveEntityPath,
177
- collectMaskableGroups
178
- };