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/lib/core/interceptor.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { buildContext } = require('./context');
|
|
4
4
|
const { evaluate } = require('../policy/evaluator');
|
|
5
5
|
const { maskFields } = require('../policy/masking');
|
|
6
|
+
const { generatePseudonym } = require('../policy/pseudonym');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Reads W3C Trace Context fields propagated by the MCP runtime.
|
|
@@ -74,6 +75,91 @@ function applyMask(results, fieldsToMask) {
|
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Replaces each listed field's real value with a deterministic pseudonym (see
|
|
80
|
+
* lib/policy/pseudonym.js) — same real value always produces the same fake one, so an
|
|
81
|
+
* AI agent can still tell rows apart without ever seeing the real data. Mutates `results`
|
|
82
|
+
* in place, same convention as applyMask().
|
|
83
|
+
*
|
|
84
|
+
* @param {object|object[]} results
|
|
85
|
+
* @param {{field: string, type: string}[]} fieldsToPseudonymize
|
|
86
|
+
* @param {string} secret required whenever fieldsToPseudonymize is non-empty (see
|
|
87
|
+
* lib/adapters/cap.js, which resolves and validates this before attachInterceptor runs)
|
|
88
|
+
*/
|
|
89
|
+
function applyPseudonymize(results, fieldsToPseudonymize, secret) {
|
|
90
|
+
const items = Array.isArray(results) ? results : [results];
|
|
91
|
+
|
|
92
|
+
items.forEach((item) => {
|
|
93
|
+
if (!item || typeof item !== 'object') return;
|
|
94
|
+
for (const { field, type } of fieldsToPseudonymize) {
|
|
95
|
+
if (Object.prototype.hasOwnProperty.call(item, field)) {
|
|
96
|
+
item[field] = generatePseudonym(field, item[field], type, secret);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Finds the association/composition elements on a CAP entity definition, along with the
|
|
104
|
+
* (already service-qualified) entity name each one targets — e.g. for `Employees.department`,
|
|
105
|
+
* `{ name: 'department', targetEntity: 'CatalogService.Departments' }`. That target name is
|
|
106
|
+
* exactly what a request against `Departments` directly would resolve via resolveEntity(),
|
|
107
|
+
* so the same `policyDefinition.entities` keys apply to nested rows without any separate
|
|
108
|
+
* EDM/$metadata resolution — CAP's own compiled model already carries it.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} target req.target — a linked CSN entity definition, or undefined
|
|
111
|
+
* @returns {{name: string, targetEntity: string}[]}
|
|
112
|
+
*/
|
|
113
|
+
function findAssociationElements(target) {
|
|
114
|
+
if (!target || !target.elements) return [];
|
|
115
|
+
|
|
116
|
+
return Object.entries(target.elements)
|
|
117
|
+
.filter(([, el]) => el && (el.type === 'cds.Association' || el.type === 'cds.Composition'))
|
|
118
|
+
.map(([name, el]) => ({
|
|
119
|
+
name,
|
|
120
|
+
targetEntity: (el._target && el._target.name) || el.target
|
|
121
|
+
}))
|
|
122
|
+
.filter((assoc) => assoc.targetEntity);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Applies each expanded association's own policy (mask/pseudonymize) to the nested
|
|
127
|
+
* row(s) hanging off of it — one level deep, matching what `$expand` actually returns.
|
|
128
|
+
* Mutates `results` in place, same convention as applyMask()/applyPseudonymize(). A no-op
|
|
129
|
+
* when the request's entity has no associations, or none of them were expanded (the nav
|
|
130
|
+
* property is simply absent from the row), or the target entity has no policy configured.
|
|
131
|
+
*
|
|
132
|
+
* @param {object|object[]} results
|
|
133
|
+
* @param {object} target req.target — see findAssociationElements()
|
|
134
|
+
* @param {object} policyDefinition see lib/policy/config.js
|
|
135
|
+
* @param {string} [pseudonymSecret] forwarded to applyPseudonymize() for nested fields
|
|
136
|
+
*/
|
|
137
|
+
function applyNestedPolicy(results, target, policyDefinition, pseudonymSecret) {
|
|
138
|
+
const associations = findAssociationElements(target);
|
|
139
|
+
if (!associations.length) return;
|
|
140
|
+
|
|
141
|
+
const rows = Array.isArray(results) ? results : [results];
|
|
142
|
+
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
if (!row || typeof row !== 'object') continue;
|
|
145
|
+
|
|
146
|
+
for (const { name, targetEntity } of associations) {
|
|
147
|
+
const navValue = row[name];
|
|
148
|
+
if (navValue === undefined || navValue === null) continue;
|
|
149
|
+
|
|
150
|
+
const entityConfig = policyDefinition.entities[targetEntity];
|
|
151
|
+
if (!entityConfig) continue;
|
|
152
|
+
|
|
153
|
+
if (entityConfig.mask && entityConfig.mask.length > 0) {
|
|
154
|
+
applyMask(navValue, entityConfig.mask);
|
|
155
|
+
}
|
|
156
|
+
if (entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0) {
|
|
157
|
+
applyPseudonymize(navValue, entityConfig.pseudonymize, pseudonymSecret);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
77
163
|
/**
|
|
78
164
|
* Attaches request interception to a CAP service instance, producing a
|
|
79
165
|
* guard context (see context.js) for every request that passes through it.
|
|
@@ -98,18 +184,58 @@ function applyMask(results, fieldsToMask) {
|
|
|
98
184
|
* each request completes, regardless of mode — this is the extension
|
|
99
185
|
* point M5 (audit log) uses
|
|
100
186
|
* @param {() => string} [options.now] injectable clock, forwarded to buildContext
|
|
187
|
+
* @param {string} [options.pseudonymSecret] required whenever any entity configures
|
|
188
|
+
* `pseudonymize` — resolved and validated one layer up in lib/adapters/cap.js
|
|
101
189
|
*/
|
|
102
190
|
function attachInterceptor(srv, options = {}) {
|
|
103
191
|
if (!srv || typeof srv.before !== 'function' || typeof srv.after !== 'function') {
|
|
104
192
|
throw new Error('attachInterceptor requires a CAP service exposing before()/after() hooks');
|
|
105
193
|
}
|
|
106
194
|
|
|
107
|
-
const { onContext, policyDefinition, onDecision, now } = options;
|
|
195
|
+
const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
|
|
108
196
|
const clockDeps = now ? { now } : {};
|
|
109
197
|
|
|
198
|
+
function buildRequestContext(req, extra) {
|
|
199
|
+
return buildContext(
|
|
200
|
+
{
|
|
201
|
+
...extractAgentInfo(req),
|
|
202
|
+
...extractTraceContext(req),
|
|
203
|
+
entity: resolveEntity(req),
|
|
204
|
+
operation: resolveOperation(req),
|
|
205
|
+
tenant: req.tenant,
|
|
206
|
+
user: req.user && req.user.id,
|
|
207
|
+
session: req.id,
|
|
208
|
+
...extra
|
|
209
|
+
},
|
|
210
|
+
clockDeps
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
110
214
|
srv.before('*', (req) => {
|
|
111
215
|
if (!req) return;
|
|
112
216
|
req._mcpGuardStartedAt = process.hrtime.bigint();
|
|
217
|
+
|
|
218
|
+
if (!policyDefinition) return;
|
|
219
|
+
|
|
220
|
+
// allowTools is a gate, not a masking rule — it has to be enforced before the
|
|
221
|
+
// query runs, not after. rowCount/durationMs aren't known yet, but `allowed`
|
|
222
|
+
// never depends on either, so this decision is already final.
|
|
223
|
+
const context = buildRequestContext(req, {});
|
|
224
|
+
const decision = evaluate(context, policyDefinition);
|
|
225
|
+
|
|
226
|
+
if (decision.mode === 'enforce' && !decision.allowed) {
|
|
227
|
+
// req.reject() throws in real CAP requests, so srv.after('*') below never runs
|
|
228
|
+
// for a denied request — report it here instead, or the denial would never be
|
|
229
|
+
// audited/traced at all.
|
|
230
|
+
if (typeof onContext === 'function') onContext(context, req);
|
|
231
|
+
if (typeof onDecision === 'function') onDecision(decision, context, req);
|
|
232
|
+
|
|
233
|
+
if (typeof req.reject === 'function') {
|
|
234
|
+
req.reject(403, decision.reason);
|
|
235
|
+
} else {
|
|
236
|
+
throw new Error(decision.reason);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
113
239
|
});
|
|
114
240
|
|
|
115
241
|
srv.after('*', (results, req) => {
|
|
@@ -120,20 +246,7 @@ function attachInterceptor(srv, options = {}) {
|
|
|
120
246
|
? undefined
|
|
121
247
|
: Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
122
248
|
|
|
123
|
-
const context =
|
|
124
|
-
{
|
|
125
|
-
...extractAgentInfo(req),
|
|
126
|
-
...extractTraceContext(req),
|
|
127
|
-
entity: resolveEntity(req),
|
|
128
|
-
operation: resolveOperation(req),
|
|
129
|
-
tenant: req.tenant,
|
|
130
|
-
user: req.user && req.user.id,
|
|
131
|
-
session: req.id,
|
|
132
|
-
rowCount: resolveRowCount(results),
|
|
133
|
-
durationMs
|
|
134
|
-
},
|
|
135
|
-
clockDeps
|
|
136
|
-
);
|
|
249
|
+
const context = buildRequestContext(req, { rowCount: resolveRowCount(results), durationMs });
|
|
137
250
|
|
|
138
251
|
if (typeof onContext === 'function') {
|
|
139
252
|
onContext(context, req);
|
|
@@ -146,6 +259,18 @@ function attachInterceptor(srv, options = {}) {
|
|
|
146
259
|
applyMask(results, decision.fieldsToMask);
|
|
147
260
|
}
|
|
148
261
|
|
|
262
|
+
if (decision.mode === 'enforce' && decision.fieldsToPseudonymize.length > 0) {
|
|
263
|
+
applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (decision.mode === 'enforce' && decision.rowLimitExceeded && Array.isArray(results)) {
|
|
267
|
+
results.length = decision.maxRows;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (decision.mode === 'enforce') {
|
|
271
|
+
applyNestedPolicy(results, req.target, policyDefinition, pseudonymSecret);
|
|
272
|
+
}
|
|
273
|
+
|
|
149
274
|
if (typeof onDecision === 'function') {
|
|
150
275
|
onDecision(decision, context, req);
|
|
151
276
|
}
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,5 @@
|
|
|
2
2
|
|
|
3
3
|
const { buildContext } = require('./core/context');
|
|
4
4
|
const { attachInterceptor } = require('./core/interceptor');
|
|
5
|
-
const { odataMcpGuard } = require('./adapters/odata');
|
|
6
5
|
|
|
7
|
-
module.exports = { buildContext, attachInterceptor
|
|
6
|
+
module.exports = { buildContext, attachInterceptor };
|
package/lib/policy/config.js
CHANGED
|
@@ -1,106 +1,191 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
throw new Error(`
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
entities
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const { PSEUDONYM_TYPES } = require('./pseudonym');
|
|
6
|
+
|
|
7
|
+
const VALID_MODES = ['enforce', 'observe'];
|
|
8
|
+
const CONFIG_KEY = 'cap-mcp-guard';
|
|
9
|
+
const NOT_CONFIGURED_PREFIX = 'cap-mcp-guard not configured';
|
|
10
|
+
|
|
11
|
+
function typeOf(value) {
|
|
12
|
+
if (Array.isArray(value)) return 'array';
|
|
13
|
+
if (value === null) return 'null';
|
|
14
|
+
return typeof value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates the optional top-level `"audit"` key — the same shape
|
|
19
|
+
* lib/audit/log.js's writeAuditEntry() options take (`{ stdout, filePath }`).
|
|
20
|
+
* Exposing it in package.json is what lets audit-log persistence be turned on
|
|
21
|
+
* through config alone, without hand-writing a registerCapMcpGuard() call
|
|
22
|
+
* (see lib/adapters/cap.js, which forwards this to onDecision's audit option).
|
|
23
|
+
*/
|
|
24
|
+
function validateAuditConfig(raw) {
|
|
25
|
+
if (raw === undefined) return undefined;
|
|
26
|
+
|
|
27
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
28
|
+
throw new Error(`"audit" must be a mapping, got ${typeOf(raw)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { filePath, stdout } = raw;
|
|
32
|
+
if (filePath !== undefined && typeof filePath !== 'string') {
|
|
33
|
+
throw new Error(`"audit.filePath" must be a string, got ${typeOf(filePath)}`);
|
|
34
|
+
}
|
|
35
|
+
if (stdout !== undefined && typeof stdout !== 'boolean') {
|
|
36
|
+
throw new Error(`"audit.stdout" must be a boolean, got ${typeOf(stdout)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { ...(filePath !== undefined && { filePath }), ...(stdout !== undefined && { stdout }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validatePseudonymizeEntry(entityName, entry) {
|
|
43
|
+
if (typeof entry === 'string') {
|
|
44
|
+
return { field: entry, type: 'opaque' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
48
|
+
const { field, type = 'opaque' } = entry;
|
|
49
|
+
|
|
50
|
+
if (typeof field !== 'string' || !field) {
|
|
51
|
+
throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
|
|
52
|
+
}
|
|
53
|
+
if (!PSEUDONYM_TYPES.includes(type)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return { field, type };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
`entities.${entityName}.pseudonymize entries must be a string or a {field, type} object, got ${typeOf(entry)}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateEntityConfig(name, raw) {
|
|
67
|
+
const config = raw ?? {};
|
|
68
|
+
|
|
69
|
+
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
70
|
+
throw new Error(`entities.${name} must be a mapping, got ${typeOf(config)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { mask, maxRows, allowTools, pseudonymize: rawPseudonymize } = config;
|
|
74
|
+
|
|
75
|
+
if (mask !== undefined && !Array.isArray(mask)) {
|
|
76
|
+
throw new Error(`entities.${name}.mask must be an array, got ${typeOf(mask)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (allowTools !== undefined && !Array.isArray(allowTools)) {
|
|
80
|
+
throw new Error(`entities.${name}.allowTools must be an array, got ${typeOf(allowTools)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (maxRows !== undefined) {
|
|
84
|
+
if (typeof maxRows !== 'number' || Number.isNaN(maxRows)) {
|
|
85
|
+
throw new Error(`entities.${name}.maxRows must be a number, got ${typeOf(maxRows)}`);
|
|
86
|
+
}
|
|
87
|
+
if (maxRows < 0) {
|
|
88
|
+
throw new Error(`entities.${name}.maxRows must not be negative (got ${maxRows})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let pseudonymize;
|
|
93
|
+
if (rawPseudonymize !== undefined) {
|
|
94
|
+
if (!Array.isArray(rawPseudonymize)) {
|
|
95
|
+
throw new Error(`entities.${name}.pseudonymize must be an array, got ${typeOf(rawPseudonymize)}`);
|
|
96
|
+
}
|
|
97
|
+
pseudonymize = rawPseudonymize.map((entry) => validatePseudonymizeEntry(name, entry));
|
|
98
|
+
|
|
99
|
+
const maskedFields = new Set(mask ?? []);
|
|
100
|
+
for (const { field } of pseudonymize) {
|
|
101
|
+
if (maskedFields.has(field)) {
|
|
102
|
+
throw new Error(`entities.${name}: "${field}" cannot be listed in both "mask" and "pseudonymize"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { mask, maxRows, allowTools, pseudonymize };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validates an already-parsed `"cap-mcp-guard"` config object (the value of
|
|
112
|
+
* that key in package.json) into a PolicyDefinition — the source-agnostic
|
|
113
|
+
* shape the policy engine consumes. Pure: no filesystem access.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} raw the parsed `"cap-mcp-guard"` value
|
|
116
|
+
* @param {object} [opts]
|
|
117
|
+
* @param {string} [opts.source] label used in error messages (defaults to
|
|
118
|
+
* the conventional file name; loadConfig() passes the real path instead)
|
|
119
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
120
|
+
*/
|
|
121
|
+
function parseConfig(raw, opts = {}) {
|
|
122
|
+
const { source = 'package.json' } = opts;
|
|
123
|
+
|
|
124
|
+
if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
125
|
+
throw new Error(`${source}: "${CONFIG_KEY}" must be a mapping with a "mode" key`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!VALID_MODES.includes(raw.mode)) {
|
|
129
|
+
throw new Error(`"mode" must be one of ${VALID_MODES.join(', ')} (got ${JSON.stringify(raw.mode)})`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rawEntities = raw.entities ?? {};
|
|
133
|
+
if (typeof rawEntities !== 'object' || Array.isArray(rawEntities)) {
|
|
134
|
+
throw new Error(`"entities" must be a mapping of entity name to config, got ${typeOf(rawEntities)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const entities = {};
|
|
138
|
+
for (const [name, entityRaw] of Object.entries(rawEntities)) {
|
|
139
|
+
entities[name] = validateEntityConfig(name, entityRaw);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (raw.services !== undefined && !Array.isArray(raw.services)) {
|
|
143
|
+
throw new Error(`"services" must be an array, got ${typeOf(raw.services)}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (raw.users !== undefined && !Array.isArray(raw.users)) {
|
|
147
|
+
throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const audit = validateAuditConfig(raw.audit);
|
|
151
|
+
|
|
152
|
+
return { mode: raw.mode, entities, services: raw.services, users: raw.users, audit };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reads the host project's package.json and validates its `"cap-mcp-guard"`
|
|
157
|
+
* key via parseConfig(). Both "no package.json at this path" and "package.json
|
|
158
|
+
* exists but has no cap-mcp-guard key" are reported under the same
|
|
159
|
+
* NOT_CONFIGURED_PREFIX, so callers can treat either as "not configured yet"
|
|
160
|
+
* with a single startsWith() check and fall back to pass-through mode.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} packageJsonPath
|
|
163
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
164
|
+
*/
|
|
165
|
+
function loadConfig(packageJsonPath) {
|
|
166
|
+
let content;
|
|
167
|
+
try {
|
|
168
|
+
content = fs.readFileSync(packageJsonPath, 'utf8');
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err.code === 'ENOENT') {
|
|
171
|
+
throw new Error(`${NOT_CONFIGURED_PREFIX}: no package.json found at ${packageJsonPath}`);
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let pkg;
|
|
177
|
+
try {
|
|
178
|
+
pkg = JSON.parse(content);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
throw new Error(`Failed to parse ${packageJsonPath}: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const raw = pkg[CONFIG_KEY];
|
|
184
|
+
if (raw === undefined) {
|
|
185
|
+
throw new Error(`${NOT_CONFIGURED_PREFIX}: "${CONFIG_KEY}" key not found in ${packageJsonPath}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return parseConfig(raw, { source: packageJsonPath });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { loadConfig, parseConfig, NOT_CONFIGURED_PREFIX };
|
package/lib/policy/evaluator.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
function passThroughDecision(context, policyDefinition) {
|
|
4
|
+
return {
|
|
5
|
+
mode: policyDefinition.mode,
|
|
6
|
+
allowed: true,
|
|
7
|
+
reason: null,
|
|
8
|
+
fieldsToMask: [],
|
|
9
|
+
fieldsToPseudonymize: [],
|
|
10
|
+
rowLimitExceeded: false,
|
|
11
|
+
maxRows: null,
|
|
12
|
+
entity: context.entity,
|
|
13
|
+
timestamp: context.timestamp
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
/**
|
|
4
18
|
* Evaluates a request Context (lib/core/context.js) against a
|
|
5
19
|
* PolicyDefinition (lib/policy/config.js) and produces a Decision — what
|
|
@@ -14,22 +28,22 @@
|
|
|
14
28
|
* @returns {object} Decision
|
|
15
29
|
*/
|
|
16
30
|
function evaluate(context, policyDefinition) {
|
|
31
|
+
// policyDefinition.users, when set, scopes enforcement to just those identities
|
|
32
|
+
// (context.user, populated from CAP's own verified req.user.id) — e.g. a technical
|
|
33
|
+
// user the MCP runtime authenticates as, distinct from a human UI user hitting the
|
|
34
|
+
// same service. A request from anyone not in the list is fully allowed/unmasked,
|
|
35
|
+
// as if this entity had no policy at all.
|
|
36
|
+
if (Array.isArray(policyDefinition.users) && !policyDefinition.users.includes(context.user)) {
|
|
37
|
+
return passThroughDecision(context, policyDefinition);
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
const entityConfig = policyDefinition.entities[context.entity];
|
|
18
41
|
|
|
19
42
|
if (!entityConfig) {
|
|
20
|
-
return
|
|
21
|
-
mode: policyDefinition.mode,
|
|
22
|
-
allowed: true,
|
|
23
|
-
reason: null,
|
|
24
|
-
fieldsToMask: [],
|
|
25
|
-
rowLimitExceeded: false,
|
|
26
|
-
maxRows: null,
|
|
27
|
-
entity: context.entity,
|
|
28
|
-
timestamp: context.timestamp
|
|
29
|
-
};
|
|
43
|
+
return passThroughDecision(context, policyDefinition);
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
const { allowTools, maxRows, mask } = entityConfig;
|
|
46
|
+
const { allowTools, maxRows, mask, pseudonymize } = entityConfig;
|
|
33
47
|
|
|
34
48
|
let allowed = true;
|
|
35
49
|
let reason = null;
|
|
@@ -46,6 +60,7 @@ function evaluate(context, policyDefinition) {
|
|
|
46
60
|
allowed,
|
|
47
61
|
reason,
|
|
48
62
|
fieldsToMask: mask ?? [],
|
|
63
|
+
fieldsToPseudonymize: pseudonymize ?? [],
|
|
49
64
|
rowLimitExceeded,
|
|
50
65
|
maxRows: typeof maxRows === 'number' ? maxRows : null,
|
|
51
66
|
entity: context.entity,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const PSEUDONYM_TYPES = ['opaque', 'iban'];
|
|
6
|
+
|
|
7
|
+
const IBAN_SHAPE = /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/;
|
|
8
|
+
|
|
9
|
+
function hmacHex(value, secret) {
|
|
10
|
+
return crypto.createHmac('sha256', secret).update(String(value)).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generic fallback: a deterministic, opaque token — same field+value+secret always
|
|
15
|
+
* produces the same token, but the token carries no information about the real value
|
|
16
|
+
* and doesn't resemble any particular data format.
|
|
17
|
+
*/
|
|
18
|
+
function generateOpaquePseudonym(field, value, secret) {
|
|
19
|
+
const hex = hmacHex(`${field}:${value}`, secret);
|
|
20
|
+
return `${field}-${hex.slice(0, 12)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Computes the 2-digit ISO 7064 MOD 97-10 check digits for an IBAN whose check-digit
|
|
25
|
+
* positions (chars 3-4) are currently '00'. Standard algorithm: move the first 4 chars
|
|
26
|
+
* to the end, map letters to numbers (A=10 .. Z=35), reduce mod 97 as a big integer via
|
|
27
|
+
* BigInt, check digits = 98 - remainder.
|
|
28
|
+
*/
|
|
29
|
+
function computeIbanCheckDigits(ibanWithZeroedCheckDigits) {
|
|
30
|
+
const rearranged = ibanWithZeroedCheckDigits.slice(4) + ibanWithZeroedCheckDigits.slice(0, 4);
|
|
31
|
+
const numeric = rearranged.replace(/[A-Z]/g, (letter) => String(letter.charCodeAt(0) - 55));
|
|
32
|
+
const remainder = BigInt(numeric) % 97n;
|
|
33
|
+
return String(98n - remainder).padStart(2, '0');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic, format-preserving fake IBAN: same country code and total length as the
|
|
38
|
+
* real value, digits derived from HMAC(value, secret), and a real, valid ISO 7064 MOD
|
|
39
|
+
* 97-10 check digit pair — so it passes standard IBAN checksum validation even though
|
|
40
|
+
* it's entirely fake. Falls back to generateOpaquePseudonym() for anything that doesn't
|
|
41
|
+
* look like an IBAN (defensive — real-world field content can't be validated ahead of
|
|
42
|
+
* time the way config shape can).
|
|
43
|
+
*/
|
|
44
|
+
function generateIbanPseudonym(value, secret) {
|
|
45
|
+
const normalized = String(value).replace(/\s+/g, '').toUpperCase();
|
|
46
|
+
|
|
47
|
+
if (!IBAN_SHAPE.test(normalized)) {
|
|
48
|
+
return generateOpaquePseudonym('IBAN', value, secret);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const countryCode = normalized.slice(0, 2);
|
|
52
|
+
const bbanLength = normalized.length - 4;
|
|
53
|
+
|
|
54
|
+
const hex = hmacHex(normalized, secret);
|
|
55
|
+
let digits = '';
|
|
56
|
+
for (let i = 0; digits.length < bbanLength; i++) {
|
|
57
|
+
const byte = parseInt(hex[i % hex.length], 16);
|
|
58
|
+
digits += String(byte % 10);
|
|
59
|
+
}
|
|
60
|
+
digits = digits.slice(0, bbanLength);
|
|
61
|
+
|
|
62
|
+
const withZeroedCheckDigits = `${countryCode}00${digits}`;
|
|
63
|
+
const checkDigits = computeIbanCheckDigits(withZeroedCheckDigits);
|
|
64
|
+
|
|
65
|
+
return `${countryCode}${checkDigits}${digits}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const GENERATORS = {
|
|
69
|
+
opaque: generateOpaquePseudonym,
|
|
70
|
+
iban: (field, value, secret) => generateIbanPseudonym(value, secret)
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} field the field name being pseudonymized
|
|
75
|
+
* @param {*} value the real value
|
|
76
|
+
* @param {string} type one of PSEUDONYM_TYPES (validated by lib/policy/config.js)
|
|
77
|
+
* @param {string} secret never embedded in the output; without it, the pseudonym can't
|
|
78
|
+
* be reproduced or (for the opaque case) traced back to a specific value
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function generatePseudonym(field, value, type, secret) {
|
|
82
|
+
const generator = GENERATORS[type];
|
|
83
|
+
if (!generator) {
|
|
84
|
+
throw new Error(`Unknown pseudonym type "${type}"`);
|
|
85
|
+
}
|
|
86
|
+
return generator(field, value, secret);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { generatePseudonym, PSEUDONYM_TYPES };
|