cap-mcp-guard 0.1.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.
- package/README.md +229 -126
- package/lib/adapters/cap.js +99 -79
- package/lib/core/interceptor.js +32 -1
- package/lib/policy/config.js +164 -106
- package/lib/policy/evaluator.js +26 -11
- package/lib/policy/pseudonym.js +89 -0
- package/package.json +56 -53
package/README.md
CHANGED
|
@@ -1,127 +1,230 @@
|
|
|
1
|
-
# cap-mcp-guard
|
|
2
|
-
|
|
3
|
-
CAP
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
│
|
|
33
|
-
│
|
|
34
|
-
│
|
|
35
|
-
│
|
|
36
|
-
│
|
|
37
|
-
│
|
|
38
|
-
│
|
|
39
|
-
│
|
|
40
|
-
│
|
|
41
|
-
│
|
|
42
|
-
│
|
|
43
|
-
│ └─
|
|
44
|
-
│ │
|
|
45
|
-
│ lib/
|
|
46
|
-
│ └─
|
|
47
|
-
│ │
|
|
48
|
-
│ lib/
|
|
49
|
-
│ └─
|
|
50
|
-
│ │
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
1
|
+
# cap-mcp-guard
|
|
2
|
+
|
|
3
|
+
**A CAP (CDS) plugin** — auto-discovered via `cds-plugin.js` the moment it's a dependency of your project, zero manual wiring required.
|
|
4
|
+
|
|
5
|
+
CAP MCP Guard is the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.
|
|
6
|
+
|
|
7
|
+
Today, organizations have only two options when exposing SAP CAP business data to AI agents: grant unrestricted access or deny access completely. CAP MCP Guard introduces a third option — controlled, observable, and policy-driven access, without requiring developers to hand-write authorization, masking, and audit logic for every entity.
|
|
8
|
+
|
|
9
|
+
We give CAP developers a standardized, reusable way to enforce AI-agent security, observability, and policy — without hand-writing that logic for every entity. Convention over implementation, in the same spirit as CAP itself.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
- **Field exposure.** When AI agents connect to CAP entities over MCP, sensitive fields (`CreditCardNo`, `Salary`, national ID numbers, ...) are visible to the agent unless someone filters them by hand, entity by entity.
|
|
14
|
+
- **No visibility.** Which agent accessed which entity, when, how many rows, and how long it took isn't logged anywhere standard. There's no answer when someone asks for an audit trail.
|
|
15
|
+
- **Excessive trust.** Even a "read-only" agent can technically reach every action/function a service exposes through the MCP layer, unless something enforces otherwise. Relying on the agent to "behave" isn't a control.
|
|
16
|
+
- **Enterprise distrust.** Companies want AI agents connected to business data, but security teams block it because nobody can prove what the agent will actually do. CAP MCP Guard is the third option between "wide open" and "no access at all."
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
AI Request → Intercept → Evaluate Policy → Mask → Execute → Audit → Trace
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Claude / Joule / Copilot / Custom Agent
|
|
26
|
+
│
|
|
27
|
+
Any MCP Runtime
|
|
28
|
+
(gavdilabs/cap-mcp-plugin, a custom runtime, a future official SAP
|
|
29
|
+
solution — it doesn't matter which)
|
|
30
|
+
│
|
|
31
|
+
┌─────────────────────────┐
|
|
32
|
+
│ cap-mcp-guard │
|
|
33
|
+
│ │
|
|
34
|
+
│ lib/core/ │
|
|
35
|
+
│ ├─ interceptor.js │ → attaches to CAP's srv.before/srv.after hooks
|
|
36
|
+
│ └─ context.js │ → builds an OTel gen_ai.*-shaped, framework-
|
|
37
|
+
│ │ agnostic request context
|
|
38
|
+
│ │
|
|
39
|
+
│ lib/policy/ │ → knows nothing about CAP. Plain JS: Context in,
|
|
40
|
+
│ ├─ config.js │ Decision out.
|
|
41
|
+
│ ├─ evaluator.js │
|
|
42
|
+
│ ├─ masking.js │
|
|
43
|
+
│ └─ pseudonym.js │
|
|
44
|
+
│ │
|
|
45
|
+
│ lib/audit/ │
|
|
46
|
+
│ └─ log.js │ → Context + Decision → structured JSON log line
|
|
47
|
+
│ │
|
|
48
|
+
│ lib/otel/ │
|
|
49
|
+
│ └─ exporter.js │ → Context + Decision → a real OTel span
|
|
50
|
+
│ │
|
|
51
|
+
│ lib/adapters/ │
|
|
52
|
+
│ └─ cap.js │ → the ONE place that knows @sap/cds.
|
|
53
|
+
│ │ cds-plugin.js calls this.
|
|
54
|
+
└─────────────────────────┘
|
|
55
|
+
│
|
|
56
|
+
CAP Service
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`lib/policy/` and `lib/core/context.js` never import `@sap/cds` — they only ever see a plain `Context` object and a plain `PolicyDefinition` object, regardless of where either one came from. That's what lets the `"cap-mcp-guard"` package.json config be swapped out for a future CDS-annotation-based source later without touching the engine itself.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install --save cap-mcp-guard
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This is a **CDS plugin**, not a library you wire up by hand: CAP auto-discovers `cds-plugin.js` the moment the package is a dependency of your project — no manual `require`, no server bootstrap changes. Add a `"cap-mcp-guard"` key to your project's `package.json` and it's picked up automatically the next time your CAP server starts.
|
|
68
|
+
|
|
69
|
+
## Configure
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"cap-mcp-guard": {
|
|
74
|
+
"mode": "enforce",
|
|
75
|
+
"entities": {
|
|
76
|
+
"Orders": {
|
|
77
|
+
"mask": ["CreditCard", "Salary"],
|
|
78
|
+
"maxRows": 100,
|
|
79
|
+
"allowTools": ["ReadOrders"]
|
|
80
|
+
},
|
|
81
|
+
"Customers": {
|
|
82
|
+
"mask": ["Email", "Phone"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- Entities not listed here are fully accessible — this is opt-in by design; you don't have to configure every entity up front.
|
|
90
|
+
- No `"cap-mcp-guard"` key at all? The guard runs in pass-through mode (a `console.warn` tells you so) rather than crashing your server.
|
|
91
|
+
- A config that exists but fails to parse *does* fail loudly — a broken config shouldn't fail silently.
|
|
92
|
+
|
|
93
|
+
### Scoping the guard to specific services
|
|
94
|
+
|
|
95
|
+
If the same entities are served both to a human-facing UI and to an AI/MCP agent, masking
|
|
96
|
+
everything is usually wrong — the UI would see masked fields too. Add a `"services"` array
|
|
97
|
+
to scope the guard to only the CAP services named there; every other served service is left
|
|
98
|
+
completely untouched (no masking, no audit, no interceptor at all):
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"cap-mcp-guard": {
|
|
103
|
+
"mode": "enforce",
|
|
104
|
+
"services": ["AgentCatalogService"],
|
|
105
|
+
"entities": {
|
|
106
|
+
"Customers": { "mask": ["IBAN"] }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The recommended pattern: expose the AI/MCP-facing traffic through its own CDS service (a
|
|
113
|
+
projection over the same entities your UI's service already serves), point `"services"` at
|
|
114
|
+
that one, and leave your UI's service out of the list entirely — it keeps seeing real,
|
|
115
|
+
unmasked data. Omitting `"services"` keeps the default: every served service is guarded, as
|
|
116
|
+
before.
|
|
117
|
+
|
|
118
|
+
### Scoping the guard to specific users
|
|
119
|
+
|
|
120
|
+
If splitting into a second CDS service isn't worth it, scope the guard by **identity**
|
|
121
|
+
instead, using CAP's own authenticated user (`req.user.id`, not a client-supplied header —
|
|
122
|
+
so it can't be spoofed the way a raw HTTP header could). Add a `"users"` array: masking (and
|
|
123
|
+
`allowTools`/`maxRows` enforcement) only applies to requests whose authenticated user is in
|
|
124
|
+
that list — everyone else's request is fully passed through, exactly as if the entity had no
|
|
125
|
+
policy at all:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"cap-mcp-guard": {
|
|
130
|
+
"mode": "enforce",
|
|
131
|
+
"users": ["mcp-agent-technical-user"],
|
|
132
|
+
"entities": {
|
|
133
|
+
"Customers": { "mask": ["IBAN"] }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Authenticate your MCP runtime as that technical user (via XSUAA/IAS, a service key bound to
|
|
140
|
+
the CAP app) so its requests carry that identity; your UI's human users authenticate
|
|
141
|
+
normally and are never in the list, so they always see real data through the same service and
|
|
142
|
+
the same endpoint. This is only as secure as your CAP app's auth strategy — it requires a
|
|
143
|
+
real, verified identity provider (XSUAA/IAS/JWT) in production. `mocked` auth (fine for local
|
|
144
|
+
dev, as `examples/bookshop` uses) lets `req.user.id` be set by an untrusted client-supplied
|
|
145
|
+
header, which defeats this entirely.
|
|
146
|
+
|
|
147
|
+
`"services"` and `"users"` compose — set both if you want a dedicated AI-facing service *and*
|
|
148
|
+
identity verification within it.
|
|
149
|
+
|
|
150
|
+
### Pseudonymizing instead of redacting
|
|
151
|
+
|
|
152
|
+
Plain `mask` replaces every real value with the same fixed string, `'***MASKED***'` —
|
|
153
|
+
which means an AI agent can no longer tell two different customers' masked fields apart at
|
|
154
|
+
all (no grouping, no counting distinct values, no relational reasoning). `pseudonymize`
|
|
155
|
+
replaces a real value with a **fake but deterministic** one instead: the same real value
|
|
156
|
+
always produces the same fake value, and different real values produce different fake
|
|
157
|
+
values — so the agent keeps that relational structure without ever seeing the real data.
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"cap-mcp-guard": {
|
|
162
|
+
"mode": "enforce",
|
|
163
|
+
"entities": {
|
|
164
|
+
"Customers": {
|
|
165
|
+
"mask": ["CreditCard"],
|
|
166
|
+
"pseudonymize": [
|
|
167
|
+
"Email",
|
|
168
|
+
{ "field": "IBAN", "type": "iban" }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Each entry is either a bare field name (uses the generic `"opaque"` generator — a
|
|
177
|
+
deterministic token like `Email-7f3a9c21e1b4`, carrying no information about the real
|
|
178
|
+
value) or a `{ "field", "type" }` object naming a specific generator. The only built-in
|
|
179
|
+
typed generator today is `"iban"`: it keeps the real country code and total length, and
|
|
180
|
+
computes a real ISO 7064 MOD 97-10 check digit pair, so the fake IBAN passes standard IBAN
|
|
181
|
+
checksum validation — it isn't a real account, but nothing downstream sees it as malformed.
|
|
182
|
+
A field can't be listed in both `mask` and `pseudonymize` on the same entity (config fails
|
|
183
|
+
to load if it is). More typed generators (e.g. a Luhn-valid fake credit card number) can be
|
|
184
|
+
added later without changing this config shape — anything without a dedicated generator
|
|
185
|
+
just falls back to `"opaque"`.
|
|
186
|
+
|
|
187
|
+
**Requires a secret.** Set the `CAP_MCP_GUARD_PSEUDONYM_SECRET` environment variable (or
|
|
188
|
+
pass `pseudonymSecret` directly to `registerCapMcpGuard`) — every pseudonym is derived from
|
|
189
|
+
it via HMAC, so without it a fake value can't be reproduced or tied back to a real one.
|
|
190
|
+
**Never commit this value.** If any entity configures `pseudonymize` and no secret is set,
|
|
191
|
+
the server fails to start rather than silently producing unprotected data. Rotating the
|
|
192
|
+
secret invalidates every previously-issued pseudonym (the same real value will map to a new
|
|
193
|
+
fake one from then on) — this is expected, not a bug.
|
|
194
|
+
|
|
195
|
+
## What you get, per request
|
|
196
|
+
|
|
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.
|
|
198
|
+
- **Audit log** — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
|
|
199
|
+
- **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
|
+
|
|
201
|
+
All three run independently and can each be disabled per-call (`audit: false`, `otel: false`) if you're wiring `registerCapMcpGuard` yourself instead of relying on auto-discovery.
|
|
202
|
+
|
|
203
|
+
## Try it
|
|
204
|
+
|
|
205
|
+
A full working example lives in [`examples/bookshop`](examples/bookshop) — SAP's own CAP getting-started sample, with `cap-mcp-guard` wired in and a `"cap-mcp-guard"` package.json config masking real fields on `CatalogService.Books`.
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
cd examples/bookshop
|
|
209
|
+
npm install
|
|
210
|
+
npm test # runs enforce/observe/audit/OTel integration tests against a real CAP service
|
|
211
|
+
npm start # boots a real server at localhost:4004 — flip package.json's "cap-mcp-guard".mode to "enforce"
|
|
212
|
+
# and hit /odata/v4/browse/Books to see masking happen live
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Coming soon (not in v1)
|
|
216
|
+
|
|
217
|
+
- `@mcp.policy`-style CDS annotations as an alternative to the `"cap-mcp-guard"` package.json config
|
|
218
|
+
- Approval workflows (human-in-the-loop for sensitive operations)
|
|
219
|
+
- 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
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npm test # unit tests for lib/core, lib/policy, lib/audit, lib/otel, lib/adapters
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
127
230
|
MIT
|
package/lib/adapters/cap.js
CHANGED
|
@@ -1,80 +1,100 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const { attachInterceptor } = require('../core/interceptor');
|
|
6
|
-
const { loadConfig } = require('../policy/config');
|
|
7
|
-
const { logAudit } = require('../audit/log');
|
|
8
|
-
const { exportSpan } = require('../otel/exporter');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* cds
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* @param {object} [options] forwarded to
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* @param {object|false} [options.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { attachInterceptor } = require('../core/interceptor');
|
|
6
|
+
const { loadConfig, NOT_CONFIGURED_PREFIX } = require('../policy/config');
|
|
7
|
+
const { logAudit } = require('../audit/log');
|
|
8
|
+
const { exportSpan } = require('../otel/exporter');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loads the `"cap-mcp-guard"` key from the host CAP project's package.json.
|
|
12
|
+
* A missing package.json or a missing key is a valid "not configured yet"
|
|
13
|
+
* state — falls back to a pass-through PolicyDefinition (every entity is
|
|
14
|
+
* opt-in, so this is equivalent to no policy at all). A config that exists
|
|
15
|
+
* but fails to parse is a user mistake and is NOT swallowed — it propagates
|
|
16
|
+
* so the bad config gets noticed rather than silently ignored.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} cds the @sap/cds module (used only for cds.root)
|
|
19
|
+
*/
|
|
20
|
+
function resolvePolicyDefinition(cds) {
|
|
21
|
+
const configPath = path.join(cds.root || process.cwd(), 'package.json');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return loadConfig(configPath);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.message.startsWith(NOT_CONFIGURED_PREFIX)) {
|
|
27
|
+
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
28
|
+
return { mode: 'observe', entities: {} };
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The single CAP-specific connection point in this package. Everything
|
|
36
|
+
* under lib/core and lib/policy is framework-agnostic; this file is where
|
|
37
|
+
* @sap/cds concepts (served services, cds.ApplicationService, cds.root)
|
|
38
|
+
* are known.
|
|
39
|
+
*
|
|
40
|
+
* cds-plugin.js is the only caller of this function in production; tests
|
|
41
|
+
* call it directly against a real or fake CAP service.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} cds the @sap/cds module (or a compatible facade)
|
|
44
|
+
* @param {object} [options] forwarded to attachInterceptor (see interceptor.js)
|
|
45
|
+
* @param {object} [options.policyDefinition] when omitted, loaded from the
|
|
46
|
+
* `"cap-mcp-guard"` key in the project's package.json (see resolvePolicyDefinition)
|
|
47
|
+
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
48
|
+
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
49
|
+
* logging entirely.
|
|
50
|
+
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
51
|
+
* (`{ tracer }`); pass `false` to skip OTel span export entirely. With
|
|
52
|
+
* no host-configured OTel SDK this is already a harmless no-op, so the
|
|
53
|
+
* default is to always attempt it.
|
|
54
|
+
* @param {(decision: object, context: object, req: object) => void} [options.onDecision]
|
|
55
|
+
* called in addition to the built-in audit log and OTel export, not
|
|
56
|
+
* instead of them — all that apply run for every request.
|
|
57
|
+
*
|
|
58
|
+
* `policyDefinition.services` (see lib/policy/config.js), when set, scopes the guard to
|
|
59
|
+
* only the named served services — any other served ApplicationService is left completely
|
|
60
|
+
* untouched (no interceptor attached at all, not even for observation/audit). This is how
|
|
61
|
+
* you keep a human-facing UI service unmasked while guarding a separate AI/MCP-facing
|
|
62
|
+
* service that projects the same entities. Omitted (the default) attaches to every served
|
|
63
|
+
* ApplicationService, as before.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} [options.pseudonymSecret] used to derive pseudonyms (see
|
|
66
|
+
* lib/policy/pseudonym.js) for any entity configuring `pseudonymize`; when omitted,
|
|
67
|
+
* falls back to the `CAP_MCP_GUARD_PSEUDONYM_SECRET` env var. If any entity uses
|
|
68
|
+
* `pseudonymize` and neither is set, this throws immediately (at server boot, before
|
|
69
|
+
* any request can be served) rather than silently producing unprotected data.
|
|
70
|
+
*/
|
|
71
|
+
function registerCapMcpGuard(cds, options = {}) {
|
|
72
|
+
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(cds);
|
|
73
|
+
const { onDecision: userOnDecision, audit, otel } = options;
|
|
74
|
+
|
|
75
|
+
const pseudonymSecret = options.pseudonymSecret || process.env.CAP_MCP_GUARD_PSEUDONYM_SECRET;
|
|
76
|
+
const usesPseudonymize = Object.values(policyDefinition.entities).some(
|
|
77
|
+
(entityConfig) => entityConfig.pseudonymize && entityConfig.pseudonymize.length > 0
|
|
78
|
+
);
|
|
79
|
+
if (usesPseudonymize && !pseudonymSecret) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'CAP_MCP_GUARD_PSEUDONYM_SECRET env var (or options.pseudonymSecret) is required when "pseudonymize" is configured'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onDecision = (decision, context, req) => {
|
|
86
|
+
if (audit !== false) logAudit(context, decision, audit);
|
|
87
|
+
if (otel !== false) exportSpan(context, decision, otel);
|
|
88
|
+
if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
cds.on('served', () => {
|
|
92
|
+
for (const [name, srv] of Object.entries(cds.services)) {
|
|
93
|
+
if (!(srv instanceof cds.ApplicationService)) continue;
|
|
94
|
+
if (Array.isArray(policyDefinition.services) && !policyDefinition.services.includes(name)) continue;
|
|
95
|
+
attachInterceptor(srv, { ...options, policyDefinition, onDecision, pseudonymSecret });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
module.exports = { registerCapMcpGuard };
|
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,30 @@ 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
|
+
|
|
77
102
|
/**
|
|
78
103
|
* Attaches request interception to a CAP service instance, producing a
|
|
79
104
|
* guard context (see context.js) for every request that passes through it.
|
|
@@ -98,13 +123,15 @@ function applyMask(results, fieldsToMask) {
|
|
|
98
123
|
* each request completes, regardless of mode — this is the extension
|
|
99
124
|
* point M5 (audit log) uses
|
|
100
125
|
* @param {() => string} [options.now] injectable clock, forwarded to buildContext
|
|
126
|
+
* @param {string} [options.pseudonymSecret] required whenever any entity configures
|
|
127
|
+
* `pseudonymize` — resolved and validated one layer up in lib/adapters/cap.js
|
|
101
128
|
*/
|
|
102
129
|
function attachInterceptor(srv, options = {}) {
|
|
103
130
|
if (!srv || typeof srv.before !== 'function' || typeof srv.after !== 'function') {
|
|
104
131
|
throw new Error('attachInterceptor requires a CAP service exposing before()/after() hooks');
|
|
105
132
|
}
|
|
106
133
|
|
|
107
|
-
const { onContext, policyDefinition, onDecision, now } = options;
|
|
134
|
+
const { onContext, policyDefinition, onDecision, now, pseudonymSecret } = options;
|
|
108
135
|
const clockDeps = now ? { now } : {};
|
|
109
136
|
|
|
110
137
|
srv.before('*', (req) => {
|
|
@@ -146,6 +173,10 @@ function attachInterceptor(srv, options = {}) {
|
|
|
146
173
|
applyMask(results, decision.fieldsToMask);
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
if (decision.mode === 'enforce' && decision.fieldsToPseudonymize.length > 0) {
|
|
177
|
+
applyPseudonymize(results, decision.fieldsToPseudonymize, pseudonymSecret);
|
|
178
|
+
}
|
|
179
|
+
|
|
149
180
|
if (typeof onDecision === 'function') {
|
|
150
181
|
onDecision(decision, context, req);
|
|
151
182
|
}
|
package/lib/policy/config.js
CHANGED
|
@@ -1,106 +1,164 @@
|
|
|
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
|
-
if (typeof
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* @
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
throw
|
|
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
|
+
function validatePseudonymizeEntry(entityName, entry) {
|
|
18
|
+
if (typeof entry === 'string') {
|
|
19
|
+
return { field: entry, type: 'opaque' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
23
|
+
const { field, type = 'opaque' } = entry;
|
|
24
|
+
|
|
25
|
+
if (typeof field !== 'string' || !field) {
|
|
26
|
+
throw new Error(`entities.${entityName}.pseudonymize entries must have a "field" string`);
|
|
27
|
+
}
|
|
28
|
+
if (!PSEUDONYM_TYPES.includes(type)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`entities.${entityName}.pseudonymize.${field}.type must be one of ${PSEUDONYM_TYPES.join(', ')} (got ${JSON.stringify(type)})`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { field, type };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(
|
|
37
|
+
`entities.${entityName}.pseudonymize entries must be a string or a {field, type} object, got ${typeOf(entry)}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateEntityConfig(name, raw) {
|
|
42
|
+
const config = raw ?? {};
|
|
43
|
+
|
|
44
|
+
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
45
|
+
throw new Error(`entities.${name} must be a mapping, got ${typeOf(config)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { mask, maxRows, allowTools, pseudonymize: rawPseudonymize } = config;
|
|
49
|
+
|
|
50
|
+
if (mask !== undefined && !Array.isArray(mask)) {
|
|
51
|
+
throw new Error(`entities.${name}.mask must be an array, got ${typeOf(mask)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (allowTools !== undefined && !Array.isArray(allowTools)) {
|
|
55
|
+
throw new Error(`entities.${name}.allowTools must be an array, got ${typeOf(allowTools)}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (maxRows !== undefined) {
|
|
59
|
+
if (typeof maxRows !== 'number' || Number.isNaN(maxRows)) {
|
|
60
|
+
throw new Error(`entities.${name}.maxRows must be a number, got ${typeOf(maxRows)}`);
|
|
61
|
+
}
|
|
62
|
+
if (maxRows < 0) {
|
|
63
|
+
throw new Error(`entities.${name}.maxRows must not be negative (got ${maxRows})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let pseudonymize;
|
|
68
|
+
if (rawPseudonymize !== undefined) {
|
|
69
|
+
if (!Array.isArray(rawPseudonymize)) {
|
|
70
|
+
throw new Error(`entities.${name}.pseudonymize must be an array, got ${typeOf(rawPseudonymize)}`);
|
|
71
|
+
}
|
|
72
|
+
pseudonymize = rawPseudonymize.map((entry) => validatePseudonymizeEntry(name, entry));
|
|
73
|
+
|
|
74
|
+
const maskedFields = new Set(mask ?? []);
|
|
75
|
+
for (const { field } of pseudonymize) {
|
|
76
|
+
if (maskedFields.has(field)) {
|
|
77
|
+
throw new Error(`entities.${name}: "${field}" cannot be listed in both "mask" and "pseudonymize"`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { mask, maxRows, allowTools, pseudonymize };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validates an already-parsed `"cap-mcp-guard"` config object (the value of
|
|
87
|
+
* that key in package.json) into a PolicyDefinition — the source-agnostic
|
|
88
|
+
* shape the policy engine consumes. Pure: no filesystem access.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} raw the parsed `"cap-mcp-guard"` value
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {string} [opts.source] label used in error messages (defaults to
|
|
93
|
+
* the conventional file name; loadConfig() passes the real path instead)
|
|
94
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
95
|
+
*/
|
|
96
|
+
function parseConfig(raw, opts = {}) {
|
|
97
|
+
const { source = 'package.json' } = opts;
|
|
98
|
+
|
|
99
|
+
if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
100
|
+
throw new Error(`${source}: "${CONFIG_KEY}" must be a mapping with a "mode" key`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!VALID_MODES.includes(raw.mode)) {
|
|
104
|
+
throw new Error(`"mode" must be one of ${VALID_MODES.join(', ')} (got ${JSON.stringify(raw.mode)})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rawEntities = raw.entities ?? {};
|
|
108
|
+
if (typeof rawEntities !== 'object' || Array.isArray(rawEntities)) {
|
|
109
|
+
throw new Error(`"entities" must be a mapping of entity name to config, got ${typeOf(rawEntities)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entities = {};
|
|
113
|
+
for (const [name, entityRaw] of Object.entries(rawEntities)) {
|
|
114
|
+
entities[name] = validateEntityConfig(name, entityRaw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (raw.services !== undefined && !Array.isArray(raw.services)) {
|
|
118
|
+
throw new Error(`"services" must be an array, got ${typeOf(raw.services)}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (raw.users !== undefined && !Array.isArray(raw.users)) {
|
|
122
|
+
throw new Error(`"users" must be an array, got ${typeOf(raw.users)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { mode: raw.mode, entities, services: raw.services, users: raw.users };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads the host project's package.json and validates its `"cap-mcp-guard"`
|
|
130
|
+
* key via parseConfig(). Both "no package.json at this path" and "package.json
|
|
131
|
+
* exists but has no cap-mcp-guard key" are reported under the same
|
|
132
|
+
* NOT_CONFIGURED_PREFIX, so callers can treat either as "not configured yet"
|
|
133
|
+
* with a single startsWith() check and fall back to pass-through mode.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} packageJsonPath
|
|
136
|
+
* @returns {{ mode: 'enforce'|'observe', entities: object, services: (string[]|undefined), users: (string[]|undefined) }}
|
|
137
|
+
*/
|
|
138
|
+
function loadConfig(packageJsonPath) {
|
|
139
|
+
let content;
|
|
140
|
+
try {
|
|
141
|
+
content = fs.readFileSync(packageJsonPath, 'utf8');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err.code === 'ENOENT') {
|
|
144
|
+
throw new Error(`${NOT_CONFIGURED_PREFIX}: no package.json found at ${packageJsonPath}`);
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let pkg;
|
|
150
|
+
try {
|
|
151
|
+
pkg = JSON.parse(content);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new Error(`Failed to parse ${packageJsonPath}: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const raw = pkg[CONFIG_KEY];
|
|
157
|
+
if (raw === undefined) {
|
|
158
|
+
throw new Error(`${NOT_CONFIGURED_PREFIX}: "${CONFIG_KEY}" key not found in ${packageJsonPath}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return parseConfig(raw, { source: packageJsonPath });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
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 };
|
package/package.json
CHANGED
|
@@ -1,53 +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
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "cap-mcp-guard",
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
+
}
|