@heavybit/pendoadmin-shared-lib 1.6.0 → 1.7.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/dist/common/gateway-signature.d.ts +49 -0
- package/dist/common/gateway-signature.d.ts.map +1 -0
- package/dist/common/gateway-signature.js +99 -0
- package/dist/common/gateway-signature.js.map +1 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +1 -0
- package/dist/common/index.js.map +1 -1
- package/dist/express/auth.middleware.d.ts +5 -4
- package/dist/express/auth.middleware.d.ts.map +1 -1
- package/dist/express/auth.middleware.js +49 -65
- package/dist/express/auth.middleware.js.map +1 -1
- package/dist/logging/index.d.ts +1 -0
- package/dist/logging/index.d.ts.map +1 -1
- package/dist/logging/index.js +5 -2
- package/dist/logging/index.js.map +1 -1
- package/dist/logging/redact.d.ts +16 -0
- package/dist/logging/redact.d.ts.map +1 -0
- package/dist/logging/redact.js +83 -0
- package/dist/logging/redact.js.map +1 -0
- package/dist/nestjs/auth.guard.d.ts +2 -0
- package/dist/nestjs/auth.guard.d.ts.map +1 -1
- package/dist/nestjs/auth.guard.js +11 -3
- package/dist/nestjs/auth.guard.js.map +1 -1
- package/dist/nestjs/permission.guard.js +5 -4
- package/dist/nestjs/permission.guard.js.map +1 -1
- package/dist/rate-limit/create-rate-limiter.d.ts +7 -1
- package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -1
- package/dist/rate-limit/create-rate-limiter.js +12 -2
- package/dist/rate-limit/create-rate-limiter.js.map +1 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.d.ts.map +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/index.js.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +2 -2
- package/src/common/gateway-signature.ts +124 -0
- package/src/common/index.ts +1 -0
- package/src/express/auth.middleware.ts +46 -64
- package/src/logging/index.ts +6 -2
- package/src/logging/redact.ts +84 -0
- package/src/nestjs/auth.guard.ts +12 -3
- package/src/nestjs/permission.guard.ts +5 -5
- package/src/rate-limit/create-rate-limiter.ts +13 -3
- package/src/rate-limit/index.ts +1 -1
- package/src/types/index.ts +2 -0
- package/tests/auth-middleware.test.mjs +91 -0
- package/tests/gateway-signature.test.mjs +93 -0
- package/tests/nestjs-guards.test.mjs +45 -0
- package/tests/rate-limit-key.test.mjs +25 -0
- package/tests/redact.test.mjs +61 -0
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
REQUIRE_ALL_KEY,
|
|
11
11
|
ROLES_KEY,
|
|
12
12
|
} from './permission.decorator.js';
|
|
13
|
-
import { IS_PUBLIC_KEY } from './auth.guard.js';
|
|
13
|
+
import { IS_PUBLIC_KEY, GATEWAY_VERIFIED_FLAG } from './auth.guard.js';
|
|
14
14
|
import type { IAuthUser } from '../types/index.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -40,10 +40,10 @@ export class PermissionGuard implements CanActivate {
|
|
|
40
40
|
if (user.isSuperAdmin) return true;
|
|
41
41
|
|
|
42
42
|
// M2M / gateway-authenticated clients: the gateway already verified the JWT
|
|
43
|
-
// and injected headers
|
|
44
|
-
// is overly restrictive
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// and signed the injected headers (AuthGuard checked the signature).
|
|
44
|
+
// Requiring human roles on top of scoped permissions is overly restrictive
|
|
45
|
+
// for API access.
|
|
46
|
+
const isGatewayAuth = request[GATEWAY_VERIFIED_FLAG] === true;
|
|
47
47
|
|
|
48
48
|
// Check permissions first — they are the primary authorization mechanism
|
|
49
49
|
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Request, Response, NextFunction } from "express";
|
|
7
7
|
import { RateLimiterRedis, RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
|
|
8
8
|
import type Redis from "ioredis";
|
|
9
|
+
import { isTrustedGatewayIdentity } from "../common/gateway-signature.js";
|
|
9
10
|
|
|
10
11
|
// ─── Types ──────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -18,7 +19,7 @@ export interface RateLimitConfig {
|
|
|
18
19
|
keyPrefix?: string;
|
|
19
20
|
/** Optional Redis client — falls back to in-memory if omitted */
|
|
20
21
|
redisClient?: Redis | null;
|
|
21
|
-
/** Extract the rate-limit key from the request. Default:
|
|
22
|
+
/** Extract the rate-limit key from the request. Default: signed gateway user id → IP → "unknown" */
|
|
22
23
|
keyExtractor?: (req: Request) => string;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -102,6 +103,15 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
|
|
|
102
103
|
|
|
103
104
|
// ─── Defaults ───────────────────────────────────────────
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The gateway user id only when its signature verifies — an unsigned
|
|
108
|
+
* x-user-id is caller-controlled, and a fresh value per request would get a
|
|
109
|
+
* fresh budget. Otherwise the client IP.
|
|
110
|
+
*/
|
|
111
|
+
export function defaultKeyExtractor(req: Request): string {
|
|
112
|
+
const userId = req.headers["x-user-id"];
|
|
113
|
+
if (typeof userId === "string" && userId && isTrustedGatewayIdentity(req.headers)) {
|
|
114
|
+
return `user:${userId}`;
|
|
115
|
+
}
|
|
116
|
+
return `ip:${req.ip || "unknown"}`;
|
|
107
117
|
}
|
package/src/rate-limit/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRateLimiter } from "./create-rate-limiter.js";
|
|
1
|
+
export { createRateLimiter, defaultKeyExtractor } from "./create-rate-limiter.js";
|
|
2
2
|
export type { RateLimitConfig, RateLimitResult } from "./create-rate-limiter.js";
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { authenticateFromGateway, optionalAuthFromGateway } from '../dist/express/index.js';
|
|
4
|
+
import { signGatewayIdentity, signAccessToken } from '../dist/common/index.js';
|
|
5
|
+
|
|
6
|
+
const SECRET = 'gateway-secret';
|
|
7
|
+
const JWT_SECRET = 'jwt-secret';
|
|
8
|
+
const identity = {
|
|
9
|
+
'x-user-id': 'user-1',
|
|
10
|
+
'x-company-id': 'comp-1',
|
|
11
|
+
'x-user-roles': '["admin"]',
|
|
12
|
+
'x-user-permissions': '["users:list"]',
|
|
13
|
+
'x-is-superadmin': 'false',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function run(middleware, headers) {
|
|
17
|
+
const req = { headers };
|
|
18
|
+
const res = { statusCode: 0, body: null, status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; return this; } };
|
|
19
|
+
let nextCalled = false;
|
|
20
|
+
middleware(req, res, () => { nextCalled = true; });
|
|
21
|
+
return { req, res, nextCalled };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('authenticateFromGateway', () => {
|
|
25
|
+
const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
|
|
26
|
+
beforeEach(() => { process.env.GATEWAY_SIGNING_SECRET = SECRET; process.env.NODE_ENV = 'production'; });
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
|
|
29
|
+
if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('accepts signed gateway headers and populates req.user', () => {
|
|
33
|
+
const headers = { ...identity, ...signGatewayIdentity(identity, SECRET) };
|
|
34
|
+
const { req, nextCalled } = run(authenticateFromGateway(JWT_SECRET), headers);
|
|
35
|
+
assert.equal(nextCalled, true);
|
|
36
|
+
assert.equal(req.user.id, 'user-1');
|
|
37
|
+
assert.equal(req.user.companyId, 'comp-1');
|
|
38
|
+
assert.deepEqual(req.user.permissions, ['users:list']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('rejects forged (unsigned) headers with 401', () => {
|
|
42
|
+
const { res, nextCalled } = run(authenticateFromGateway(JWT_SECRET), { ...identity, 'x-is-superadmin': 'true', 'x-gateway-auth': 'verified' });
|
|
43
|
+
assert.equal(nextCalled, false);
|
|
44
|
+
assert.equal(res.statusCode, 401);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('rejects a signed request whose headers were altered afterwards', () => {
|
|
48
|
+
const headers = { ...identity, ...signGatewayIdentity(identity, SECRET), 'x-is-superadmin': 'true' };
|
|
49
|
+
const { res } = run(authenticateFromGateway(JWT_SECRET), headers);
|
|
50
|
+
assert.equal(res.statusCode, 401);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('rejects an expired signature', () => {
|
|
54
|
+
const headers = { ...identity, ...signGatewayIdentity(identity, SECRET, Date.now() - 5 * 60_000) };
|
|
55
|
+
const { res } = run(authenticateFromGateway(JWT_SECRET), headers);
|
|
56
|
+
assert.equal(res.statusCode, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('ignores the direct-JWT fallback in production', () => {
|
|
60
|
+
const token = signAccessToken({ sub: 'user-1', email: 'a@b.c', roles: [], permissions: [] }, JWT_SECRET);
|
|
61
|
+
const { res } = run(authenticateFromGateway(JWT_SECRET), { authorization: `Bearer ${token}` });
|
|
62
|
+
assert.equal(res.statusCode, 401);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('allows the direct-JWT fallback outside production', () => {
|
|
66
|
+
process.env.NODE_ENV = 'development';
|
|
67
|
+
const token = signAccessToken({ sub: 'user-9', email: 'a@b.c', roles: [], permissions: [] }, JWT_SECRET);
|
|
68
|
+
const { req, nextCalled } = run(authenticateFromGateway(JWT_SECRET), { authorization: `Bearer ${token}` });
|
|
69
|
+
assert.equal(nextCalled, true);
|
|
70
|
+
assert.equal(req.user.id, 'user-9');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('production without a signing secret fails closed', () => {
|
|
74
|
+
delete process.env.GATEWAY_SIGNING_SECRET;
|
|
75
|
+
const { res } = run(authenticateFromGateway(JWT_SECRET), identity);
|
|
76
|
+
assert.equal(res.statusCode, 401);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('optionalAuthFromGateway', () => {
|
|
81
|
+
test('leaves forged headers anonymous', () => {
|
|
82
|
+
process.env.GATEWAY_SIGNING_SECRET = SECRET;
|
|
83
|
+
try {
|
|
84
|
+
const { req, nextCalled } = run(optionalAuthFromGateway(), { ...identity });
|
|
85
|
+
assert.equal(nextCalled, true);
|
|
86
|
+
assert.equal(req.user, undefined);
|
|
87
|
+
} finally {
|
|
88
|
+
delete process.env.GATEWAY_SIGNING_SECRET;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { signGatewayIdentity, verifyGatewayIdentity, isTrustedGatewayIdentity } from '../dist/common/index.js';
|
|
4
|
+
|
|
5
|
+
// Shared with api-gateway/tests/utils/gateway-signature.test.ts: the gateway's
|
|
6
|
+
// own signer must produce this exact signature.
|
|
7
|
+
const VECTOR = {
|
|
8
|
+
secret: 'test-signing-secret',
|
|
9
|
+
nowMs: 1_790_000_000_000,
|
|
10
|
+
headers: {
|
|
11
|
+
'x-correlation-id': 'corr-1',
|
|
12
|
+
'x-user-id': 'user-1',
|
|
13
|
+
'x-user-email': 'alice@example.com',
|
|
14
|
+
'x-company-id': 'comp-1',
|
|
15
|
+
'x-company-slug': 'acme',
|
|
16
|
+
'x-user-roles': '["admin"]',
|
|
17
|
+
'x-user-permissions': '["users:list"]',
|
|
18
|
+
'x-is-superadmin': 'false',
|
|
19
|
+
'x-otp-verified': 'true',
|
|
20
|
+
},
|
|
21
|
+
signature: '681c8ff3354db858c618a257601cced23ed694f824db116eb200c724470fe55d',
|
|
22
|
+
timestamp: '1790000000',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const signed = (overrides = {}) => ({
|
|
26
|
+
...VECTOR.headers,
|
|
27
|
+
'x-gateway-signature': VECTOR.signature,
|
|
28
|
+
'x-gateway-ts': VECTOR.timestamp,
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
const verify = (headers, opts = {}) => verifyGatewayIdentity(headers, VECTOR.secret, { nowMs: VECTOR.nowMs, ...opts });
|
|
32
|
+
|
|
33
|
+
describe('gateway signature', () => {
|
|
34
|
+
test('matches the api-gateway signer test vector', () => {
|
|
35
|
+
assert.deepEqual(signGatewayIdentity(VECTOR.headers, VECTOR.secret, VECTOR.nowMs), {
|
|
36
|
+
'x-gateway-signature': VECTOR.signature,
|
|
37
|
+
'x-gateway-ts': VECTOR.timestamp,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('accepts a valid, fresh signature', () => {
|
|
42
|
+
assert.equal(verify(signed()), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects a missing or malformed signature', () => {
|
|
46
|
+
assert.equal(verify(VECTOR.headers), false);
|
|
47
|
+
assert.equal(verify(signed({ 'x-gateway-signature': 'nothex' })), false);
|
|
48
|
+
assert.equal(verify(signed({ 'x-gateway-ts': 'soon' })), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rejects tampered identity headers', () => {
|
|
52
|
+
assert.equal(verify(signed({ 'x-is-superadmin': 'true' })), false);
|
|
53
|
+
assert.equal(verify(signed({ 'x-company-id': 'other-company' })), false);
|
|
54
|
+
assert.equal(verify(signed({ 'x-user-permissions': '["*"]' })), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rejects a timestamp outside the ±60 s window', () => {
|
|
58
|
+
assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 61_000 }), false);
|
|
59
|
+
assert.equal(verify(signed(), { nowMs: VECTOR.nowMs - 61_000 }), false);
|
|
60
|
+
assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 59_000 }), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('rejects a signature made with a different secret, or an empty secret', () => {
|
|
64
|
+
assert.equal(verifyGatewayIdentity(signed(), 'other-secret', { nowMs: VECTOR.nowMs }), false);
|
|
65
|
+
assert.equal(verifyGatewayIdentity(signed(), '', { nowMs: VECTOR.nowMs }), false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('isTrustedGatewayIdentity', () => {
|
|
70
|
+
const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
|
|
71
|
+
beforeEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
|
|
74
|
+
if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('with a secret configured, requires a valid signature', () => {
|
|
78
|
+
process.env.GATEWAY_SIGNING_SECRET = VECTOR.secret;
|
|
79
|
+
const fresh = { ...VECTOR.headers, ...signGatewayIdentity(VECTOR.headers, VECTOR.secret) };
|
|
80
|
+
assert.equal(isTrustedGatewayIdentity(fresh), true);
|
|
81
|
+
assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('without a secret, never trusts unsigned identity in production', () => {
|
|
85
|
+
process.env.NODE_ENV = 'production';
|
|
86
|
+
assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('without a secret, trusts unsigned identity in local development', () => {
|
|
90
|
+
process.env.NODE_ENV = 'development';
|
|
91
|
+
assert.equal(isTrustedGatewayIdentity(VECTOR.headers), true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { AuthGuard, PermissionGuard } from '../dist/nestjs/index.js';
|
|
5
|
+
import { signGatewayIdentity } from '../dist/common/index.js';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'gateway-secret';
|
|
8
|
+
const identity = { 'x-user-id': 'key-1', 'x-company-id': 'comp-1', 'x-user-roles': '["api-client"]', 'x-user-permissions': '["ussd.sessions:list"]', 'x-is-superadmin': 'false' };
|
|
9
|
+
|
|
10
|
+
const reflector = (values = {}) => ({ getAllAndOverride: (key) => values[key] });
|
|
11
|
+
const contextFor = (request) => ({
|
|
12
|
+
getHandler: () => null,
|
|
13
|
+
getClass: () => null,
|
|
14
|
+
switchToHttp: () => ({ getRequest: () => request }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('NestJS AuthGuard + PermissionGuard', () => {
|
|
18
|
+
const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
|
|
19
|
+
beforeEach(() => { process.env.GATEWAY_SIGNING_SECRET = SECRET; process.env.NODE_ENV = 'production'; });
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
|
|
22
|
+
if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('AuthGuard trusts signed gateway headers', async () => {
|
|
26
|
+
const request = { headers: { ...identity, ...signGatewayIdentity(identity, SECRET) } };
|
|
27
|
+
assert.equal(await new AuthGuard(reflector()).canActivate(contextFor(request)), true);
|
|
28
|
+
assert.equal(request.user.id, 'key-1');
|
|
29
|
+
assert.equal(request.gatewayVerified, true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('AuthGuard rejects forged superadmin headers', async () => {
|
|
33
|
+
const request = { headers: { ...identity, 'x-is-superadmin': 'true', 'x-gateway-auth': 'verified' } };
|
|
34
|
+
await assert.rejects(new AuthGuard(reflector()).canActivate(contextFor(request)), { status: 401 });
|
|
35
|
+
assert.equal(request.user, undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('PermissionGuard role bypass needs a verified signature, not the x-gateway-auth header', () => {
|
|
39
|
+
const guard = new PermissionGuard(reflector({ permissions: ['ussd.sessions:list'], roles: ['company_admin'] }));
|
|
40
|
+
const user = { id: 'key-1', roles: ['api-client'], permissions: ['ussd.sessions:list'], isSuperAdmin: false };
|
|
41
|
+
|
|
42
|
+
assert.throws(() => guard.canActivate(contextFor({ user, headers: { 'x-gateway-auth': 'verified' } })), { status: 403 });
|
|
43
|
+
assert.equal(guard.canActivate(contextFor({ user, headers: {}, gatewayVerified: true })), true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, describe, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { defaultKeyExtractor } from '../dist/rate-limit/index.js';
|
|
4
|
+
import { signGatewayIdentity } from '../dist/common/index.js';
|
|
5
|
+
|
|
6
|
+
describe('defaultKeyExtractor', () => {
|
|
7
|
+
afterEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
|
|
8
|
+
|
|
9
|
+
test('ignores an unsigned, caller-supplied x-user-id when a signing secret is set', () => {
|
|
10
|
+
process.env.GATEWAY_SIGNING_SECRET = 's';
|
|
11
|
+
assert.equal(defaultKeyExtractor({ ip: '81.2.69.160', headers: { 'x-user-id': 'rotating-1' } }), 'ip:81.2.69.160');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('keys on the user id when the gateway signature verifies', () => {
|
|
15
|
+
process.env.GATEWAY_SIGNING_SECRET = 's';
|
|
16
|
+
const identity = { 'x-user-id': 'u1' };
|
|
17
|
+
const headers = { ...identity, ...signGatewayIdentity(identity, 's') };
|
|
18
|
+
assert.equal(defaultKeyExtractor({ ip: '10.0.0.1', headers }), 'user:u1');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('falls back to "unknown" with no IP', () => {
|
|
22
|
+
process.env.GATEWAY_SIGNING_SECRET = 's';
|
|
23
|
+
assert.equal(defaultKeyExtractor({ headers: {} }), 'ip:unknown');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { redactSensitive, redactFormat, isSensitiveKey, REDACTED } from '../dist/logging/index.js';
|
|
4
|
+
|
|
5
|
+
describe('redactSensitive', () => {
|
|
6
|
+
test('redacts credential keys at any depth, case- and separator-insensitively', () => {
|
|
7
|
+
const out = redactSensitive({
|
|
8
|
+
headers: { Authorization: 'Bearer abc.def', cookie: 'sid=1', 'x-internal-service-secret': 's' },
|
|
9
|
+
body: { email: 'a@b.c', password: 'hunter2', otp: '123456', pin: '0000' },
|
|
10
|
+
client_secret: 'cs', apiKey: 'k', smtpPassword: 'p', encryptionKey: 'e',
|
|
11
|
+
accessToken: 't', refresh_token: 'r', captchaToken: 'c',
|
|
12
|
+
nested: [{ privateKey: '-----BEGIN' }],
|
|
13
|
+
});
|
|
14
|
+
assert.equal(out.headers.Authorization, REDACTED);
|
|
15
|
+
assert.equal(out.headers.cookie, REDACTED);
|
|
16
|
+
assert.equal(out.headers['x-internal-service-secret'], REDACTED);
|
|
17
|
+
assert.equal(out.body.email, 'a@b.c');
|
|
18
|
+
for (const key of ['password', 'otp', 'pin']) assert.equal(out.body[key], REDACTED);
|
|
19
|
+
for (const key of ['client_secret', 'apiKey', 'smtpPassword', 'encryptionKey', 'accessToken', 'refresh_token', 'captchaToken']) {
|
|
20
|
+
assert.equal(out[key], REDACTED, key);
|
|
21
|
+
}
|
|
22
|
+
assert.equal(out.nested[0].privateKey, REDACTED);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('keeps non-sensitive fields, including token counters', () => {
|
|
26
|
+
const out = redactSensitive({ userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
|
|
27
|
+
assert.deepEqual(out, { userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('masks bearer/basic credentials inside free text and error messages', () => {
|
|
31
|
+
assert.equal(redactSensitive('auth header was Bearer eyJhbGciOi.x.y'), `auth header was Bearer ${REDACTED}`);
|
|
32
|
+
const err = redactSensitive(new Error('upstream rejected Basic dXNlcjpwYXNz'));
|
|
33
|
+
assert.equal(err.message, `upstream rejected Basic ${REDACTED}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('survives cycles without throwing', () => {
|
|
37
|
+
const a = { name: 'a' };
|
|
38
|
+
a.self = a;
|
|
39
|
+
assert.equal(redactSensitive(a).self, '[Circular]');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('isSensitiveKey', () => {
|
|
43
|
+
assert.equal(isSensitiveKey('X-Gateway-Signature'), true);
|
|
44
|
+
assert.equal(isSensitiveKey('correlationId'), false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('redactFormat', () => {
|
|
49
|
+
test('redacts log entry metadata and message', () => {
|
|
50
|
+
const info = redactFormat().transform({
|
|
51
|
+
level: 'error',
|
|
52
|
+
message: 'login failed for Bearer abc',
|
|
53
|
+
password: 'p',
|
|
54
|
+
request: { headers: { authorization: 'Bearer abc' } },
|
|
55
|
+
});
|
|
56
|
+
assert.equal(info.password, REDACTED);
|
|
57
|
+
assert.equal(info.request.headers.authorization, REDACTED);
|
|
58
|
+
assert.equal(info.message, `login failed for Bearer ${REDACTED}`);
|
|
59
|
+
assert.equal(info.level, 'error');
|
|
60
|
+
});
|
|
61
|
+
});
|