diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml deleted file mode 100644 index ec1324a..0000000 --- a/.github/workflows/conformance.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Conformance - -on: [push, pull_request] - -# ๐Ÿ” Restrict GITHUB_TOKEN permissions (least privilege) -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: { node-version: '20' } - - run: npm ci - - run: npm run test - - uses: actions/upload-artifact@v4 - with: - name: conformance-json - path: docs/conformance.json diff --git a/CLAUDE.md b/CLAUDE.md index 4f65c01..73bbb2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,10 @@ packages// - supertest for Express middleware integration tests ### current status -Tests are not yet implemented across packages. Priority order for adding tests: -1. proxyabl-core (security-critical: SSRF, auth modes, proxy execution) -2. transformabl-core (correctness-critical: PII regex patterns) -3. validatabl-core (security-critical: policy enforcement) -4. identifiabl-core (security-critical: JWT verification) -5. limitabl-core (correctness-critical: rate limiting, budget tracking) -6. request-context, explicabl, middleware wrappers +Every `-core` package and the middleware wrappers have vitest suites under +`packages//__tests__/` (note: `__tests__/`, not `tests/`). `npm test` at +the repo root runs the whole suite; `npm run build` must pass first (the tests +import built workspace packages). Security-critical coverage is real: +proxyabl-core SSRF, transformabl-core PII regex + redaction, validatabl-core +policy enforcement, identifiabl-core JWT verification, limitabl-core +rate/budget/agent-guard. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 842f6ae..173be9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ Run the full test suite: npm test ``` -This runs Vitest plus the conformance report writer. See `docs/testing.md` for: +This runs the Vitest suite. See `docs/testing.md` for: - `/__test__/echo` routes - Scope/RBAC parity checks - Proxy + echo server validation diff --git a/README.md b/README.md index d4b42b4..3a45cd8 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,6 @@ TypeScript Cloud Run - - MCP Auth Conformance -

See, price, and control every tool call your AI agents make.

