cap-mcp-guard 0.3.0 → 0.4.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.
- package/README.md +28 -2
- package/lib/adapters/cap.js +35 -9
- package/lib/core/interceptor.js +110 -16
- package/lib/policy/config.js +40 -4
- package/lib/policy/pseudonym.js +12 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,6 +184,32 @@ to load if it is). More typed generators (e.g. a Luhn-valid fake credit card num
|
|
|
184
184
|
added later without changing this config shape — anything without a dedicated generator
|
|
185
185
|
just falls back to `"opaque"`.
|
|
186
186
|
|
|
187
|
+
Sometimes you don't want a *derived* fake value at all — just one fixed, human-chosen
|
|
188
|
+
replacement, the same for every row (e.g. a shared support alias instead of each customer's
|
|
189
|
+
real email). Use `type: "custom"` with a required `"value"`:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"cap-mcp-guard": {
|
|
194
|
+
"mode": "enforce",
|
|
195
|
+
"entities": {
|
|
196
|
+
"Customers": {
|
|
197
|
+
"pseudonymize": [{ "field": "Email", "type": "custom", "value": "hidden@example.com" }]
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Unlike `"opaque"`/`"iban"`, `"custom"` doesn't derive anything from the real value or the
|
|
205
|
+
pseudonym secret — every row gets the exact same literal, so it doesn't need
|
|
206
|
+
`CAP_MCP_GUARD_PSEUDONYM_SECRET` at all (a config that mixes a `"custom"` entry with a
|
|
207
|
+
non-`"custom"` one on the same or another entity still needs the secret, for the other
|
|
208
|
+
entry). Because every real value maps to the same output, `"custom"` gives up the
|
|
209
|
+
relational structure (telling two customers apart) that `"opaque"`/`"iban"` preserve — it's
|
|
210
|
+
closer in effect to `mask`, just with your own replacement string instead of the fixed
|
|
211
|
+
`'***MASKED***'`.
|
|
212
|
+
|
|
187
213
|
**Requires a secret.** Set the `CAP_MCP_GUARD_PSEUDONYM_SECRET` environment variable (or
|
|
188
214
|
pass `pseudonymSecret` directly to `registerCapMcpGuard`) — every pseudonym is derived from
|
|
189
215
|
it via HMAC, so without it a fake value can't be reproduced or tied back to a real one.
|
|
@@ -194,7 +220,8 @@ fake one from then on) — this is expected, not a bug.
|
|
|
194
220
|
|
|
195
221
|
## What you get, per request
|
|
196
222
|
|
|
197
|
-
- **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'`, and fields under `pseudonymize` with a deterministic fake value (see above), in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen.
|
|
223
|
+
- **Masking** — in `enforce` mode, fields listed under `mask` are replaced with `'***MASKED***'`, and fields under `pseudonymize` with a deterministic fake value (see above), in the real response. In `observe` mode nothing is touched; the guard only computes what *would* happen. Also applied one level deep to any `$expand`ed association whose target entity has its own policy.
|
|
224
|
+
- **Tool/row enforcement** — in `enforce` mode, a request naming an operation outside `allowTools` is rejected with a 403 before it runs; a response exceeding `maxRows` is truncated to that limit. In `observe` mode both are only computed and reported, never applied.
|
|
198
225
|
- **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
|
|
199
226
|
- **OpenTelemetry spans** — every request also becomes a real span via `@opentelemetry/api`. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (`traceparent`/`tracestate`) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (`gen_ai.*`) from the start.
|
|
200
227
|
|
|
@@ -217,7 +244,6 @@ npm start # boots a real server at localhost:4004 — flip package.json's "cap
|
|
|
217
244
|
- `@mcp.policy`-style CDS annotations as an alternative to the `"cap-mcp-guard"` package.json config
|
|
218
245
|
- Approval workflows (human-in-the-loop for sensitive operations)
|
|
219
246
|
- Rate limiting and a dashboard UI
|
|
220
|
-
- Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
|
|
221
247
|
|
|
222
248
|
## Development
|
|
223
249
|
|
package/lib/adapters/cap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
|
|
5
6
|
const { attachInterceptor } = require('../core/interceptor');
|
|
@@ -7,23 +8,42 @@ const { loadConfig, NOT_CONFIGURED_PREFIX } = require('../policy/config');
|
|
|
7
8
|
const { logAudit } = require('../audit/log');
|
|
8
9
|
const { exportSpan } = require('../otel/exporter');
|
|
9
10
|
|
|
11
|
+
// Versions before 0.3.0 read policy from this file instead of package.json. It is no
|
|
12
|
+
// longer read at all — see resolvePolicyDefinition()'s legacy-file check below, which
|
|
13
|
+
// exists specifically so an unmigrated project fails loudly instead of silently running
|
|
14
|
+
// with zero enforcement.
|
|
15
|
+
const LEGACY_YAML_FILENAME = 'cap-mcp-guard.yaml';
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Loads the `"cap-mcp-guard"` key from the host CAP project's package.json.
|
|
12
19
|
* A missing package.json or a missing key is a valid "not configured yet"
|
|
13
20
|
* state — falls back to a pass-through PolicyDefinition (every entity is
|
|
14
|
-
* opt-in, so this is equivalent to no policy at all)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
21
|
+
* opt-in, so this is equivalent to no policy at all), UNLESS a legacy
|
|
22
|
+
* `cap-mcp-guard.yaml` is sitting right there (see LEGACY_YAML_FILENAME): that
|
|
23
|
+
* almost certainly means an unmigrated pre-0.3.0 project, and starting up
|
|
24
|
+
* silently unenforced would be worse than refusing to start. A config that
|
|
25
|
+
* exists but fails to parse is a user mistake and is NOT swallowed either —
|
|
26
|
+
* it propagates so the bad config gets noticed rather than silently ignored.
|
|
17
27
|
*
|
|
18
28
|
* @param {object} cds the @sap/cds module (used only for cds.root)
|
|
19
29
|
*/
|
|
20
30
|
function resolvePolicyDefinition(cds) {
|
|
21
|
-
const
|
|
31
|
+
const root = cds.root || process.cwd();
|
|
32
|
+
const configPath = path.join(root, 'package.json');
|
|
22
33
|
|
|
23
34
|
try {
|
|
24
35
|
return loadConfig(configPath);
|
|
25
36
|
} catch (err) {
|
|
26
37
|
if (err.message.startsWith(NOT_CONFIGURED_PREFIX)) {
|
|
38
|
+
const legacyYamlPath = path.join(root, LEGACY_YAML_FILENAME);
|
|
39
|
+
if (fs.existsSync(legacyYamlPath)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[cap-mcp-guard] found "${LEGACY_YAML_FILENAME}" at ${legacyYamlPath}, but this file has not been read ` +
|
|
42
|
+
'since 0.3.0 — config now lives under the "cap-mcp-guard" key in package.json. Move its contents there ' +
|
|
43
|
+
'(or delete the file, if you no longer want a policy) before starting the server. Refusing to start ' +
|
|
44
|
+
'rather than run with zero enforcement silently.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
27
47
|
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
28
48
|
return { mode: 'observe', entities: {} };
|
|
29
49
|
}
|
|
@@ -46,7 +66,10 @@ function resolvePolicyDefinition(cds) {
|
|
|
46
66
|
* `"cap-mcp-guard"` key in the project's package.json (see resolvePolicyDefinition)
|
|
47
67
|
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
48
68
|
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
49
|
-
* logging entirely.
|
|
69
|
+
* logging entirely. When omitted, falls back to the `"audit"` key parsed
|
|
70
|
+
* from package.json's `"cap-mcp-guard"` config (see lib/policy/config.js) —
|
|
71
|
+
* this is what lets `audit.filePath` be set through config alone, without
|
|
72
|
+
* hand-writing a registerCapMcpGuard() call.
|
|
50
73
|
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
51
74
|
* (`{ tracer }`); pass `false` to skip OTel span export entirely. With
|
|
52
75
|
* no host-configured OTel SDK this is already a harmless no-op, so the
|
|
@@ -70,13 +93,16 @@ function resolvePolicyDefinition(cds) {
|
|
|
70
93
|
*/
|
|
71
94
|
function registerCapMcpGuard(cds, options = {}) {
|
|
72
95
|
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
|
|
73
|
-
const { onDecision: userOnDecision,
|
|
96
|
+
const { onDecision: userOnDecision, otel } = options;
|
|
97
|
+
const audit = options.audit !== undefined ? options.audit : policyDefinition.audit;
|
|
74
98
|
|
|
99
|
+
// type: "custom" is a fixed, operator-chosen literal, not a derived value — it needs no
|
|
100
|
+
// secret to reproduce, so a config using only "custom" entries can start without one.
|
|
75
101
|
const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
|
|
76
|
-
const
|
|
77
|
-
(entityConfig
|
|
102
|
+
const needsPseudonymSecret = Object.values(policyDefinition.entities).some((entityConfig) =>
|
|
103
|
+
(entityConfig.pseudonymize || []).some((entry) => entry.type !== 'custom')
|
|
78
104
|
);
|
|
79
|
-
if (
|
|
105
|
+
if (needsPseudonymSecret && !pseudonymSecret) {
|
|
80
106
|
throw new Error(
|
|
81
107
|
'CAP_MCP_GUARD_PSEUDONYM_SECRET env var (or options.pseudonymSecret) is required when "pseudonymize" is configured'
|
|
82
108
|
);
|
package/lib/core/interceptor.js
CHANGED
|
@@ -91,14 +91,75 @@ function applyPseudonymize(results, fieldsToPseudonymize, secret) {
|
|
|
91
91
|
|
|
92
92
|
items.forEach((item) => {
|
|
93
93
|
if (!item || typeof item !== 'object') return;
|
|
94
|
-
for (const { field, type } of fieldsToPseudonymize) {
|
|
94
|
+
for (const { field, type, value: customValue } of fieldsToPseudonymize) {
|
|
95
95
|
if (Object.prototype.hasOwnProperty.call(item, field)) {
|
|
96
|
-
item[field] = generatePseudonym(field, item[field], type, secret);
|
|
96
|
+
item[field] = generatePseudonym(field, item[field], type, secret, customValue);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
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
|
+
|
|
102
163
|
/**
|
|
103
164
|
* Attaches request interception to a CAP service instance, producing a
|
|
104
165
|
* guard context (see context.js) for every request that passes through it.
|
|
@@ -134,9 +195,47 @@ function attachInterceptor(srv, options = {}) {
|
|
|
134
195
|
const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
|
|
135
196
|
const clockDeps = now ? { now } : {};
|
|
136
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
|
+
|
|
137
214
|
srv.before('*', (req) => {
|
|
138
215
|
if (!req) return;
|
|
139
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
|
+
}
|
|
140
239
|
});
|
|
141
240
|
|
|
142
241
|
srv.after('*', (results, req) => {
|
|
@@ -147,20 +246,7 @@ function attachInterceptor(srv, options = {}) {
|
|
|
147
246
|
? undefined
|
|
148
247
|
: Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
149
248
|
|
|
150
|
-
const context =
|
|
151
|
-
{
|
|
152
|
-
...extractAgentInfo(req),
|
|
153
|
-
...extractTraceContext(req),
|
|
154
|
-
entity: resolveEntity(req),
|
|
155
|
-
operation: resolveOperation(req),
|
|
156
|
-
tenant: req.tenant,
|
|
157
|
-
user: req.user && req.user.id,
|
|
158
|
-
session: req.id,
|
|
159
|
-
rowCount: resolveRowCount(results),
|
|
160
|
-
durationMs
|
|
161
|
-
},
|
|
162
|
-
clockDeps
|
|
163
|
-
);
|
|
249
|
+
const context = buildRequestContext(req, { rowCount: resolveRowCount(results), durationMs });
|
|
164
250
|
|
|
165
251
|
if (typeof onContext === 'function') {
|
|
166
252
|
onContext(context, req);
|
|
@@ -177,6 +263,14 @@ function attachInterceptor(srv, options = {}) {
|
|
|
177
263
|
applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
|
|
178
264
|
}
|
|
179
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
|
+
|
|
180
274
|
if (typeof onDecision === 'function') {
|
|
181
275
|
onDecision(decision, context, req);
|
|
182
276
|
}
|
package/lib/policy/config.js
CHANGED
|
@@ -14,13 +14,38 @@ function typeOf(value) {
|
|
|
14
14
|
return typeof value;
|
|
15
15
|
}
|
|
16
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
|
+
|
|
17
42
|
function validatePseudonymizeEntry(entityName, entry) {
|
|
18
43
|
if (typeof entry === 'string') {
|
|
19
44
|
return { field: entry, type: 'opaque' };
|
|
20
45
|
}
|
|
21
46
|
|
|
22
47
|
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
23
|
-
const { field, type = 'opaque' } = entry;
|
|
48
|
+
const { field, type = 'opaque', value } = entry;
|
|
24
49
|
|
|
25
50
|
if (typeof field !== 'string' || !field) {
|
|
26
51
|
throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
|
|
@@ -30,6 +55,15 @@ function validatePseudonymizeEntry(entityName, entry) {
|
|
|
30
55
|
`entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
|
|
31
56
|
);
|
|
32
57
|
}
|
|
58
|
+
if (type === 'custom') {
|
|
59
|
+
if (typeof value !== 'string' || !value) {
|
|
60
|
+
throw new Error(`entities.${entityName}.pseudonymize.${field}: type "custom" requires a non-empty "value" string`);
|
|
61
|
+
}
|
|
62
|
+
return { field, type, value };
|
|
63
|
+
}
|
|
64
|
+
if (value !== undefined) {
|
|
65
|
+
throw new Error(`entities.${entityName}.pseudonymize.${field}: "value" is only allowed with type "custom"`);
|
|
66
|
+
}
|
|
33
67
|
return { field, type };
|
|
34
68
|
}
|
|
35
69
|
|
|
@@ -91,7 +125,7 @@ function validateEntityConfig(name, raw) {
|
|
|
91
125
|
* @param {object} [opts]
|
|
92
126
|
* @param {string} [opts.source] label used in error messages (defaults to
|
|
93
127
|
* the conventional file name; loadConfig() passes the real path instead)
|
|
94
|
-
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
128
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
95
129
|
*/
|
|
96
130
|
function parseConfig(raw, opts = {}) {
|
|
97
131
|
const { source = 'package.json' } = opts;
|
|
@@ -122,7 +156,9 @@ function parseConfig(raw, opts = {}) {
|
|
|
122
156
|
throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
const audit = validateAuditConfig(raw.audit);
|
|
160
|
+
|
|
161
|
+
return { mode: raw.mode, entities, services: raw.services, users: raw.users, audit };
|
|
126
162
|
}
|
|
127
163
|
|
|
128
164
|
/**
|
|
@@ -133,7 +169,7 @@ function parseConfig(raw, opts = {}) {
|
|
|
133
169
|
* with a single startsWith() check and fall back to pass-through mode.
|
|
134
170
|
*
|
|
135
171
|
* @param {string} packageJsonPath
|
|
136
|
-
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
172
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined), audit: (object|undefined) }}
|
|
137
173
|
*/
|
|
138
174
|
function loadConfig(packageJsonPath) {
|
|
139
175
|
let content;
|
package/lib/policy/pseudonym.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
|
-
const PSEUDONYM_TYPES = ['opaque', 'iban'];
|
|
5
|
+
const PSEUDONYM_TYPES = ['opaque', 'iban', 'custom'];
|
|
6
6
|
|
|
7
7
|
const IBAN_SHAPE = /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/;
|
|
8
8
|
|
|
@@ -67,23 +67,29 @@ function generateIbanPseudonym(value, secret) {
|
|
|
67
67
|
|
|
68
68
|
const GENERATORS = {
|
|
69
69
|
opaque: generateOpaquePseudonym,
|
|
70
|
-
iban: (field, value, secret) => generateIbanPseudonym(value, secret)
|
|
70
|
+
iban: (field, value, secret) => generateIbanPseudonym(value, secret),
|
|
71
|
+
// Not actually a generator — every row gets the exact same operator-chosen literal
|
|
72
|
+
// instead of a derived fake value, so there's nothing to compute from value/secret.
|
|
73
|
+
custom: (field, value, secret, customValue) => customValue
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
/**
|
|
74
77
|
* @param {string} field the field name being pseudonymized
|
|
75
78
|
* @param {*} value the real value
|
|
76
79
|
* @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
|
|
80
|
+
* @param {string} [secret] never embedded in the output; without it, the pseudonym can't
|
|
81
|
+
* be reproduced or (for the opaque/iban case) traced back to a specific value. Unused
|
|
82
|
+
* (and not required) for type "custom".
|
|
83
|
+
* @param {string} [customValue] the literal replacement value, required and used only
|
|
84
|
+
* when type is "custom"
|
|
79
85
|
* @returns {string}
|
|
80
86
|
*/
|
|
81
|
-
function generatePseudonym(field, value, type, secret) {
|
|
87
|
+
function generatePseudonym(field, value, type, secret, customValue) {
|
|
82
88
|
const generator = GENERATORS[type];
|
|
83
89
|
if (!generator) {
|
|
84
90
|
throw new Error(`Unknown pseudonym type "${type}"`);
|
|
85
91
|
}
|
|
86
|
-
return generator(field, value, secret);
|
|
92
|
+
return generator(field, value, secret, customValue);
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
module.exports = { generatePseudonym, PSEUDONYM_TYPES };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cap-mcp-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"main": "lib/index.js",
|
|
6
6
|
"author": "Furkan Aksoy <furkanaksy838@gmail.com>",
|