hazo_secure 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +30 -0
- package/README.md +24 -2
- package/SETUP_CHECKLIST.md +17 -0
- package/dist/crypto/index.d.ts +58 -1
- package/dist/crypto/index.js +91 -1
- package/dist/mask/index.d.ts +48 -0
- package/dist/mask/index.js +115 -0
- package/dist/secrets/index.d.ts +41 -0
- package/dist/secrets/index.js +84 -0
- package/package.json +31 -17
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 1.4.0 — 2026-07-12
|
|
4
|
+
|
|
5
|
+
### New: `hazo_secure/crypto` — generic HMAC sign/verify primitive
|
|
6
|
+
|
|
7
|
+
- **`signHmac(payload, secret)`** — signs an arbitrary UTF-8 payload string
|
|
8
|
+
with HMAC-SHA256, returning a `<payloadB64url>.<sigB64url>` token. No
|
|
9
|
+
expiry/claims logic — callers put their own JSON in the payload and handle
|
|
10
|
+
their own expiry/revocation.
|
|
11
|
+
- **`verifyHmac(token, secret)`** — constant-time verification via
|
|
12
|
+
`timingSafeEqual`, with a length pre-check on the signature against a
|
|
13
|
+
fixed constant before comparing, and a caught missing/empty-secret path
|
|
14
|
+
that fails closed with `{ valid: false, reason: "malformed" }` instead of
|
|
15
|
+
throwing. Returns `{ valid: true, payload }` with the decoded original
|
|
16
|
+
payload string on success.
|
|
17
|
+
- **`HmacVerifyResult`** type: `{ valid: true; payload: string } | { valid: false; reason: "malformed" | "signature_mismatch" }`.
|
|
18
|
+
- Dependency-free (only `node:crypto`); added to the existing `./crypto`
|
|
19
|
+
subpath export, no new subpath. Motivated by `hazo_embed`'s need for
|
|
20
|
+
domain-locked embed-key token signing, but kept fully generic.
|
|
21
|
+
|
|
22
|
+
## 1.3.0 — 2026-06-12
|
|
23
|
+
|
|
24
|
+
### New: `hazo_secure/secrets` — SecretsProvider
|
|
25
|
+
|
|
26
|
+
- **`SecretsProvider` interface** with `get(name)` / `resolve(names[])` methods.
|
|
27
|
+
- **`EnvSecretsProvider`** — reads from env vars (`${prefix}_${NAME}`; default prefix `HAZO_SECRET`).
|
|
28
|
+
- **`StaticSecretsProvider`** — in-memory map; primary use is tests.
|
|
29
|
+
- **`LookupSecretsProvider`** — caller-supplied `(name) => string|undefined` function.
|
|
30
|
+
- **`SecretsError`** — extends `HazoError`, code `HAZO_SECURE_SECRET_NOT_FOUND` (httpStatus 400, retryable false).
|
|
31
|
+
- New `./secrets` subpath export.
|
|
32
|
+
|
|
3
33
|
## 1.1.0 — 2026-05-29
|
|
4
34
|
|
|
5
35
|
### hazo workspace v2 — Wave 2 migration
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hazo_secure
|
|
2
2
|
|
|
3
|
-
Security & compliance primitives for Node.js / Next.js apps.
|
|
3
|
+
Security & compliance primitives for Node.js / Next.js apps. Seven independent subpath modules — import only the ones you need.
|
|
4
4
|
|
|
5
5
|
| Subpath | Purpose |
|
|
6
6
|
|---|---|
|
|
@@ -9,6 +9,8 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
9
9
|
| `hazo_secure/crypto` | AES-256-GCM field-level encryption with key versioning and AAD support |
|
|
10
10
|
| `hazo_secure/gdpr` | Exporter / anonymiser registry + lifecycle orchestrator. Uses `hazo_jobs` for async export, `hazo_files` for ZIP delivery |
|
|
11
11
|
| `hazo_secure/csrf` | Double-submit CSRF protection for Next.js routes — token generation, cookie header, constant-time verification |
|
|
12
|
+
| `hazo_secure/mask` | PII masking and anonymization transforms (redact, hash, tokenize, fake_name, jitter_date, …) |
|
|
13
|
+
| `hazo_secure/secrets` | Provider-pattern secret resolution — env vars, static map, or custom lookup |
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -16,7 +18,7 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
16
18
|
npm install hazo_secure
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
`
|
|
21
|
+
`hazo_core` is a required peer dep. `hazo_logs`, `hazo_connect`, `hazo_jobs`, `hazo_files`, and `hazo_ui` are optional peers — `hazo_connect` is needed for `ConnectRateLimitStore`; `hazo_jobs` and `hazo_files` are only needed for `hazo_secure/gdpr`.
|
|
20
22
|
|
|
21
23
|
## Quick start
|
|
22
24
|
|
|
@@ -56,6 +58,24 @@ const stored = await encryptField(plaintext, { keys, aad: `user:${userId}` });
|
|
|
56
58
|
const recovered = await decryptField(stored, { keys });
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
### Resolve application secrets
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { EnvSecretsProvider, StaticSecretsProvider, LookupSecretsProvider } from "hazo_secure/secrets";
|
|
65
|
+
|
|
66
|
+
// Reads from env vars: HAZO_SECRET_DB_PASSWORD, HAZO_SECRET_STRIPE_KEY, …
|
|
67
|
+
const secrets = new EnvSecretsProvider();
|
|
68
|
+
const { db_password, stripe_key } = await secrets.resolve(["db_password", "stripe_key"]);
|
|
69
|
+
|
|
70
|
+
// Tests — in-memory map
|
|
71
|
+
const mock = new StaticSecretsProvider({ api_key: "test-key" });
|
|
72
|
+
|
|
73
|
+
// Custom backend (KMS, HashiCorp Vault, etc.)
|
|
74
|
+
const vault = new LookupSecretsProvider(async (name) => fetchFromVault(name));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`SecretsError` (code `HAZO_SECURE_SECRET_NOT_FOUND`) is thrown when a name can't be resolved. Use a custom prefix: `new EnvSecretsProvider("MY_APP")` → reads `MY_APP_<NAME>`.
|
|
78
|
+
|
|
59
79
|
### Register GDPR exporters
|
|
60
80
|
|
|
61
81
|
```ts
|
|
@@ -152,6 +172,8 @@ await gdpr.anonymiseUser({ userId });
|
|
|
152
172
|
| `hazo_secure/fetch` | ⚠️ pre-flight only | ✅ full protection | Edge: host/IP checks only; Node: + undici connect-time validation |
|
|
153
173
|
| `hazo_secure/crypto` | ❌ | ✅ | Uses `node:crypto` |
|
|
154
174
|
| `hazo_secure/gdpr` | ❌ | ✅ | Async iterables + optional hazo_connect |
|
|
175
|
+
| `hazo_secure/mask` | ✅ | ✅ | Pure transforms — no I/O |
|
|
176
|
+
| `hazo_secure/secrets` | ✅ | ✅ | Pure async — no Node built-ins |
|
|
155
177
|
|
|
156
178
|
**Node.js ≥ 18 required** for all Node-only subpaths.
|
|
157
179
|
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -34,6 +34,20 @@ Default in-memory store works out of the box. For multi-instance deployments, su
|
|
|
34
34
|
- **Custom** — implement the `KeyProvider` interface directly
|
|
35
35
|
3. Decide AAD scheme — usually `domain:entityId` (e.g. `health:${personId}`)
|
|
36
36
|
|
|
37
|
+
### `hazo_secure/secrets`
|
|
38
|
+
|
|
39
|
+
No setup. Choose a provider at startup:
|
|
40
|
+
|
|
41
|
+
- **`EnvSecretsProvider(prefix?)`** — reads from `${prefix}_${NAME}` env vars (default prefix: `HAZO_SECRET`). Set each secret as an env var on your deployment platform.
|
|
42
|
+
- **`StaticSecretsProvider(map)`** — in-memory; use in tests only.
|
|
43
|
+
- **`LookupSecretsProvider(fn)`** — caller-supplied sync or async lookup; useful for KMS / Vault integrations.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { EnvSecretsProvider } from "hazo_secure/secrets";
|
|
47
|
+
const secrets = new EnvSecretsProvider();
|
|
48
|
+
const value = await secrets.get("db_password"); // reads HAZO_SECRET_DB_PASSWORD
|
|
49
|
+
```
|
|
50
|
+
|
|
37
51
|
### `hazo_secure/gdpr`
|
|
38
52
|
|
|
39
53
|
1. Install optional peers if you'll use async export and ZIP delivery:
|
|
@@ -65,4 +79,7 @@ node --input-type=module -e "import * as m from 'hazo_secure/crypto'; console.lo
|
|
|
65
79
|
|
|
66
80
|
# GDPR — expect: createGdprRegistry, GdprRegistryError
|
|
67
81
|
node --input-type=module -e "import * as m from 'hazo_secure/gdpr'; console.log(Object.keys(m).join(', '))"
|
|
82
|
+
|
|
83
|
+
# Secrets — expect: SecretsError, SecretsProvider (type), EnvSecretsProvider, StaticSecretsProvider, LookupSecretsProvider
|
|
84
|
+
node --input-type=module -e "import * as m from 'hazo_secure/secrets'; console.log(Object.keys(m).join(', '))"
|
|
68
85
|
```
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -31,6 +31,30 @@ declare class HttpKeyProvider implements KeyProvider {
|
|
|
31
31
|
clearCache(): void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Sign an arbitrary UTF-8 payload string with HMAC-SHA256, returning a
|
|
36
|
+
* `<payloadB64url>.<sigB64url>` token. The payload is opaque to this
|
|
37
|
+
* module — callers put their own JSON (or any string) in it and are
|
|
38
|
+
* responsible for their own expiry/revocation semantics; this primitive
|
|
39
|
+
* only proves the payload wasn't tampered with and was signed under `secret`.
|
|
40
|
+
*/
|
|
41
|
+
declare function signHmac(payload: string, secret: string): string;
|
|
42
|
+
type HmacVerifyResult = {
|
|
43
|
+
valid: true;
|
|
44
|
+
payload: string;
|
|
45
|
+
} | {
|
|
46
|
+
valid: false;
|
|
47
|
+
reason: "malformed" | "signature_mismatch";
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Verify a token produced by `signHmac`. Constant-time signature comparison
|
|
51
|
+
* via `timingSafeEqual`, with a length pre-check against a fixed constant
|
|
52
|
+
* (not secret-dependent) so short/garbage signatures never reach the
|
|
53
|
+
* timing-safe path. Fails closed — never throws, even for a missing/empty
|
|
54
|
+
* `secret` or a malformed token.
|
|
55
|
+
*/
|
|
56
|
+
declare function verifyHmac(token: string, secret: string): HmacVerifyResult;
|
|
57
|
+
|
|
34
58
|
/**
|
|
35
59
|
* Source of symmetric keys for `encryptField` / `decryptField`. The current
|
|
36
60
|
* key is used for encryption; `byId` resolves any key (current or rotated-out)
|
|
@@ -140,8 +164,41 @@ declare class StaticKeyProvider implements KeyProvider {
|
|
|
140
164
|
}>;
|
|
141
165
|
byId(keyId: string): Promise<Uint8Array>;
|
|
142
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Lookup-function-backed `KeyProvider` for config-file (INI) key sources.
|
|
169
|
+
* Reads keys from a caller-supplied synchronous lookup function, decoding
|
|
170
|
+
* each value from base64 to a 32-byte AES-256 key.
|
|
171
|
+
*
|
|
172
|
+
* Expected key naming convention (mirrors hazo_config INI layout):
|
|
173
|
+
* `current` → the current key id (e.g. `"v1"`)
|
|
174
|
+
* `key_<id>` → base64-encoded 32-byte key (e.g. `key_v1=<base64>`)
|
|
175
|
+
*
|
|
176
|
+
* Example backed by hazo_config:
|
|
177
|
+
* ```ts
|
|
178
|
+
* const cfg = getConfig('crypto.myapp'); // hazo_config, not imported here
|
|
179
|
+
* const keys = new LookupKeyProvider(name => cfg.get(name));
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* hazo_secure takes no dependency on hazo_config — the caller supplies the
|
|
183
|
+
* lookup, keeping this library config-agnostic. `Buffer.from(value, 'base64')`
|
|
184
|
+
* handles standard base64; keys decoded to anything other than 32 bytes are
|
|
185
|
+
* rejected with `invalid_key`.
|
|
186
|
+
*
|
|
187
|
+
* Decoded keys are memoised per id so the lookup runs once per key per
|
|
188
|
+
* process lifetime.
|
|
189
|
+
*/
|
|
190
|
+
declare class LookupKeyProvider implements KeyProvider {
|
|
191
|
+
private readonly lookup;
|
|
192
|
+
private readonly cache;
|
|
193
|
+
constructor(lookup: (name: string) => string | undefined);
|
|
194
|
+
current(): Promise<{
|
|
195
|
+
keyId: string;
|
|
196
|
+
key: Uint8Array;
|
|
197
|
+
}>;
|
|
198
|
+
byId(keyId: string): Promise<Uint8Array>;
|
|
199
|
+
}
|
|
143
200
|
/** Helper: build a `aad` string with a stable, parser-friendly shape.
|
|
144
201
|
* Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
|
|
145
202
|
declare function aadFor(table: string, entityId: string, field: string): string;
|
|
146
203
|
|
|
147
|
-
export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
|
|
204
|
+
export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, type HmacVerifyResult, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, LookupKeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField, signHmac, verifyHmac };
|
package/dist/crypto/index.js
CHANGED
|
@@ -79,6 +79,55 @@ var HttpKeyProvider = class {
|
|
|
79
79
|
}
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
// src/crypto/hmac.ts
|
|
83
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
84
|
+
var HMAC_B64URL_LEN = 43;
|
|
85
|
+
function b64url(input) {
|
|
86
|
+
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
|
87
|
+
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
88
|
+
}
|
|
89
|
+
function b64urlDecode(input) {
|
|
90
|
+
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
91
|
+
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
92
|
+
}
|
|
93
|
+
function sign(payloadB64, secret) {
|
|
94
|
+
if (!secret) throw new Error("hazo_secure/crypto: HMAC secret is not set");
|
|
95
|
+
return b64url(createHmac("sha256", secret).update(payloadB64).digest());
|
|
96
|
+
}
|
|
97
|
+
function signHmac(payload, secret) {
|
|
98
|
+
const payloadB64 = b64url(payload);
|
|
99
|
+
const sig = sign(payloadB64, secret);
|
|
100
|
+
return `${payloadB64}.${sig}`;
|
|
101
|
+
}
|
|
102
|
+
function verifyHmac(token, secret) {
|
|
103
|
+
if (!secret) return { valid: false, reason: "malformed" };
|
|
104
|
+
if (!token || typeof token !== "string") return { valid: false, reason: "malformed" };
|
|
105
|
+
const parts = token.split(".");
|
|
106
|
+
if (parts.length !== 2) return { valid: false, reason: "malformed" };
|
|
107
|
+
const [payloadB64, sig] = parts;
|
|
108
|
+
if (sig.length !== HMAC_B64URL_LEN) {
|
|
109
|
+
return { valid: false, reason: "signature_mismatch" };
|
|
110
|
+
}
|
|
111
|
+
let expectedSig;
|
|
112
|
+
try {
|
|
113
|
+
expectedSig = sign(payloadB64, secret);
|
|
114
|
+
} catch {
|
|
115
|
+
return { valid: false, reason: "malformed" };
|
|
116
|
+
}
|
|
117
|
+
const a = Buffer.from(sig);
|
|
118
|
+
const b = Buffer.from(expectedSig);
|
|
119
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
120
|
+
return { valid: false, reason: "signature_mismatch" };
|
|
121
|
+
}
|
|
122
|
+
let payload;
|
|
123
|
+
try {
|
|
124
|
+
payload = b64urlDecode(payloadB64).toString("utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
return { valid: false, reason: "malformed" };
|
|
127
|
+
}
|
|
128
|
+
return { valid: true, payload };
|
|
129
|
+
}
|
|
130
|
+
|
|
82
131
|
// src/crypto/index.ts
|
|
83
132
|
var CryptoError = class extends HazoError {
|
|
84
133
|
constructor(message, code) {
|
|
@@ -218,6 +267,44 @@ var StaticKeyProvider = class {
|
|
|
218
267
|
return key;
|
|
219
268
|
}
|
|
220
269
|
};
|
|
270
|
+
var LookupKeyProvider = class {
|
|
271
|
+
constructor(lookup) {
|
|
272
|
+
this.lookup = lookup;
|
|
273
|
+
}
|
|
274
|
+
lookup;
|
|
275
|
+
cache = /* @__PURE__ */ new Map();
|
|
276
|
+
async current() {
|
|
277
|
+
const keyId = this.lookup("current");
|
|
278
|
+
if (!keyId) {
|
|
279
|
+
throw new CryptoError(
|
|
280
|
+
"LookupKeyProvider: no 'current' key id from lookup",
|
|
281
|
+
"missing_env_key"
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const key = await this.byId(keyId);
|
|
285
|
+
return { keyId, key };
|
|
286
|
+
}
|
|
287
|
+
async byId(keyId) {
|
|
288
|
+
if (this.cache.has(keyId)) return this.cache.get(keyId);
|
|
289
|
+
const lookupName = `key_${keyId}`;
|
|
290
|
+
const raw = this.lookup(lookupName);
|
|
291
|
+
if (!raw) {
|
|
292
|
+
throw new CryptoError(
|
|
293
|
+
`LookupKeyProvider: no key material for id ${keyId} (looked up "${lookupName}")`,
|
|
294
|
+
"key_not_found"
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const buf = Buffer.from(raw, "base64");
|
|
298
|
+
if (buf.byteLength !== KEY_LEN2) {
|
|
299
|
+
throw new CryptoError(
|
|
300
|
+
`${lookupName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}`,
|
|
301
|
+
"invalid_key"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
this.cache.set(keyId, buf);
|
|
305
|
+
return buf;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
221
308
|
function aadFor(table, entityId, field) {
|
|
222
309
|
return `${table}:${entityId}:${field}`;
|
|
223
310
|
}
|
|
@@ -225,8 +312,11 @@ export {
|
|
|
225
312
|
CryptoError,
|
|
226
313
|
EnvKeyProvider,
|
|
227
314
|
HttpKeyProvider,
|
|
315
|
+
LookupKeyProvider,
|
|
228
316
|
StaticKeyProvider,
|
|
229
317
|
aadFor,
|
|
230
318
|
decryptField,
|
|
231
|
-
encryptField
|
|
319
|
+
encryptField,
|
|
320
|
+
signHmac,
|
|
321
|
+
verifyHmac
|
|
232
322
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mask_email — replace with deterministic masked email.
|
|
3
|
+
* alice@example.com → a3f9b12c1d2e@masked.example
|
|
4
|
+
* Same source → same masked value (FK survival).
|
|
5
|
+
*/
|
|
6
|
+
declare function mask_email(value: unknown, _col: string, maskKey: string): unknown;
|
|
7
|
+
/**
|
|
8
|
+
* mask_phone — replace with deterministic masked phone.
|
|
9
|
+
* Preserves leading + and length pattern. +1-555-0001 → +1-XXX-a3f9b1
|
|
10
|
+
*/
|
|
11
|
+
declare function mask_phone(value: unknown, _col: string, maskKey: string): unknown;
|
|
12
|
+
declare function fake_name(value: unknown, _col: string, maskKey: string): unknown;
|
|
13
|
+
/**
|
|
14
|
+
* jitter_date — add a deterministic date offset (±0–180 days) to a date string.
|
|
15
|
+
* Preserves approximate era; different source values → different offsets.
|
|
16
|
+
*/
|
|
17
|
+
declare function jitter_date(value: unknown, _col: string, maskKey: string): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* hash — replace with HMAC-SHA256 hex hash of value.
|
|
20
|
+
* Fully opaque; same source → same hash.
|
|
21
|
+
*/
|
|
22
|
+
declare function hash(value: unknown, col: string, maskKey: string): unknown;
|
|
23
|
+
/**
|
|
24
|
+
* tokenize — replace with a stable short token (FK-safe).
|
|
25
|
+
* tok_<12 hex chars> — same source → same token.
|
|
26
|
+
*/
|
|
27
|
+
declare function tokenize(value: unknown, col: string, maskKey: string): unknown;
|
|
28
|
+
/**
|
|
29
|
+
* drop — always returns null (secret dropped, not masked).
|
|
30
|
+
* Used for passwords, tokens, etc.
|
|
31
|
+
*/
|
|
32
|
+
declare function drop(_value: unknown, _col: string, _maskKey: string): unknown;
|
|
33
|
+
/**
|
|
34
|
+
* nullify — replace with null (less aggressive than drop; preserves column semantics).
|
|
35
|
+
*/
|
|
36
|
+
declare function nullify(_value: unknown, _col: string, _maskKey: string): unknown;
|
|
37
|
+
/**
|
|
38
|
+
* redact_pii — NER-based PII redaction (wink-nlp lite model).
|
|
39
|
+
* Detects named entities (dates, cardinals, etc.) and replaces them with [REDACTED_TYPE].
|
|
40
|
+
* Deterministic: same input + same model → same output.
|
|
41
|
+
* maskKey is accepted but not used (redaction is model-driven, not key-driven).
|
|
42
|
+
*/
|
|
43
|
+
declare function redact_pii(value: unknown, _col: string, _maskKey: string): unknown;
|
|
44
|
+
/** Registry of all built-in transforms */
|
|
45
|
+
declare const BUILTIN_TRANSFORMS: Record<string, (value: unknown, col: string, maskKey: string) => unknown>;
|
|
46
|
+
type MaskTransformFn = (value: unknown, col: string, maskKey: string) => unknown;
|
|
47
|
+
|
|
48
|
+
export { BUILTIN_TRANSFORMS, type MaskTransformFn, drop, fake_name, hash, jitter_date, mask_email, mask_phone, nullify, redact_pii, tokenize };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/mask/index.ts
|
|
2
|
+
import { createHmac } from "crypto";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
function hmacHex(value, maskKey, context) {
|
|
5
|
+
return createHmac("sha256", maskKey).update(`${context}:${value}`).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
function hmacToken(value, maskKey, context) {
|
|
8
|
+
return hmacHex(value, maskKey, context).slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
function mask_email(value, _col, maskKey) {
|
|
11
|
+
if (value === null || value === void 0) return value;
|
|
12
|
+
const str = String(value);
|
|
13
|
+
const atIdx = str.indexOf("@");
|
|
14
|
+
const domain = atIdx >= 0 ? str.slice(atIdx + 1) : "example.com";
|
|
15
|
+
const token = hmacToken(str, maskKey, "email");
|
|
16
|
+
return `${token}@masked.${domain}`;
|
|
17
|
+
}
|
|
18
|
+
function mask_phone(value, _col, maskKey) {
|
|
19
|
+
if (value === null || value === void 0) return value;
|
|
20
|
+
const str = String(value);
|
|
21
|
+
const token = hmacToken(str, maskKey, "phone").slice(0, 6);
|
|
22
|
+
const hasPlus = str.startsWith("+");
|
|
23
|
+
const digits = str.replace(/[^\d]/g, "");
|
|
24
|
+
const prefix = digits.length > 4 ? digits.slice(0, 2) : "00";
|
|
25
|
+
return `${hasPlus ? "+" : ""}${prefix}-XXX-${token}`;
|
|
26
|
+
}
|
|
27
|
+
var FIRST_NAMES = ["Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Indigo", "Jordan"];
|
|
28
|
+
var LAST_NAMES = ["Smith", "Jones", "Williams", "Brown", "Davis", "Miller", "Wilson", "Moore", "Taylor", "Anderson"];
|
|
29
|
+
function fake_name(value, _col, maskKey) {
|
|
30
|
+
if (value === null || value === void 0) return value;
|
|
31
|
+
const str = String(value);
|
|
32
|
+
const h = hmacHex(str, maskKey, "name");
|
|
33
|
+
const firstIdx = parseInt(h.slice(0, 2), 16) % FIRST_NAMES.length;
|
|
34
|
+
const lastIdx = parseInt(h.slice(2, 4), 16) % LAST_NAMES.length;
|
|
35
|
+
return `${FIRST_NAMES[firstIdx]} ${LAST_NAMES[lastIdx]}`;
|
|
36
|
+
}
|
|
37
|
+
function jitter_date(value, _col, maskKey) {
|
|
38
|
+
if (value === null || value === void 0) return value;
|
|
39
|
+
const str = String(value);
|
|
40
|
+
const h = hmacHex(str, maskKey, "date");
|
|
41
|
+
const jitterDays = parseInt(h.slice(0, 4), 16) % 361 - 180;
|
|
42
|
+
const date = new Date(str);
|
|
43
|
+
if (isNaN(date.getTime())) return value;
|
|
44
|
+
date.setDate(date.getDate() + jitterDays);
|
|
45
|
+
return date.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
function hash(value, col, maskKey) {
|
|
48
|
+
if (value === null || value === void 0) return value;
|
|
49
|
+
return hmacHex(String(value), maskKey, col);
|
|
50
|
+
}
|
|
51
|
+
function tokenize(value, col, maskKey) {
|
|
52
|
+
if (value === null || value === void 0) return value;
|
|
53
|
+
return `tok_${hmacToken(String(value), maskKey, col)}`;
|
|
54
|
+
}
|
|
55
|
+
function drop(_value, _col, _maskKey) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function nullify(_value, _col, _maskKey) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
var _esmReq = createRequire(import.meta.url);
|
|
62
|
+
var _nlpInstance = null;
|
|
63
|
+
var _nlpIts = null;
|
|
64
|
+
function getNlp() {
|
|
65
|
+
if (_nlpInstance) return { nlp: _nlpInstance, its: _nlpIts };
|
|
66
|
+
try {
|
|
67
|
+
const winkNLP = _esmReq("wink-nlp");
|
|
68
|
+
const model = _esmReq("wink-eng-lite-web-model");
|
|
69
|
+
const nlp = winkNLP(model, ["ner"]);
|
|
70
|
+
_nlpInstance = nlp;
|
|
71
|
+
_nlpIts = nlp.its;
|
|
72
|
+
return { nlp, its: nlp.its };
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function redact_pii(value, _col, _maskKey) {
|
|
78
|
+
if (value === null || value === void 0) return value;
|
|
79
|
+
const text = String(value);
|
|
80
|
+
const ctx = getNlp();
|
|
81
|
+
if (!ctx) return value;
|
|
82
|
+
const doc = ctx.nlp.readDoc(text);
|
|
83
|
+
const replacements = [];
|
|
84
|
+
doc.entities().each((e) => {
|
|
85
|
+
replacements.push({ text: e.out(), type: e.out(ctx.its.type) });
|
|
86
|
+
});
|
|
87
|
+
let result = text;
|
|
88
|
+
for (const { text: entityText, type } of replacements) {
|
|
89
|
+
result = result.replaceAll(entityText, `[REDACTED_${type}]`);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
var BUILTIN_TRANSFORMS = {
|
|
94
|
+
mask_email,
|
|
95
|
+
mask_phone,
|
|
96
|
+
fake_name,
|
|
97
|
+
jitter_date,
|
|
98
|
+
hash,
|
|
99
|
+
tokenize,
|
|
100
|
+
drop,
|
|
101
|
+
nullify,
|
|
102
|
+
redact_pii
|
|
103
|
+
};
|
|
104
|
+
export {
|
|
105
|
+
BUILTIN_TRANSFORMS,
|
|
106
|
+
drop,
|
|
107
|
+
fake_name,
|
|
108
|
+
hash,
|
|
109
|
+
jitter_date,
|
|
110
|
+
mask_email,
|
|
111
|
+
mask_phone,
|
|
112
|
+
nullify,
|
|
113
|
+
redact_pii,
|
|
114
|
+
tokenize
|
|
115
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
|
|
3
|
+
type SecretsErrorCode = 'HAZO_SECURE_SECRET_NOT_FOUND';
|
|
4
|
+
declare class SecretsError extends HazoError {
|
|
5
|
+
readonly code: SecretsErrorCode;
|
|
6
|
+
constructor(code: SecretsErrorCode, message: string);
|
|
7
|
+
}
|
|
8
|
+
interface SecretsProvider {
|
|
9
|
+
/** Resolve multiple secret names at once. Returns all resolved values.
|
|
10
|
+
* Throws SecretsError (HAZO_SECURE_SECRET_NOT_FOUND) for any missing name. */
|
|
11
|
+
resolve(names: string[]): Promise<Record<string, string>>;
|
|
12
|
+
/** Resolve a single secret. Throws if absent. */
|
|
13
|
+
get(name: string): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reads secrets from environment variables.
|
|
17
|
+
* Variable name = `${prefix}_${NAME.toUpperCase()}` (underscores preserved).
|
|
18
|
+
* Default prefix: `HAZO_SECRET`
|
|
19
|
+
*/
|
|
20
|
+
declare class EnvSecretsProvider implements SecretsProvider {
|
|
21
|
+
private readonly prefix;
|
|
22
|
+
constructor(prefix?: string);
|
|
23
|
+
get(name: string): Promise<string>;
|
|
24
|
+
resolve(names: string[]): Promise<Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
/** In-memory map. Primary use: tests. */
|
|
27
|
+
declare class StaticSecretsProvider implements SecretsProvider {
|
|
28
|
+
private readonly map;
|
|
29
|
+
constructor(secrets: Record<string, string>);
|
|
30
|
+
get(name: string): Promise<string>;
|
|
31
|
+
resolve(names: string[]): Promise<Record<string, string>>;
|
|
32
|
+
}
|
|
33
|
+
/** Caller-supplied lookup function. Useful for custom secret backends. */
|
|
34
|
+
declare class LookupSecretsProvider implements SecretsProvider {
|
|
35
|
+
private readonly lookup;
|
|
36
|
+
constructor(lookup: (name: string) => string | undefined | Promise<string | undefined>);
|
|
37
|
+
get(name: string): Promise<string>;
|
|
38
|
+
resolve(names: string[]): Promise<Record<string, string>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { EnvSecretsProvider, LookupSecretsProvider, SecretsError, type SecretsProvider, StaticSecretsProvider };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/secrets/index.ts
|
|
2
|
+
import { HazoError } from "hazo_core";
|
|
3
|
+
var SecretsError = class extends HazoError {
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super({ code, pkg: "hazo_secure", message, httpStatus: 400, retryable: false });
|
|
6
|
+
this.name = "SecretsError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var EnvSecretsProvider = class {
|
|
10
|
+
prefix;
|
|
11
|
+
constructor(prefix = "HAZO_SECRET") {
|
|
12
|
+
this.prefix = prefix;
|
|
13
|
+
}
|
|
14
|
+
async get(name) {
|
|
15
|
+
const envKey = `${this.prefix}_${name.toUpperCase()}`;
|
|
16
|
+
const val = process.env[envKey];
|
|
17
|
+
if (val === void 0) {
|
|
18
|
+
throw new SecretsError(
|
|
19
|
+
"HAZO_SECURE_SECRET_NOT_FOUND",
|
|
20
|
+
`Secret "${name}" not found (checked env var ${envKey})`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return val;
|
|
24
|
+
}
|
|
25
|
+
async resolve(names) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
out[name] = await this.get(name);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var StaticSecretsProvider = class {
|
|
34
|
+
map;
|
|
35
|
+
constructor(secrets) {
|
|
36
|
+
this.map = new Map(Object.entries(secrets));
|
|
37
|
+
}
|
|
38
|
+
async get(name) {
|
|
39
|
+
const val = this.map.get(name);
|
|
40
|
+
if (val === void 0) {
|
|
41
|
+
throw new SecretsError(
|
|
42
|
+
"HAZO_SECURE_SECRET_NOT_FOUND",
|
|
43
|
+
`Secret "${name}" not found in static map`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return val;
|
|
47
|
+
}
|
|
48
|
+
async resolve(names) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
out[name] = await this.get(name);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var LookupSecretsProvider = class {
|
|
57
|
+
lookup;
|
|
58
|
+
constructor(lookup) {
|
|
59
|
+
this.lookup = lookup;
|
|
60
|
+
}
|
|
61
|
+
async get(name) {
|
|
62
|
+
const val = await this.lookup(name);
|
|
63
|
+
if (val === void 0) {
|
|
64
|
+
throw new SecretsError(
|
|
65
|
+
"HAZO_SECURE_SECRET_NOT_FOUND",
|
|
66
|
+
`Secret "${name}" not found via lookup`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return val;
|
|
70
|
+
}
|
|
71
|
+
async resolve(names) {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
out[name] = await this.get(name);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
export {
|
|
80
|
+
EnvSecretsProvider,
|
|
81
|
+
LookupSecretsProvider,
|
|
82
|
+
SecretsError,
|
|
83
|
+
StaticSecretsProvider
|
|
84
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_secure",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration, CSRF. Five subpath modules, pick the ones you need.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -36,6 +36,16 @@
|
|
|
36
36
|
"types": "./dist/csrf/index.d.ts",
|
|
37
37
|
"import": "./dist/csrf/index.js",
|
|
38
38
|
"default": "./dist/csrf/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./mask": {
|
|
41
|
+
"types": "./dist/mask/index.d.ts",
|
|
42
|
+
"import": "./dist/mask/index.js",
|
|
43
|
+
"default": "./dist/mask/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./secrets": {
|
|
46
|
+
"types": "./dist/secrets/index.d.ts",
|
|
47
|
+
"import": "./dist/secrets/index.js",
|
|
48
|
+
"default": "./dist/secrets/index.js"
|
|
39
49
|
}
|
|
40
50
|
},
|
|
41
51
|
"files": [
|
|
@@ -53,23 +63,25 @@
|
|
|
53
63
|
"build": "tsup",
|
|
54
64
|
"dev": "tsup --watch",
|
|
55
65
|
"lint": "tsc --noEmit",
|
|
56
|
-
"test": "jest",
|
|
57
|
-
"test:watch": "jest --watch",
|
|
66
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
67
|
+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch",
|
|
58
68
|
"dev:test-app": "npm run build && cd test-app && npm run dev",
|
|
59
69
|
"build:test-app": "npm run build && cd test-app && npm run build",
|
|
60
70
|
"install:test-app": "cd test-app && npm install",
|
|
61
71
|
"prepublishOnly": "npm run build"
|
|
62
72
|
},
|
|
63
73
|
"dependencies": {
|
|
64
|
-
"undici": "^7.3.0"
|
|
74
|
+
"undici": "^7.3.0",
|
|
75
|
+
"wink-nlp": "^2.3.1",
|
|
76
|
+
"wink-eng-lite-web-model": "^1.7.0"
|
|
65
77
|
},
|
|
66
78
|
"peerDependencies": {
|
|
67
|
-
"hazo_core": "^1.
|
|
68
|
-
"hazo_logs": "^2.
|
|
69
|
-
"hazo_connect": "^3.
|
|
70
|
-
"hazo_jobs": "^0.
|
|
71
|
-
"hazo_files": "^3.
|
|
72
|
-
"hazo_ui": "^
|
|
79
|
+
"hazo_core": "^1.2.1",
|
|
80
|
+
"hazo_logs": "^2.1.1",
|
|
81
|
+
"hazo_connect": "^3.9.2",
|
|
82
|
+
"hazo_jobs": "^0.15.0",
|
|
83
|
+
"hazo_files": "^3.1.1",
|
|
84
|
+
"hazo_ui": "^6.1.0"
|
|
73
85
|
},
|
|
74
86
|
"peerDependenciesMeta": {
|
|
75
87
|
"hazo_logs": {
|
|
@@ -91,17 +103,19 @@
|
|
|
91
103
|
"devDependencies": {
|
|
92
104
|
"@types/jest": "^30.0.0",
|
|
93
105
|
"@types/node": "^22.10.0",
|
|
94
|
-
"hazo_core": "^1.
|
|
95
|
-
"hazo_logs": "^2.
|
|
96
|
-
"hazo_connect": "^3.
|
|
97
|
-
"hazo_jobs": "^0.
|
|
98
|
-
"hazo_files": "^3.
|
|
99
|
-
"hazo_ui": "^
|
|
106
|
+
"hazo_core": "^1.2.1",
|
|
107
|
+
"hazo_logs": "^2.1.1",
|
|
108
|
+
"hazo_connect": "^3.9.2",
|
|
109
|
+
"hazo_jobs": "^0.15.0",
|
|
110
|
+
"hazo_files": "^3.1.1",
|
|
111
|
+
"hazo_ui": "^6.1.0",
|
|
100
112
|
"jest": "^30.2.0",
|
|
101
113
|
"ts-jest": "^29.4.5",
|
|
102
114
|
"tsup": "^8.5.1",
|
|
103
115
|
"typescript": "^5.7.2",
|
|
104
|
-
"
|
|
116
|
+
"wink-nlp": "^2.3.1",
|
|
117
|
+
"wink-eng-lite-web-model": "^1.7.0",
|
|
118
|
+
"zod": "^4.1.12"
|
|
105
119
|
},
|
|
106
120
|
"author": "Pubs Abayasiri",
|
|
107
121
|
"license": "MIT"
|