@@ -44,6 +38,16 @@ Hermes Agent uses a native Python plugin instead ([why](https://agenticcontrolpl pip install hermes-acp && hermes plugins enable acp ``` +**Prefer to see the guarantees work first โ€” zero config, no IdP, no backend?** + +```bash +git clone https://github.com/agentic-control-plane/GatewayStack +cd GatewayStack/examples/quickstart && npm install && npm start +``` + +Prints three real decisions โ€” a deny-by-default policy, PII redaction, and a +rate limit โ€” as pure local library code ([`examples/quickstart`](examples/quickstart)). + Your own framework code โ€” drop in a package: ```bash @@ -88,9 +92,11 @@ Each layer ships as a framework-agnostic `-core` package plus an Express middlew | `@gatewaystack/transformabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/transformabl)](https://www.npmjs.com/package/@gatewaystack/transformabl) | PII detection, redaction, safety classification | | `@gatewaystack/validatabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/validatabl)](https://www.npmjs.com/package/@gatewaystack/validatabl) | Deny-by-default policy engine, scope/permission enforcement | | `@gatewaystack/limitabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/limitabl)](https://www.npmjs.com/package/@gatewaystack/limitabl) | Rate limits, budget tracking, agent guard | -| `@gatewaystack/proxyabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/proxyabl)](https://www.npmjs.com/package/@gatewaystack/proxyabl) | Auth mode routing, SSRF protection, identity-aware proxy | +| `@gatewaystack/proxyabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/proxyabl)](https://www.npmjs.com/package/@gatewaystack/proxyabl) | Auth-mode routing, identity-aware proxy to a configured backend (path-allowlisted). SSRF-safe fetch for arbitrary URLs is a `proxyabl-core` primitive โ€” see note below. | | `@gatewaystack/explicabl` | [![npm](https://img.shields.io/npm/v/@gatewaystack/explicabl)](https://www.npmjs.com/package/@gatewaystack/explicabl) | Structured audit logging, health endpoints | +> **SSRF note (proxyabl).** The `proxyabl` Express router forwards to an operator-**configured** backend, with tool-name sanitization, path allowlisting, and host/protocol pinning to that backend โ€” the target host is not caller-controlled. The DNS-resolving, private-IP-blocking SSRF engine (`assertUrlSafe` / `executeProxyRequest`) lives in `@gatewaystack/proxyabl-core` and is the primitive to use when you proxy **arbitrary, caller-influenced URLs**; the bundled router does not currently route through it (its threat model is different โ€” a configured backend may legitimately be internal). See [`docs/packages.md`](docs/packages.md#proxyabl). + ## Full stack example Wire all six layers together. Each is optional โ€” use only what you need. @@ -175,7 +181,7 @@ AI apps have three actors โ€” user, LLM, backend โ€” and no shared identity laye | `demos/` | MCP issuer + ChatGPT Apps SDK connectors that mint demo JWTs | | `tools/` | Echo server, mock tool backend, Cloud Run deploy helper | | `tests/` | Vitest smoke tests | -| `docs/` | Auth0 walkthroughs, conformance output, endpoint references, troubleshooting | +| `docs/` | Auth0 walkthroughs, endpoint references, troubleshooting | ## Testing diff --git a/docs/conformance.json b/docs/conformance.json deleted file mode 100644 index 963d627..0000000 --- a/docs/conformance.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "0.1.0", - "spec": "Apps SDK OAuth 2.1 + MCP Authorization (subset)", - "categories": { - "pkce": "pass", - "jwt_verify": "pass", - "scope_enforcement": "pass", - "allowlist": "pass", - "expiry": "pass" - }, - "timestamp": "2026-02-07T07:46:57.508Z" -} \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index aaac788..edb6691 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -71,11 +71,11 @@ docker run -p 8080:8080 \ ### CI/CD -`.github/workflows/conformance.yml` runs `npm test` and updates `docs/conformance.json` on every push to main. +`.github/workflows/build.yml` builds the monorepo and runs `npm test` on every push and pull request to main. **To add deployment:** ```yaml -# Add to conformance.yml after tests pass +# Add to build.yml after tests pass - name: Deploy to Cloud Run if: github.ref == 'refs/heads/main' run: ./tools/deploy/cloud-run.sh apps/gateway-server diff --git a/docs/operations.md b/docs/operations.md index 81d3363..57d7b9a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -107,7 +107,7 @@ Run the full test suite: npm test ``` -This runs Vitest plus the conformance report writer that updates `docs/conformance.json`. +This runs the Vitest suite across the gateway and core packages. For detailed testing workflows, see: - `docs/testing.md` โ€” `/__test__/echo` routes, scope checks, proxy validation @@ -126,7 +126,7 @@ For detailed testing workflows, see: Toggles worth noting: - `DEMO_MODE=true` swaps in `OAUTH_*_DEMO` overrides so demos can mint JWTs locally. -- `ENABLE_TEST_ROUTES=true` + `TOOL_SCOPE_ALLOWLIST_JSON` expose `/__test__/echo` for conformance runs. +- `ENABLE_TEST_ROUTES=true` + `TOOL_SCOPE_ALLOWLIST_JSON` expose `/__test__/echo` for scope/proxy validation runs. - `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX` tune limitabl without editing TypeScript. - `.env.example` plus `apps/gateway-server/.env.example` enumerate every knob. diff --git a/docs/packages.md b/docs/packages.md index c636227..3621f5e 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -30,7 +30,13 @@ GatewayStack ships as composable npm packages. Each governance layer has a `-cor `@gatewaystack/proxyabl-core` / `@gatewaystack/proxyabl` -**Execution Control & Identity-Aware Routing.** Five auth modes (API key, forward bearer, service OAuth, user OAuth, none), SSRF protection (host allowlist, private IP blocking), HTTP proxy with timeout/redirect/size controls, multi-provider registry. Express middleware serves PRM/OIDC metadata, enforces scope-to-tool mappings, and injects verified identity into downstream headers. +**Execution Control & Identity-Aware Routing.** Five auth modes (API key, forward bearer, service OAuth, user OAuth, none) and a multi-provider registry. + +**`proxyabl-core` โ€” SSRF-safe fetch primitive.** `assertUrlSafe` / `executeProxyRequest` provide host-allowlisting, DNS-resolving private-IP blocking (IPv4 + IPv6, incl. IPv4-mapped IPv6), protocol enforcement, redirect blocking (`redirect: "manual"`), and timeout/response-size caps. Use these directly when you proxy **arbitrary, caller-influenced URLs** โ€” that is the case SSRF protection is for. + +**`proxyabl` โ€” Express gateway router.** Forwards to an operator-**configured** backend (`functionsBase` / proxy target). The caller controls the tool name / path, not the host: the router sanitizes the tool name, enforces a path allowlist, and pins the resolved URL's host + protocol to the configured backend. It is a pass-through proxy and **does not currently route through `proxyabl-core`'s `assertUrlSafe` engine** โ€” the two have different threat models (a configured backend may legitimately be an internal address, which the private-IP-blocking engine would reject). The Express middleware also serves PRM/OIDC metadata, enforces scope-to-tool mappings, and injects verified identity into downstream headers. + +> Converging these โ€” one canonical `proxyabl-core` engine consumed by both the OSS router and the production gateway (which forked an equivalent) โ€” is tracked in the proxyabl convergence work. Until then, treat `assertUrlSafe` / `executeProxyRequest` as an available primitive, not something the bundled router runs for you. ## explicabl diff --git a/docs/testing.md b/docs/testing.md index d526506..65edcca 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,7 +3,7 @@ This document covers: - How to enable internal `/__test__` routes - Scope/RBAC parity checks -- Vitest + conformance report +- Running the Vitest suite --- @@ -21,7 +21,7 @@ These routes are guarded by: - `TOOL_SCOPE_ALLOWLIST_JSON` (allowed scopes per test route) - The `X-Required-Scope` header (per-request scope requirement you pass in curl) -They are for validation and conformance testing only, not for production traffic. +They are for validation and scope/proxy testing only, not for production traffic. --- @@ -95,12 +95,10 @@ npm test This runs: - Vitest against the gateway and core packages (see `vitest.config.mts`) -- The conformance report writer, which emits a summary of MCP/Auth OAuth behavior into `docs/conformance.json` **Key files:** - `package.json` โ†’ test script - `vitest.config.mts` โ†’ shared test config - `tests/smoke.test.ts` โ†’ placeholder smoke test -- `packages/explicabl-core/src/reporting/saveReport.ts` โ†’ writes the conformance report artifact --- \ No newline at end of file diff --git a/docs/testing/conformance.md b/docs/testing/conformance.md deleted file mode 100644 index e69de29..0000000 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d9d30f0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +# Examples + +Runnable GatewayStack examples. + +| Example | What it shows | Setup | +|---------|---------------|-------| +| [`quickstart/`](./quickstart) | Deny-by-default policy, PII redaction, and rate limiting โ€” three real decisions printed to your console | **None.** `npm install && npm start` | + +The quickstart uses only the framework-agnostic `-core` packages, so it runs +anywhere Node runs, with zero external services. Wiring the same layers as +Express middleware (which does need an IdP for the identity layer) is covered in +the [root README](../README.md#full-stack-example). diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md new file mode 100644 index 0000000..0c43835 --- /dev/null +++ b/examples/quickstart/README.md @@ -0,0 +1,32 @@ +# Quickstart โ€” governance in 30 seconds + +Three real GatewayStack decisions running on your machine. **No IdP, no backend, +no config.** + +```bash +npm install +npm start +``` + +You'll see: + +``` +1 ยท Deny-by-default policy (validatabl) + โœ… ALLOW read_file โ†’ Matched rule: allow-reads (allow) + ๐Ÿ›‘ DENY delete_database โ†’ No rules matched; default: deny + +2 ยท PII redaction (transformabl) + in : Email me at john@acme.com or call about SSN 123-45-6789. + out: Email me at [EMAIL] or call about SSN [SSN]. + detected: email, ssn + +3 ยท Rate limit + budget guard (limitabl) + call 1: โœ… ALLOW + ... + call 4: ๐Ÿ›‘ DENY โ†’ Rate limited. Retry after 60s +``` + +Everything here is pure `-core` library code โ€” the same logic the Express +middleware wraps. See [`index.ts`](./index.ts); it's ~50 lines. To wire these as +HTTP middleware in front of your own tools/models, see the +[full-stack example](../../README.md#full-stack-example). diff --git a/examples/quickstart/index.ts b/examples/quickstart/index.ts new file mode 100644 index 0000000..8568c57 --- /dev/null +++ b/examples/quickstart/index.ts @@ -0,0 +1,69 @@ +/** + * GatewayStack โ€” 30-second quickstart. + * + * Three real governance decisions, running entirely on your machine. No IdP, no + * backend, no config. Run it: + * + * npm install && npm start + * + * Everything here is pure library code from the `-core` packages โ€” the same + * logic the Express middleware wraps. If you can see these decisions happen, + * you can drop them into your own gateway. + */ + +import { applyPolicies, type PolicySet } from "@gatewaystack/validatabl-core"; +import { transformContent } from "@gatewaystack/transformabl-core"; +import { LimitablEngine } from "@gatewaystack/limitabl-core"; + +const ALLOW = "\x1b[32mโœ… ALLOW\x1b[0m"; +const DENY = "\x1b[31m๐Ÿ›‘ DENY \x1b[0m"; +const h = (s: string) => console.log(`\n\x1b[1m${s}\x1b[0m`); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 1. Deny-by-default policy โ€” a tool call is refused unless a rule allows it. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +h("1 ยท Deny-by-default policy (validatabl)"); + +const policy: PolicySet = { + defaultEffect: "deny", // nothing is allowed unless a rule says so + rules: [ + { + id: "allow-reads", + effect: "allow", + conditions: [{ field: "tool", operator: "in", value: ["read_file", "list_files"] }], + }, + ], +}; + +for (const tool of ["read_file", "delete_database"]) { + const d = applyPolicies(policy, { identity: { sub: "agent-1" }, tool }); + console.log(` ${d.allowed ? ALLOW : DENY} ${tool.padEnd(16)} โ†’ ${d.reason}`); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 2. PII redaction โ€” sensitive values are stripped before they leave. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +h("2 ยท PII redaction (transformabl)"); + +const raw = "Email me at john@acme.com or call about SSN 123-45-6789."; +const result = transformContent(raw, { redaction: { mode: "placeholder" } }); + +console.log(` in : ${raw}`); +console.log(` out: ${result.content}`); +console.log(` detected: ${result.piiMatches.map((m) => m.type).join(", ") || "none"}`); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 3. Rate limit โ€” the 4th call in the window is refused. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +h("3 ยท Rate limit + budget guard (limitabl)"); + +const engine = new LimitablEngine({ rateLimit: { windowMs: 60_000, maxRequests: 3 } }); +const key = { sub: "agent-1" }; + +for (let i = 1; i <= 5; i++) { + const r = engine.preflight(key); + console.log(` call ${i}: ${r.allowed ? ALLOW : DENY} ${r.allowed ? "" : "โ†’ " + r.reason}`); +} + +h("Next โ†’ wire these as Express middleware: see ../../README.md#full-stack-example"); +console.log(); diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json new file mode 100644 index 0000000..70abf75 --- /dev/null +++ b/examples/quickstart/package.json @@ -0,0 +1,17 @@ +{ + "name": "gatewaystack-quickstart", + "private": true, + "type": "module", + "description": "30-second, zero-config GatewayStack governance demo", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@gatewaystack/validatabl-core": "^0.1.0", + "@gatewaystack/transformabl-core": "^0.4.0", + "@gatewaystack/limitabl-core": "^0.1.0" + }, + "devDependencies": { + "tsx": "^4.19.2" + } +} diff --git a/package-lock.json b/package-lock.json index 0bf0456..d11ca39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6601,7 +6601,7 @@ }, "packages/explicabl": { "name": "@gatewaystack/explicabl", - "version": "0.0.7", + "version": "0.0.9", "license": "MIT", "dependencies": { "express": "^4.22.0", @@ -6611,11 +6611,17 @@ "@types/express": "^4.17.21", "tsx": "^4.19.2", "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "packages/explicabl-core": { "name": "@gatewaystack/observability-core", - "version": "0.0.1" + "version": "0.0.1", + "engines": { + "node": ">=18" + } }, "packages/identifiabl": { "name": "@gatewaystack/identifiabl", @@ -6623,8 +6629,10 @@ "license": "MIT", "dependencies": { "@gatewaystack/identifiabl-core": "^0.1.0", - "@gatewaystack/request-context": "^0.0.6", - "jose": "^5.0.0" + "@gatewaystack/request-context": "^0.0.6" + }, + "engines": { + "node": ">=18" }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" @@ -6636,6 +6644,9 @@ "license": "MIT", "dependencies": { "jose": "^6.1.0" + }, + "engines": { + "node": ">=18" } }, "packages/identifiabl-core/node_modules/jose": { @@ -6656,6 +6667,9 @@ "@types/express": "^4.17.21", "typescript": "^5.6.3" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" } @@ -6666,30 +6680,62 @@ "license": "MIT", "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "packages/proxyabl": { "name": "@gatewaystack/proxyabl", - "version": "0.0.12", + "version": "0.0.13", "license": "MIT", "dependencies": { - "@gatewaystack/proxyabl-core": "^0.1.0", - "@gatewaystack/request-context": "0.0.6", + "@gatewaystack/proxyabl-core": "^0.1.1", + "@gatewaystack/request-context": "^0.0.6", "express": "^4.22.0", "express-rate-limit": "^8.2.2" }, "devDependencies": { "@types/express": "^4.17.21", + "jose": "^6.1.0", "tsx": "^4.19.2", "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "packages/proxyabl-core": { "name": "@gatewaystack/proxyabl-core", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", + "dependencies": { + "jose": "^6.1.0" + }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "packages/proxyabl-core/node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "packages/proxyabl/node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, "packages/request-context": { @@ -6698,28 +6744,37 @@ "license": "MIT", "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "packages/transformabl": { "name": "@gatewaystack/transformabl", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "@gatewaystack/transformabl-core": "^0.3.0" + "@gatewaystack/transformabl-core": "^0.4.1" }, "devDependencies": { "typescript": "^5.6.3" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" } }, "packages/transformabl-core": { "name": "@gatewaystack/transformabl-core", - "version": "0.3.0", + "version": "0.4.1", "license": "MIT", "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "packages/validatabl": { @@ -6733,6 +6788,9 @@ "@types/express": "^4.17.21", "typescript": "^5.6.3" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" } @@ -6743,6 +6801,9 @@ "license": "MIT", "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } }, "tools/echo-server": { diff --git a/package.json b/package.json index c7779d1..c3cfc46 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,8 @@ "build:admin": "npm --workspace apps/admin-ui run build", "start:server": "npm --workspace apps/gateway-server run start", "start:admin": "npm --workspace apps/admin-ui run preview", - "test": "vitest run --config ./vitest.config.mts --reporter=verbose && npm run conformance:report", + "test": "vitest run --config ./vitest.config.mts --reporter=verbose", "test:watch": "vitest --config ./vitest.config.mts", - "conformance:report": "tsx packages/explicabl-core/src/reporting/saveReport.ts", "demo:mcp": "npm-run-all -p demo:issuer demo:gateway demo:mcp-server", "demo:issuer": "npm run -w @gatewaystack/demo-mcp-server dev", "demo:mcp-server": "npm run -w @gatewaystack/demo-mcp-server dev", diff --git a/packages/explicabl-core/LICENSE b/packages/explicabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/explicabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/explicabl-core/package.json b/packages/explicabl-core/package.json index da1df19..8cdfed5 100644 --- a/packages/explicabl-core/package.json +++ b/packages/explicabl-core/package.json @@ -2,5 +2,8 @@ "name": "@gatewaystack/observability-core", "version": "0.0.1", "main": "dist/index.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "engines": { + "node": ">=18" + } } diff --git a/packages/explicabl-core/src/reporting/saveReport.js b/packages/explicabl-core/src/reporting/saveReport.js deleted file mode 100644 index 3e90f4a..0000000 --- a/packages/explicabl-core/src/reporting/saveReport.js +++ /dev/null @@ -1,16 +0,0 @@ -import fs from "node:fs"; -const out = { - version: "0.1.0", - spec: "Apps SDK OAuth 2.1 + MCP Authorization (subset)", - categories: { - pkce: "pass", - jwt_verify: "pass", - scope_enforcement: "pass", - allowlist: "pass", - expiry: "pass" - }, - timestamp: new Date().toISOString() -}; -fs.mkdirSync("docs", { recursive: true }); -fs.writeFileSync("docs/conformance.json", JSON.stringify(out, null, 2)); -console.log("[conformance] wrote docs/conformance.json"); diff --git a/packages/explicabl-core/src/reporting/saveReport.ts b/packages/explicabl-core/src/reporting/saveReport.ts deleted file mode 100644 index 7161784..0000000 --- a/packages/explicabl-core/src/reporting/saveReport.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as fs from "node:fs"; -const out = { - version: "0.1.0", - spec: "Apps SDK OAuth 2.1 + MCP Authorization (subset)", - categories: { - pkce: "pass", - jwt_verify: "pass", - scope_enforcement: "pass", - allowlist: "pass", - expiry: "pass" - }, - timestamp: new Date().toISOString() -}; -fs.mkdirSync("docs", { recursive: true }); -fs.writeFileSync("docs/conformance.json", JSON.stringify(out, null, 2)); -console.log("[conformance] wrote docs/conformance.json"); diff --git a/packages/explicabl/LICENSE b/packages/explicabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/explicabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/explicabl/package.json b/packages/explicabl/package.json index 4a360ce..2fbd85d 100644 --- a/packages/explicabl/package.json +++ b/packages/explicabl/package.json @@ -1,6 +1,6 @@ { "name": "@gatewaystack/explicabl", - "version": "0.0.8", + "version": "0.0.9", "private": false, "license": "MIT", "type": "module", @@ -18,6 +18,9 @@ "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" }, + "engines": { + "node": ">=18" + }, "dependencies": { "express": "^4.22.0", "express-rate-limit": "^8.2.2" @@ -26,5 +29,8 @@ "@types/express": "^4.17.21", "typescript": "^5.6.3", "tsx": "^4.19.2" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/explicabl/src/health.ts b/packages/explicabl/src/health.ts index e4a4654..91aee1d 100644 --- a/packages/explicabl/src/health.ts +++ b/packages/explicabl/src/health.ts @@ -1,5 +1,4 @@ import { Router, type Request, type RequestHandler } from "express"; -import fetch from "node-fetch"; import rateLimit from "express-rate-limit"; import { timingSafeEqual } from "node:crypto"; diff --git a/packages/explicabl/tsconfig.json b/packages/explicabl/tsconfig.json index f5bc8df..8cb43ee 100644 --- a/packages/explicabl/tsconfig.json +++ b/packages/explicabl/tsconfig.json @@ -13,7 +13,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"] } diff --git a/packages/identifiabl-core/LICENSE b/packages/identifiabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/identifiabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/identifiabl-core/package.json b/packages/identifiabl-core/package.json index 75588f5..7e9acc9 100644 --- a/packages/identifiabl-core/package.json +++ b/packages/identifiabl-core/package.json @@ -13,7 +13,9 @@ "directory": "packages/identifiabl-core" }, "files": [ - "dist" + "dist", + "LICENSE", + "README.md" ], "scripts": { "build": "tsc -p tsconfig.json", @@ -21,5 +23,8 @@ }, "dependencies": { "jose": "^6.1.0" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/identifiabl-core/tsconfig.json b/packages/identifiabl-core/tsconfig.json index 5c317ea..e4c820a 100644 --- a/packages/identifiabl-core/tsconfig.json +++ b/packages/identifiabl-core/tsconfig.json @@ -4,7 +4,7 @@ "composite": true, "rootDir": "src", "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"], "references": [ diff --git a/packages/identifiabl/LICENSE b/packages/identifiabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/identifiabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/identifiabl/README.md b/packages/identifiabl/README.md index 6b401c3..80ef011 100644 --- a/packages/identifiabl/README.md +++ b/packages/identifiabl/README.md @@ -30,12 +30,12 @@ npm install @gatewaystack/identifiabl ```typescript import express from 'express'; -import { createIdentifiablMiddleware } from '@gatewaystack/identifiabl'; +import { identifiabl } from '@gatewaystack/identifiabl'; const app = express(); // Add identity verification to all routes -app.use(createIdentifiablMiddleware({ +app.use(identifiabl({ issuer: 'https://your-tenant.auth0.com/', audience: 'https://gateway.local/api', jwksUri: 'https://your-tenant.auth0.com/.well-known/jwks.json' @@ -59,7 +59,7 @@ app.get('/api/data', (req, res) => { app.get('/health', (req, res) => res.json({ ok: true })); // Protected routes (auth required) -app.use('/protected', createIdentifiablMiddleware({ +app.use('/protected', identifiabl({ issuer: process.env.OAUTH_ISSUER, audience: process.env.OAUTH_AUDIENCE, jwksUri: process.env.OAUTH_JWKS_URI @@ -400,7 +400,7 @@ identifiabl is optimized for production: ### Custom Claims Extraction ```typescript -app.use(createIdentifiablMiddleware({ +app.use(identifiabl({ issuer: process.env.OAUTH_ISSUER, audience: process.env.OAUTH_AUDIENCE, jwksUri: process.env.OAUTH_JWKS_URI, @@ -418,7 +418,7 @@ app.use(createIdentifiablMiddleware({ ```typescript // Enforce tenant ID in token -app.use(createIdentifiablMiddleware({ +app.use(identifiabl({ issuer: process.env.OAUTH_ISSUER, audience: process.env.OAUTH_AUDIENCE, jwksUri: process.env.OAUTH_JWKS_URI, diff --git a/packages/identifiabl/package.json b/packages/identifiabl/package.json index a3a33f6..2a8b24c 100644 --- a/packages/identifiabl/package.json +++ b/packages/identifiabl/package.json @@ -15,7 +15,6 @@ "LICENSE" ], "dependencies": { - "jose": "^5.0.0", "@gatewaystack/identifiabl-core": "^0.1.0", "@gatewaystack/request-context": "^0.0.6" }, @@ -25,5 +24,8 @@ "scripts": { "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/limitabl-core/LICENSE b/packages/limitabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/limitabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/limitabl-core/package.json b/packages/limitabl-core/package.json index 83b7fff..c60c68d 100644 --- a/packages/limitabl-core/package.json +++ b/packages/limitabl-core/package.json @@ -20,5 +20,8 @@ }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/limitabl-core/tsconfig.json b/packages/limitabl-core/tsconfig.json index f3f2f18..6566a49 100644 --- a/packages/limitabl-core/tsconfig.json +++ b/packages/limitabl-core/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"] } diff --git a/packages/limitabl/LICENSE b/packages/limitabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/limitabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/limitabl/package.json b/packages/limitabl/package.json index 7ce7aaa..0bc2f8d 100644 --- a/packages/limitabl/package.json +++ b/packages/limitabl/package.json @@ -27,5 +27,8 @@ "devDependencies": { "@types/express": "^4.17.21", "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/limitabl/tsconfig.json b/packages/limitabl/tsconfig.json index 7208f0f..0678654 100644 --- a/packages/limitabl/tsconfig.json +++ b/packages/limitabl/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"], "references": [ diff --git a/packages/proxyabl-core/LICENSE b/packages/proxyabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/proxyabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/proxyabl-core/README.md b/packages/proxyabl-core/README.md index d3e567f..d078b43 100644 --- a/packages/proxyabl-core/README.md +++ b/packages/proxyabl-core/README.md @@ -98,6 +98,8 @@ resolveAuth(config: AuthModeConfig, context: AuthContext): ResolvedAuth ### SSRF Protection +> **Scope.** `assertUrlSafe` / `executeProxyRequest` are the primitive for proxying **arbitrary, caller-influenced URLs** โ€” call them directly from your handler. The Express router in [`@gatewaystack/proxyabl`](https://www.npmjs.com/package/@gatewaystack/proxyabl) is a different thing: it forwards to an operator-**configured** backend and does not route through this engine (a configured backend may legitimately be internal, which private-IP blocking would reject). Importing the router does not give you this SSRF check automatically. + ```ts assertUrlSafe(opts: UrlSafetyOptions): Promise ``` diff --git a/packages/proxyabl-core/package.json b/packages/proxyabl-core/package.json index 5cb5be1..12b37d5 100644 --- a/packages/proxyabl-core/package.json +++ b/packages/proxyabl-core/package.json @@ -1,6 +1,6 @@ { "name": "@gatewaystack/proxyabl-core", - "version": "0.1.0", + "version": "0.1.1", "private": false, "license": "MIT", "type": "module", @@ -18,7 +18,13 @@ "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" }, + "dependencies": { + "jose": "^6.1.0" + }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/proxyabl-core/tsconfig.json b/packages/proxyabl-core/tsconfig.json index f3f2f18..6566a49 100644 --- a/packages/proxyabl-core/tsconfig.json +++ b/packages/proxyabl-core/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"] } diff --git a/packages/proxyabl/LICENSE b/packages/proxyabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/proxyabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/proxyabl/package.json b/packages/proxyabl/package.json index 4235dd8..302e7a2 100644 --- a/packages/proxyabl/package.json +++ b/packages/proxyabl/package.json @@ -1,6 +1,6 @@ { "name": "@gatewaystack/proxyabl", - "version": "0.0.12", + "version": "0.0.13", "private": false, "license": "MIT", "type": "module", @@ -21,12 +21,16 @@ "dependencies": { "express": "^4.22.0", "express-rate-limit": "^8.2.2", - "@gatewaystack/proxyabl-core": "^0.1.0", - "@gatewaystack/request-context": "0.0.6" + "@gatewaystack/proxyabl-core": "^0.1.1", + "@gatewaystack/request-context": "^0.0.6" }, "devDependencies": { "@types/express": "^4.17.21", + "jose": "^6.1.0", "typescript": "^5.6.3", "tsx": "^4.19.2" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/proxyabl/tsconfig.json b/packages/proxyabl/tsconfig.json index ae64fc4..962c896 100644 --- a/packages/proxyabl/tsconfig.json +++ b/packages/proxyabl/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"], "references": [ diff --git a/packages/request-context/LICENSE b/packages/request-context/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/request-context/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/request-context/package.json b/packages/request-context/package.json index ac22aea..3e9b840 100644 --- a/packages/request-context/package.json +++ b/packages/request-context/package.json @@ -15,10 +15,14 @@ "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build" }, "dependencies": {}, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } -} \ No newline at end of file +} diff --git a/packages/transformabl-core/LICENSE b/packages/transformabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/transformabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/transformabl-core/__tests__/detect.test.ts b/packages/transformabl-core/__tests__/detect.test.ts index 7986fff..f770b74 100644 --- a/packages/transformabl-core/__tests__/detect.test.ts +++ b/packages/transformabl-core/__tests__/detect.test.ts @@ -145,6 +145,162 @@ describe("detectPii", () => { }); }); + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Presidio-parity recognizers (v0.4.0) + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + describe("us_bank_number", () => { + it("detects 10-digit bank account", () => { + const matches = detectPii("account 9845302145 for deposit"); + expect(matches.some((m) => m.type === "us_bank_number")).toBe(true); + }); + + it("detects 17-digit routing/account combos", () => { + const matches = detectPii("wire to 12345678901234567"); + expect(matches.some((m) => m.type === "us_bank_number")).toBe(true); + }); + + it("does NOT match 7-digit values (too short)", () => { + const matches = detectPii("code 1234567"); + expect(matches.some((m) => m.type === "us_bank_number")).toBe(false); + }); + }); + + describe("us_itin", () => { + it("detects ITIN format 9XX-7X-XXXX", () => { + const matches = detectPii("ITIN: 912-78-5551"); + expect(matches.some((m) => m.type === "us_itin")).toBe(true); + }); + + it("detects ITIN format 9XX-8X-XXXX", () => { + const matches = detectPii("taxpayer 988-83-0099"); + expect(matches.some((m) => m.type === "us_itin")).toBe(true); + }); + + it("regular SSN 123-45-6789 does not match ITIN pattern", () => { + const matches = detectPii("ssn 123-45-6789"); + expect(matches.some((m) => m.type === "us_itin")).toBe(false); + }); + }); + + describe("us_passport", () => { + it("detects 9-digit passport", () => { + const matches = detectPii("passport 123456789 expires"); + expect(matches.some((m) => m.type === "us_passport")).toBe(true); + }); + + it("detects 1-letter + 8-digit format", () => { + const matches = detectPii("passport A12345678"); + expect(matches.some((m) => m.type === "us_passport")).toBe(true); + }); + }); + + describe("us_drivers_license", () => { + it("detects 1-letter + 7-digit DL", () => { + const matches = detectPii("DL D1234567 state CA"); + expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true); + }); + + it("detects DL: prefix form", () => { + const matches = detectPii("license DL:12345678"); + expect(matches.some((m) => m.type === "us_drivers_license")).toBe(true); + }); + }); + + describe("iban", () => { + it("detects German IBAN", () => { + const matches = detectPii("wire to DE89370400440532013000"); + expect(matches.some((m) => m.type === "iban")).toBe(true); + }); + + it("detects UK IBAN", () => { + const matches = detectPii("acct GB82WEST12345698765432"); + expect(matches.some((m) => m.type === "iban")).toBe(true); + }); + + it("does NOT match arbitrary long alphanum", () => { + const matches = detectPii("token ABCDEFGHIJKLMNOP12345"); + expect(matches.some((m) => m.type === "iban")).toBe(false); + }); + }); + + describe("phone (international)", () => { + it("detects +44 UK number", () => { + const matches = detectPii("call +44 20 7946 0958"); + expect(matches.some((m) => m.type === "phone")).toBe(true); + }); + + it("detects +81 Japan number", () => { + const matches = detectPii("support +81-3-1234-5678"); + expect(matches.some((m) => m.type === "phone")).toBe(true); + }); + }); + + describe("ip_address (IPv6)", () => { + it("detects full-form IPv6", () => { + const matches = detectPii("addr 2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + expect(matches.some((m) => m.type === "ip_address")).toBe(true); + }); + + it("detects compressed IPv6", () => { + const matches = detectPii("addr 2001:db8::8a2e:370:7334"); + expect(matches.some((m) => m.type === "ip_address")).toBe(true); + }); + }); + + describe("icd_10", () => { + it("detects F32.9 (major depressive disorder)", () => { + const matches = detectPii("diagnosis code F32.9 confirmed"); + expect(matches.some((m) => m.type === "icd_10")).toBe(true); + }); + + it("detects E11.65 (type 2 diabetes)", () => { + const matches = detectPii("pt has E11.65 on chart"); + expect(matches.some((m) => m.type === "icd_10")).toBe(true); + }); + + it("detects code without decimals", () => { + const matches = detectPii("dx: Z00 routine"); + expect(matches.some((m) => m.type === "icd_10")).toBe(true); + }); + }); + + describe("icd_9", () => { + it("detects numeric ICD-9 with decimal", () => { + const matches = detectPii("legacy code 250.01 on record"); + expect(matches.some((m) => m.type === "icd_9")).toBe(true); + }); + + it("detects V-code", () => { + const matches = detectPii("status V70.0"); + expect(matches.some((m) => m.type === "icd_9")).toBe(true); + }); + }); + + describe("npi", () => { + it("detects 10-digit NPI starting with 1", () => { + const matches = detectPii("provider NPI 1234567893 treating"); + expect(matches.some((m) => m.type === "npi")).toBe(true); + }); + }); + + describe("crypto_wallet", () => { + it("detects BTC legacy address", () => { + const matches = detectPii("send to 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"); + expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true); + }); + + it("detects BTC bech32 address", () => { + const matches = detectPii("payout bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); + expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true); + }); + + it("detects ETH address", () => { + const matches = detectPii("eth 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"); + expect(matches.some((m) => m.type === "crypto_wallet")).toBe(true); + }); + }); + describe("zero-width character bypass", () => { it("detects email with zero-width space inside", () => { const text = "contact john\u200B@example.com for info"; diff --git a/packages/transformabl-core/__tests__/redact.overlap.test.ts b/packages/transformabl-core/__tests__/redact.overlap.test.ts new file mode 100644 index 0000000..119a307 --- /dev/null +++ b/packages/transformabl-core/__tests__/redact.overlap.test.ts @@ -0,0 +1,87 @@ +// Overlapping-match redaction (H4 / #40). +// +// Detection routinely emits overlapping spans โ€” e.g. `us_bank_number` +// (`\d{8,17}`) covers the same digit run as `npi` / `us_drivers_license`. The +// old descending-start substitution let an inner match's replacement shift the +// text so an outer match's stale `end` sliced the wrong point, leaving trailing +// PII digits behind in placeholder/remove modes. These lock in the +// merge-into-maximal-intervals fix. + +import { describe, it, expect } from "vitest"; +import { redactPii } from "../src/redact.js"; +import type { PiiMatch } from "../src/types.js"; + +describe("redactPii โ€” overlapping matches merge into maximal intervals", () => { + // A partial overlap that, under the old code, left the digits "23" behind: + // the inner npi span (higher start) was substituted first, shifting the text + // so the outer us_bank_number span's stale `end` sliced past its real end. + const text = "id 1234567890123 end MORE"; + const overlapping: PiiMatch[] = [ + { type: "us_bank_number", value: "1234567890123", start: 3, end: 16 }, // outer + { type: "npi", value: "234", start: 4, end: 7 }, // inner, higher start + ]; + + it("placeholder mode: the outer span is replaced once and no digits survive", () => { + const result = redactPii(text, overlapping, { mode: "placeholder" }); + expect(result).toBe("id [US_BANK_NUMBER] end MORE"); + expect(result).not.toMatch(/\d/); + }); + + it("remove mode: the outer span is removed cleanly and no digits survive", () => { + const result = redactPii(text, overlapping, { mode: "remove" }); + expect(result).toBe("id end MORE"); + expect(result).not.toMatch(/\d/); + }); + + it("acceptance (#40): output contains no substring of any original match value", () => { + for (const mode of ["placeholder", "remove"] as const) { + const result = redactPii(text, overlapping, { mode }); + for (const m of overlapping) { + expect(result).not.toContain(m.value); + } + expect(result).not.toMatch(/\d/); + } + }); + + it("labels the merged span by its widest contributing type", () => { + const result = redactPii(text, overlapping, { mode: "placeholder" }); + expect(result).toContain("[US_BANK_NUMBER]"); + expect(result).not.toContain("[NPI]"); + }); + + it("identical spans (same start and end) collapse to one replacement", () => { + // us_bank_number and us_drivers_license both match an 8-digit run. + const t = "acct 12345678 ok"; + const same: PiiMatch[] = [ + { type: "us_bank_number", value: "12345678", start: 5, end: 13 }, + { type: "us_drivers_license", value: "12345678", start: 5, end: 13 }, + ]; + const result = redactPii(t, same, { mode: "placeholder" }); + expect(result).toBe("acct [US_BANK_NUMBER] ok"); + expect(result).not.toMatch(/\d/); + }); + + it("respects `types`: only the requested type's span is redacted", () => { + // Only redact npi. Its [4,7] span ("234") is replaced; the surrounding + // bank digits are the caller's explicit choice to keep, and the merge must + // not sweep the excluded us_bank_number span in. + const result = redactPii(text, overlapping, { mode: "placeholder", types: ["npi"] }); + expect(result).toBe("id 1[NPI]567890123 end MORE"); + }); + + it("mask mode masks the whole merged span (keeps only edge chars)", () => { + const result = redactPii(text, overlapping, { mode: "mask" }); + // Full 13-char span masked keeping 2 at each edge: "12" + 9*'*' + "23". + expect(result).toBe("id 12*********23 end MORE"); + }); + + it("non-overlapping matches are unaffected by the merge", () => { + const t = "mail x@y.com then 123-45-6789 z"; + const matches: PiiMatch[] = [ + { type: "email", value: "x@y.com", start: 5, end: 12 }, + { type: "ssn", value: "123-45-6789", start: 18, end: 29 }, + ]; + const result = redactPii(t, matches, { mode: "placeholder" }); + expect(result).toBe("mail [EMAIL] then [SSN] z"); + }); +}); diff --git a/packages/transformabl-core/__tests__/scanTruncation.test.ts b/packages/transformabl-core/__tests__/scanTruncation.test.ts new file mode 100644 index 0000000..307297c --- /dev/null +++ b/packages/transformabl-core/__tests__/scanTruncation.test.ts @@ -0,0 +1,60 @@ +// PII-scan truncation loudness (M2 / #40). +// +// detectPii caps scanning at MAX_PII_SCAN_LENGTH (512 KB). Before this fix the +// cap was silent, so PII past it flowed through unredacted AND unlogged โ€” +// a fail-open that wasn't loud. detectPiiDetailed / TransformResult now carry a +// `scanTruncated` flag so a consumer can tell "scanned clean" from "not fully +// scanned." + +import { describe, it, expect } from "vitest"; +import { + detectPii, + detectPiiDetailed, + MAX_PII_SCAN_LENGTH, +} from "../src/detect.js"; +import { transformContent } from "../src/transform.js"; + +describe("scan-cap truncation is reported (not silent)", () => { + it("does not flag truncation for normal-sized input", () => { + const r = detectPiiDetailed("contact john@example.com please"); + expect(r.scanTruncated).toBe(false); + expect(r.totalLength).toBe("contact john@example.com please".length); + expect(r.scannedLength).toBe(r.totalLength); + expect(r.matches.some((m) => m.type === "email")).toBe(true); + }); + + it("flags truncation and does NOT scan PII past the cap", () => { + const filler = "a".repeat(MAX_PII_SCAN_LENGTH + 100); + const input = `${filler} leak@example.com`; + const r = detectPiiDetailed(input); + + expect(r.scanTruncated).toBe(true); + expect(r.scannedLength).toBe(MAX_PII_SCAN_LENGTH); + expect(r.totalLength).toBe(input.length); + // The email is beyond the cap, so it is (knowingly) not found โ€” the flag + // is what makes that honest rather than a silent miss. + expect(r.matches.some((m) => m.type === "email")).toBe(false); + }); + + it("still finds PII that falls before the cap in an over-cap input", () => { + const input = `early@example.com ${"a".repeat(MAX_PII_SCAN_LENGTH)} late@example.com`; + const r = detectPiiDetailed(input); + expect(r.scanTruncated).toBe(true); + const emails = r.matches.filter((m) => m.type === "email"); + expect(emails).toHaveLength(1); + expect(emails[0].value).toBe("early@example.com"); + }); + + it("transformContent surfaces scanTruncated on the result", () => { + const small = transformContent("john@example.com"); + expect(small.scanTruncated).toBe(false); + + const big = transformContent("a".repeat(MAX_PII_SCAN_LENGTH + 1)); + expect(big.scanTruncated).toBe(true); + }); + + it("detectPii (array wrapper) stays behaviourally identical to detectPiiDetailed().matches", () => { + const input = "ssn 123-45-6789 and mail a@b.co"; + expect(detectPii(input)).toEqual(detectPiiDetailed(input).matches); + }); +}); diff --git a/packages/transformabl-core/package.json b/packages/transformabl-core/package.json index 5cae6eb..e1c995c 100644 --- a/packages/transformabl-core/package.json +++ b/packages/transformabl-core/package.json @@ -1,6 +1,6 @@ { "name": "@gatewaystack/transformabl-core", - "version": "0.3.0", + "version": "0.4.1", "private": false, "license": "MIT", "type": "module", @@ -20,5 +20,8 @@ }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/transformabl-core/src/classify.ts b/packages/transformabl-core/src/classify.ts index 89f2780..db3f858 100644 --- a/packages/transformabl-core/src/classify.ts +++ b/packages/transformabl-core/src/classify.ts @@ -58,6 +58,15 @@ const PII_TO_REGULATORY: Record = { phone: ["gdpr"], date_of_birth: ["gdpr", "coppa", "hipaa"], ip_address: ["gdpr"], + us_bank_number: ["pci", "gdpr"], + us_itin: ["pci", "gdpr"], + us_passport: ["gdpr"], + us_drivers_license: ["gdpr"], + iban: ["pci", "gdpr"], + icd_10: ["hipaa"], + icd_9: ["hipaa"], + npi: ["hipaa"], + crypto_wallet: ["gdpr"], }; /** diff --git a/packages/transformabl-core/src/detect.ts b/packages/transformabl-core/src/detect.ts index 31885a5..992aad7 100644 --- a/packages/transformabl-core/src/detect.ts +++ b/packages/transformabl-core/src/detect.ts @@ -1,13 +1,14 @@ // packages/transformabl-core/src/detect.ts // -// Regex-based PII detection. +// Regex-based PII detection. Recognizer set tracks Microsoft Presidio's +// default recognizer list (US-focused) plus healthcare-specific additions +// that are standard under HIPAA but not covered by Presidio's core list. // // FUTURE WORK: // - ML-based NER detection (plug in spaCy, Presidio, or cloud NER APIs) // - Address detection (street addresses, zip codes) // - Name detection (requires NER - too many false positives with regex) -// - International phone number formats -// - Passport numbers, driver's license numbers +// - Country-specific identifiers (UK NHS, AU ABN, IN PAN, etc.) import type { PiiType, PiiMatch } from "./types.js"; import { stripInvisible } from "./normalize.js"; @@ -24,45 +25,158 @@ interface PiiPattern { const BUILTIN_PATTERNS: PiiPattern[] = [ { type: "email", - pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // Quantifiers are bounded to RFC 5321 limits (local โ‰ค64, domain โ‰ค255, + // TLD โ‰ค24). The previous unbounded `+โ€ฆ+` form around a single mandatory + // `@` was quadratic (O(nยฒ)) โ€” a long alphanumeric run with no `@` made + // the engine scan-to-end-then-backtrack from every start position, so a + // ~1 MB string of `a`s could block the event loop for minutes (ReDoS). + // Bounding the work per start position makes it effectively linear. + pattern: /[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,255}\.[a-zA-Z]{2,24}/g, }, { type: "phone", - // US phone numbers (10+ digits): (xxx) xxx-xxxx, xxx-xxx-xxxx, +1xxxxxxxxxx - // Requires area code (3 digits) + 7-digit number to avoid matching short numbers - pattern: /(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b/g, + // US 10-digit formats AND international E.164 (+<7-14 digits>). + pattern: + /(?:\+1[-.\s]?)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}\b|\+(?!1[-.\s]?\d)\d{1,3}[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}\b/g, }, { type: "ssn", // US Social Security Numbers: xxx-xx-xxxx pattern: /\b\d{3}-\d{2}-\d{4}\b/g, }, + { + type: "us_itin", + // US Individual Taxpayer Identification Number: 9XX-(7X|8X)-XXXX where + // the middle group starts with 7, 8, or 9. Must be checked BEFORE the + // generic SSN pattern since ITIN overlaps structurally with SSN โ€” ITIN + // is more specific, so order matters when dedup'ing overlapping hits. + pattern: /\b9\d{2}-[78]\d-\d{4}\b/g, + }, { type: "credit_card", // Major card formats: Visa (13-19), MC (16), Amex (15), Discover (16), Diners (14) // Matches 13-19 digit sequences with optional separators (space or dash) pattern: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2})|3(?:0[0-5]|[68]\d)\d)[-\s]?\d{4,6}[-\s]?\d{4,5}(?:[-\s]?\d{1,4})?\b/g, }, + { + type: "us_bank_number", + // US bank account numbers: 8-17 digits. Loose by design โ€” banks don't + // publish a format. Tightened with word boundaries + minimum length to + // avoid matching short IDs. Will false-positive on other long digit + // strings; acceptable for a governance layer that prefers over- + // redaction on financial context. + pattern: /\b\d{8,17}\b/g, + }, + { + type: "us_passport", + // US passport: 9 digits (older) or 1 letter + 8 digits (newer). + pattern: /\b(?:[A-Z]\d{8}|\d{9})\b/g, + }, + { + type: "us_drivers_license", + // US driver's license formats are state-specific. Approximation: + // 1 letter + 7-8 digits, or 7-9 all-digit sequences with "DL:" prefix. + // False-positive rate is non-trivial; callers can disable this type. + pattern: /\b(?:DL[:\s]?)?(?:[A-Z]\d{7,8}|\d{7,9})\b/g, + }, + { + type: "iban", + // ISO 13616 IBAN: 2 letters (country) + 2 check digits + 11-30 alphanum. + // Optional spaces every 4 chars are stripped by normalize.ts before scan. + pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g, + }, { type: "ip_address", - // IPv4 addresses - pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g, + // IPv4 OR IPv6 (full + compressed forms). Three alternatives: + // (1) IPv4 dotted-quad + // (2) IPv6 full form โ€” 8 hex groups separated by `:` + // (3) IPv6 compressed โ€” contains `::`, with hex groups on either side + pattern: + /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b|\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|(? ): PiiMatch[] { + return detectPiiDetailed(text, customPatterns).matches; +} + +/** + * Detect PII and report scan-cap truncation. + * Returns matches plus a `scanTruncated` flag so a truncated scan is never a + * silent fail-open. + */ +export function detectPiiDetailed( + text: string, + customPatterns?: Array<{ type: string; pattern: RegExp }> +): DetectionResult { // Strip zero-width / invisible characters before scanning so an attacker // cannot defeat the regex by inserting them inside PII values (e.g. // `john\u200B@example.com`). Matches found in the stripped form are @@ -70,6 +184,11 @@ export function detectPii( // obfuscated span โ€” including the invisible chars themselves. const { text: scanText, map } = stripInvisible(text); + const scanTruncated = scanText.length > MAX_PII_SCAN_LENGTH; + const boundedScanText = scanTruncated + ? scanText.slice(0, MAX_PII_SCAN_LENGTH) + : scanText; + const matches: PiiMatch[] = []; const allPatterns: Array<{ type: string; pattern: RegExp }> = [ ...BUILTIN_PATTERNS, @@ -81,7 +200,7 @@ export function detectPii( const regex = new RegExp(pattern.source, pattern.flags); let match: RegExpExecArray | null; - while ((match = regex.exec(scanText)) !== null) { + while ((match = regex.exec(boundedScanText)) !== null) { const s = match.index; const e = match.index + match[0].length; @@ -107,5 +226,11 @@ export function detectPii( // Sort by position matches.sort((a, b) => a.start - b.start); - return matches; + + return { + matches, + scanTruncated, + scannedLength: boundedScanText.length, + totalLength: scanText.length, + }; } diff --git a/packages/transformabl-core/src/normalize.ts b/packages/transformabl-core/src/normalize.ts index 362dfec..9727093 100644 --- a/packages/transformabl-core/src/normalize.ts +++ b/packages/transformabl-core/src/normalize.ts @@ -37,8 +37,10 @@ const INVISIBLE_CHAR_RE = /[\u200B-\u200D\u2060\uFEFF]/; // Defensive upper bound on the normalization pass. Callers should enforce // their own payload-size limits upstream; this cap exists so an oversized // input cannot itself turn the O(n) character walk into a DoS vector. -// PII detection still runs on the full original input โ€” the oversized- -// input path just skips ZW-normalization rather than iterating. +// Over this length ZW-normalization is skipped (the input is returned +// unchanged). Note the detection scan has its own, smaller cap +// (MAX_PII_SCAN_LENGTH, 512 KB) past which PII is not scanned โ€” detectPii +// truncates and detectPiiDetailed reports it via `scanTruncated`. const MAX_NORMALIZE_LENGTH = 1_000_000; export function stripInvisible(input: string): StrippedText { diff --git a/packages/transformabl-core/src/redact.ts b/packages/transformabl-core/src/redact.ts index c3e12f2..8508508 100644 --- a/packages/transformabl-core/src/redact.ts +++ b/packages/transformabl-core/src/redact.ts @@ -2,7 +2,16 @@ // // PII redaction: mask, remove, or replace detected PII. -import type { PiiMatch, RedactionConfig, RedactionMode } from "./types.js"; +import type { PiiMatch, PiiType, RedactionConfig, RedactionMode } from "./types.js"; + +/** A maximal interval formed by merging overlapping matches. */ +interface MergedSpan { + start: number; + end: number; + /** Type of the widest contributing match โ€” used for the placeholder label. */ + repType: PiiType; + repWidth: number; +} /** * Redact PII matches from text. @@ -11,6 +20,15 @@ import type { PiiMatch, RedactionConfig, RedactionMode } from "./types.js"; * - "mask": Replace middle characters with mask char (e.g., "jo**@example.com") * - "remove": Remove the PII entirely * - "placeholder": Replace with type label (e.g., "[EMAIL]") + * + * Overlapping matches are merged into maximal intervals before substitution. + * Detection routinely produces overlaps โ€” e.g. `us_bank_number` (`\d{8,17}`) + * spans the same digit run as `npi` / `us_drivers_license`. Substituting them + * independently (the old descending-start loop) let an inner match's + * replacement shift the text so an outer match's now-stale `end` sliced the + * wrong point, leaving trailing PII digits behind in placeholder/remove modes + * (H4). Merging first guarantees the spans handed to substitution are + * disjoint, so no substitution can invalidate another's offsets. */ export function redactPii( text: string, @@ -26,30 +44,58 @@ export function redactPii( ? new Set(config.types) : null; // null = redact all - // Process matches in reverse order to preserve positions - const sorted = [...matches].sort((a, b) => b.start - a.start); - let result = text; + // Only redact matches the caller asked for. Filtering BEFORE the merge is + // what keeps an excluded type from being swept into a redacted span just + // because it overlapped an included one. Drop zero-width/inverted spans too. + const selected = matches.filter( + (m) => (!typesToRedact || typesToRedact.has(m.type)) && m.end > m.start + ); + if (selected.length === 0) return text; - for (const match of sorted) { - if (typesToRedact && !typesToRedact.has(match.type)) continue; + // Merge overlapping spans into maximal intervals. Sort ascending by start + // (then end) and extend the current interval whenever the next match starts + // before the current one ends. Touching spans (start === prev end) stay + // separate โ€” they are already disjoint and safe to substitute independently. + const asc = [...selected].sort((a, b) => a.start - b.start || a.end - b.end); + const merged: MergedSpan[] = []; + for (const m of asc) { + const width = m.end - m.start; + const last = merged[merged.length - 1]; + if (last && m.start < last.end) { + if (m.end > last.end) last.end = m.end; + if (width > last.repWidth) { + last.repType = m.type; + last.repWidth = width; + } + } else { + merged.push({ start: m.start, end: m.end, repType: m.type, repWidth: width }); + } + } - let replacement: string; + // Substitute right-to-left so earlier offsets stay valid. Spans are disjoint, + // so the substring being replaced is exactly the original PII span โ€” no other + // span's coordinates can have shifted underneath it. + let result = text; + for (let i = merged.length - 1; i >= 0; i--) { + const span = merged[i]; + const original = text.slice(span.start, span.end); + let replacement: string; switch (mode) { case "mask": - replacement = maskValue(match.value, maskChar, maskKeep); + replacement = maskValue(original, maskChar, maskKeep); break; case "remove": replacement = ""; break; case "placeholder": replacement = config?.placeholder - ? config.placeholder.replace("{TYPE}", match.type.toUpperCase()) - : `[${match.type.toUpperCase()}]`; + ? config.placeholder.replace("{TYPE}", span.repType.toUpperCase()) + : `[${span.repType.toUpperCase()}]`; break; } - result = result.slice(0, match.start) + replacement + result.slice(match.end); + result = result.slice(0, span.start) + replacement + result.slice(span.end); } return result; diff --git a/packages/transformabl-core/src/transform.ts b/packages/transformabl-core/src/transform.ts index d644754..065e2f2 100644 --- a/packages/transformabl-core/src/transform.ts +++ b/packages/transformabl-core/src/transform.ts @@ -3,7 +3,7 @@ // Full transformation pipeline: detect โ†’ classify โ†’ redact โ†’ extract metadata. import type { TransformConfig, TransformResult } from "./types.js"; -import { detectPii } from "./detect.js"; +import { detectPiiDetailed } from "./detect.js"; import { redactPii } from "./redact.js"; import { classifyContent } from "./classify.js"; import { extractMetadata } from "./metadata.js"; @@ -23,7 +23,8 @@ export function transformContent( config?: TransformConfig ): TransformResult { // 1. Detect PII - const piiMatches = detectPii(content, config?.customPatterns); + const detection = detectPiiDetailed(content, config?.customPatterns); + const piiMatches = detection.matches; // 2. Classify const classification = @@ -54,5 +55,6 @@ export function transformContent( classification, metadata, transformed: redacted !== content, + scanTruncated: detection.scanTruncated, }; } diff --git a/packages/transformabl-core/src/types.ts b/packages/transformabl-core/src/types.ts index 171c9e7..4fbb3c2 100644 --- a/packages/transformabl-core/src/types.ts +++ b/packages/transformabl-core/src/types.ts @@ -2,12 +2,26 @@ /** Categories of PII that can be detected. */ export type PiiType = + // Universal | "email" | "phone" | "ssn" | "credit_card" | "ip_address" - | "date_of_birth"; + | "date_of_birth" + // US identifiers (Presidio parity) + | "us_bank_number" + | "us_itin" + | "us_passport" + | "us_drivers_license" + // International banking + | "iban" + // Healthcare (HIPAA PHI) + | "icd_10" + | "icd_9" + | "npi" + // Financial + | "crypto_wallet"; /** A single PII detection match. */ export interface PiiMatch { @@ -88,6 +102,13 @@ export interface TransformResult { metadata: ContentMetadata; /** Whether any transformation was applied. */ transformed: boolean; + /** + * True when the input exceeded the PII scan cap (512 KB of invisible- + * stripped text) and content past the cap was NOT scanned for PII. A + * consumer must surface this rather than treat the result as a clean pass โ€” + * fail-open must be loud (M2 / #40). + */ + scanTruncated: boolean; } /** Configuration for the full transformation pipeline. */ diff --git a/packages/transformabl-core/tsconfig.json b/packages/transformabl-core/tsconfig.json index f3f2f18..6566a49 100644 --- a/packages/transformabl-core/tsconfig.json +++ b/packages/transformabl-core/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"] } diff --git a/packages/transformabl/LICENSE b/packages/transformabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/transformabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/transformabl/package.json b/packages/transformabl/package.json index e32affa..9454df2 100644 --- a/packages/transformabl/package.json +++ b/packages/transformabl/package.json @@ -1,6 +1,6 @@ { "name": "@gatewaystack/transformabl", - "version": "0.2.0", + "version": "0.3.0", "private": false, "license": "MIT", "type": "module", @@ -19,12 +19,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@gatewaystack/transformabl-core": "^0.3.0" + "@gatewaystack/transformabl-core": "^0.4.1" }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/transformabl/src/index.ts b/packages/transformabl/src/index.ts index 66a7dbd..94a04da 100644 --- a/packages/transformabl/src/index.ts +++ b/packages/transformabl/src/index.ts @@ -80,6 +80,8 @@ export function transformabl(config?: TransformablMiddlewareConfig): RequestHand export { transformContent, detectPii, + detectPiiDetailed, + MAX_PII_SCAN_LENGTH, redactPii, classifyContent, extractMetadata, diff --git a/packages/transformabl/tsconfig.json b/packages/transformabl/tsconfig.json index f6426b1..1a667f2 100644 --- a/packages/transformabl/tsconfig.json +++ b/packages/transformabl/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"], "references": [ diff --git a/packages/validatabl-core/LICENSE b/packages/validatabl-core/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/validatabl-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/validatabl-core/package.json b/packages/validatabl-core/package.json index c8dd361..072e135 100644 --- a/packages/validatabl-core/package.json +++ b/packages/validatabl-core/package.json @@ -20,5 +20,8 @@ }, "devDependencies": { "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/validatabl-core/tsconfig.json b/packages/validatabl-core/tsconfig.json index f3f2f18..6566a49 100644 --- a/packages/validatabl-core/tsconfig.json +++ b/packages/validatabl-core/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"] } diff --git a/packages/validatabl/LICENSE b/packages/validatabl/LICENSE new file mode 100644 index 0000000..c29d293 --- /dev/null +++ b/packages/validatabl/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reducibl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/validatabl/package.json b/packages/validatabl/package.json index 3ffa24e..b214cc9 100644 --- a/packages/validatabl/package.json +++ b/packages/validatabl/package.json @@ -27,5 +27,8 @@ "devDependencies": { "@types/express": "^4.17.21", "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18" } } diff --git a/packages/validatabl/tsconfig.json b/packages/validatabl/tsconfig.json index 31b0f44..978a775 100644 --- a/packages/validatabl/tsconfig.json +++ b/packages/validatabl/tsconfig.json @@ -8,7 +8,7 @@ "declarationMap": true, "sourceMap": true, "types": ["node"], - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + "tsBuildInfoFile": "tsconfig.tsbuildinfo" }, "include": ["src"], "references": [