@sentinel-atl/hardening 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/dist/audit-rotation.d.ts +33 -0
- package/dist/audit-rotation.d.ts.map +1 -0
- package/dist/audit-rotation.js +120 -0
- package/dist/audit-rotation.js.map +1 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +117 -0
- package/dist/auth.js.map +1 -0
- package/dist/cors.d.ts +34 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +86 -0
- package/dist/cors.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/nonce-store.d.ts +50 -0
- package/dist/nonce-store.d.ts.map +1 -0
- package/dist/nonce-store.js +88 -0
- package/dist/nonce-store.js.map +1 -0
- package/dist/rate-limit.d.ts +55 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +116 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/security-headers.d.ts +36 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +48 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/tls.d.ts +33 -0
- package/dist/tls.d.ts.map +1 -0
- package/dist/tls.js +41 -0
- package/dist/tls.js.map +1 -0
- package/package.json +43 -0
- package/src/__tests__/hardening.test.ts +472 -0
- package/src/audit-rotation.ts +149 -0
- package/src/auth.ts +162 -0
- package/src/cors.ts +118 -0
- package/src/index.ts +62 -0
- package/src/nonce-store.ts +111 -0
- package/src/rate-limit.ts +141 -0
- package/src/security-headers.ts +79 -0
- package/src/tls.ts +66 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { mkdtemp, writeFile, readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import {
|
|
8
|
+
authenticate, hasScope, authConfigFromEnv, sendUnauthorized, sendForbidden,
|
|
9
|
+
type AuthConfig,
|
|
10
|
+
} from '../auth.js';
|
|
11
|
+
import {
|
|
12
|
+
applyCors, defaultCorsConfig, corsConfigFromEnv,
|
|
13
|
+
type CorsConfig,
|
|
14
|
+
} from '../cors.js';
|
|
15
|
+
import {
|
|
16
|
+
RateLimiter, setRateLimitHeaders, sendRateLimited, parseRateLimit,
|
|
17
|
+
} from '../rate-limit.js';
|
|
18
|
+
import {
|
|
19
|
+
rotateIfNeeded, cleanupRotatedFiles,
|
|
20
|
+
} from '../audit-rotation.js';
|
|
21
|
+
import {
|
|
22
|
+
applySecurityHeaders,
|
|
23
|
+
} from '../security-headers.js';
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function mockReq(opts: {
|
|
28
|
+
method?: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
}): IncomingMessage {
|
|
32
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
33
|
+
req.method = opts.method ?? 'GET';
|
|
34
|
+
req.url = opts.url ?? '/';
|
|
35
|
+
req.headers = opts.headers ?? {};
|
|
36
|
+
return req;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockRes(): ServerResponse & { _status?: number; _headers: Record<string, string>; _body: string } {
|
|
40
|
+
const res = new EventEmitter() as any;
|
|
41
|
+
res._headers = {};
|
|
42
|
+
res._body = '';
|
|
43
|
+
res._status = undefined;
|
|
44
|
+
res.setHeader = (key: string, val: string) => { res._headers[key.toLowerCase()] = val; };
|
|
45
|
+
res.getHeader = (key: string) => res._headers[key.toLowerCase()];
|
|
46
|
+
res.writeHead = (status: number, headers?: Record<string, string>) => {
|
|
47
|
+
res._status = status;
|
|
48
|
+
if (headers) {
|
|
49
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
50
|
+
res._headers[k.toLowerCase()] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
res.write = (data: string) => { res._body += data; };
|
|
55
|
+
res.end = (data?: string) => { if (data) res._body += data; };
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
60
|
+
// Auth
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
describe('authenticate', () => {
|
|
64
|
+
const config: AuthConfig = {
|
|
65
|
+
enabled: true,
|
|
66
|
+
keys: [
|
|
67
|
+
{ key: 'test-key-read', scopes: ['read'], label: 'reader' },
|
|
68
|
+
{ key: 'test-key-admin', scopes: ['admin'], label: 'admin' },
|
|
69
|
+
{ key: 'test-key-rw', scopes: ['read', 'write'], label: 'readwrite' },
|
|
70
|
+
],
|
|
71
|
+
publicPaths: ['/health', '/badge'],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it('allows requests when auth is disabled', () => {
|
|
75
|
+
const req = mockReq({ headers: {} });
|
|
76
|
+
const result = authenticate(req, { enabled: false, keys: [] });
|
|
77
|
+
expect(result.authenticated).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('allows public paths without key', () => {
|
|
81
|
+
const req = mockReq({ url: '/health' });
|
|
82
|
+
const result = authenticate(req, config);
|
|
83
|
+
expect(result.authenticated).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects missing key on protected path', () => {
|
|
87
|
+
const req = mockReq({ url: '/api/certificates' });
|
|
88
|
+
const result = authenticate(req, config);
|
|
89
|
+
expect(result.authenticated).toBe(false);
|
|
90
|
+
expect(result.error).toContain('Missing');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('authenticates via Bearer token', () => {
|
|
94
|
+
const req = mockReq({
|
|
95
|
+
url: '/api/data',
|
|
96
|
+
headers: { authorization: 'Bearer test-key-read' },
|
|
97
|
+
});
|
|
98
|
+
const result = authenticate(req, config);
|
|
99
|
+
expect(result.authenticated).toBe(true);
|
|
100
|
+
expect(result.key?.label).toBe('reader');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('authenticates via X-API-Key header', () => {
|
|
104
|
+
const req = mockReq({
|
|
105
|
+
url: '/api/data',
|
|
106
|
+
headers: { 'x-api-key': 'test-key-admin' },
|
|
107
|
+
});
|
|
108
|
+
const result = authenticate(req, config);
|
|
109
|
+
expect(result.authenticated).toBe(true);
|
|
110
|
+
expect(result.key?.scopes).toContain('admin');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('authenticates via query param', () => {
|
|
114
|
+
const req = mockReq({ url: '/api/data?apiKey=test-key-rw' });
|
|
115
|
+
const result = authenticate(req, config);
|
|
116
|
+
expect(result.authenticated).toBe(true);
|
|
117
|
+
expect(result.key?.scopes).toEqual(['read', 'write']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects invalid key', () => {
|
|
121
|
+
const req = mockReq({
|
|
122
|
+
url: '/api/data',
|
|
123
|
+
headers: { 'x-api-key': 'wrong-key' },
|
|
124
|
+
});
|
|
125
|
+
const result = authenticate(req, config);
|
|
126
|
+
expect(result.authenticated).toBe(false);
|
|
127
|
+
expect(result.error).toContain('Invalid');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('hasScope', () => {
|
|
132
|
+
it('returns true for admin on any scope', () => {
|
|
133
|
+
expect(hasScope({ authenticated: true, key: { key: 'k', scopes: ['admin'] } }, 'write')).toBe(true);
|
|
134
|
+
expect(hasScope({ authenticated: true, key: { key: 'k', scopes: ['admin'] } }, 'read')).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns false when scope missing', () => {
|
|
138
|
+
expect(hasScope({ authenticated: true, key: { key: 'k', scopes: ['read'] } }, 'write')).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns true for matching scope', () => {
|
|
142
|
+
expect(hasScope({ authenticated: true, key: { key: 'k', scopes: ['read', 'write'] } }, 'write')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns true when auth disabled (no key)', () => {
|
|
146
|
+
expect(hasScope({ authenticated: true }, 'admin')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false when not authenticated', () => {
|
|
150
|
+
expect(hasScope({ authenticated: false }, 'read')).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('sendUnauthorized / sendForbidden', () => {
|
|
155
|
+
it('sends 401 with WWW-Authenticate', () => {
|
|
156
|
+
const res = mockRes();
|
|
157
|
+
sendUnauthorized(res, { enabled: true, keys: [], realm: 'TestRealm' }, 'Bad key');
|
|
158
|
+
expect(res._status).toBe(401);
|
|
159
|
+
expect(res._headers['www-authenticate']).toContain('TestRealm');
|
|
160
|
+
expect(res._body).toContain('Bad key');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('sends 403 for forbidden', () => {
|
|
164
|
+
const res = mockRes();
|
|
165
|
+
sendForbidden(res, 'No write access');
|
|
166
|
+
expect(res._status).toBe(403);
|
|
167
|
+
expect(res._body).toContain('No write access');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('authConfigFromEnv', () => {
|
|
172
|
+
afterEach(() => { delete process.env['SENTINEL_API_KEYS']; });
|
|
173
|
+
|
|
174
|
+
it('returns disabled when env not set', () => {
|
|
175
|
+
const cfg = authConfigFromEnv();
|
|
176
|
+
expect(cfg.enabled).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('parses keys from env', () => {
|
|
180
|
+
process.env['SENTINEL_API_KEYS'] = 'mykey:read,write;admin-key:admin';
|
|
181
|
+
const cfg = authConfigFromEnv();
|
|
182
|
+
expect(cfg.enabled).toBe(true);
|
|
183
|
+
expect(cfg.keys).toHaveLength(2);
|
|
184
|
+
expect(cfg.keys[0].key).toBe('mykey');
|
|
185
|
+
expect(cfg.keys[0].scopes).toEqual(['read', 'write']);
|
|
186
|
+
expect(cfg.keys[1].scopes).toEqual(['admin']);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
191
|
+
// CORS
|
|
192
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
describe('applyCors', () => {
|
|
195
|
+
const strictConfig: CorsConfig = {
|
|
196
|
+
allowedOrigins: ['https://app.example.com'],
|
|
197
|
+
allowedMethods: ['GET', 'POST'],
|
|
198
|
+
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
199
|
+
maxAge: 3600,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
it('allows matching origin', () => {
|
|
203
|
+
const req = mockReq({ headers: { origin: 'https://app.example.com' } });
|
|
204
|
+
const res = mockRes();
|
|
205
|
+
const isPreflight = applyCors(req, res, strictConfig);
|
|
206
|
+
expect(isPreflight).toBe(false);
|
|
207
|
+
expect(res._headers['access-control-allow-origin']).toBe('https://app.example.com');
|
|
208
|
+
expect(res._headers['vary']).toBe('Origin');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('blocks non-matching origin', () => {
|
|
212
|
+
const req = mockReq({ headers: { origin: 'https://evil.com' } });
|
|
213
|
+
const res = mockRes();
|
|
214
|
+
applyCors(req, res, strictConfig);
|
|
215
|
+
expect(res._headers['access-control-allow-origin']).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('handles preflight with 204', () => {
|
|
219
|
+
const req = mockReq({ method: 'OPTIONS', headers: { origin: 'https://app.example.com' } });
|
|
220
|
+
const res = mockRes();
|
|
221
|
+
const isPreflight = applyCors(req, res, strictConfig);
|
|
222
|
+
expect(isPreflight).toBe(true);
|
|
223
|
+
expect(res._status).toBe(204);
|
|
224
|
+
expect(res._headers['access-control-allow-methods']).toBe('GET, POST');
|
|
225
|
+
expect(res._headers['access-control-max-age']).toBe('3600');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('wildcard mode sets * origin', () => {
|
|
229
|
+
const req = mockReq({ headers: { origin: 'https://anything.com' } });
|
|
230
|
+
const res = mockRes();
|
|
231
|
+
applyCors(req, res, defaultCorsConfig());
|
|
232
|
+
expect(res._headers['access-control-allow-origin']).toBe('*');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('same-origin request (no Origin header) sets no CORS header', () => {
|
|
236
|
+
const req = mockReq({ headers: {} });
|
|
237
|
+
const res = mockRes();
|
|
238
|
+
applyCors(req, res, strictConfig);
|
|
239
|
+
expect(res._headers['access-control-allow-origin']).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('corsConfigFromEnv', () => {
|
|
244
|
+
afterEach(() => { delete process.env['SENTINEL_CORS_ORIGINS']; });
|
|
245
|
+
|
|
246
|
+
it('defaults to wildcard', () => {
|
|
247
|
+
const cfg = corsConfigFromEnv();
|
|
248
|
+
expect(cfg.allowedOrigins).toEqual(['*']);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('parses origins from env', () => {
|
|
252
|
+
process.env['SENTINEL_CORS_ORIGINS'] = 'https://a.com, https://b.com';
|
|
253
|
+
const cfg = corsConfigFromEnv();
|
|
254
|
+
expect(cfg.allowedOrigins).toEqual(['https://a.com', 'https://b.com']);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
259
|
+
// Rate Limiter
|
|
260
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
261
|
+
|
|
262
|
+
describe('RateLimiter', () => {
|
|
263
|
+
it('allows requests within limit', () => {
|
|
264
|
+
const limiter = new RateLimiter(3, 60_000);
|
|
265
|
+
const r1 = limiter.check('user-1');
|
|
266
|
+
const r2 = limiter.check('user-1');
|
|
267
|
+
const r3 = limiter.check('user-1');
|
|
268
|
+
expect(r1.allowed).toBe(true);
|
|
269
|
+
expect(r2.allowed).toBe(true);
|
|
270
|
+
expect(r3.allowed).toBe(true);
|
|
271
|
+
expect(r3.info.remaining).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('blocks when limit exceeded', () => {
|
|
275
|
+
const limiter = new RateLimiter(2, 60_000);
|
|
276
|
+
limiter.check('user-1');
|
|
277
|
+
limiter.check('user-1');
|
|
278
|
+
const r3 = limiter.check('user-1');
|
|
279
|
+
expect(r3.allowed).toBe(false);
|
|
280
|
+
expect(r3.info.remaining).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('tracks different keys independently', () => {
|
|
284
|
+
const limiter = new RateLimiter(1, 60_000);
|
|
285
|
+
expect(limiter.check('a').allowed).toBe(true);
|
|
286
|
+
expect(limiter.check('b').allowed).toBe(true);
|
|
287
|
+
expect(limiter.check('a').allowed).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('cleanup removes expired windows', () => {
|
|
291
|
+
const limiter = new RateLimiter(1, 1); // 1ms window
|
|
292
|
+
limiter.check('expired-key');
|
|
293
|
+
// Wait for expiry
|
|
294
|
+
const start = Date.now();
|
|
295
|
+
while (Date.now() - start < 5) { /* spin */ }
|
|
296
|
+
const removed = limiter.cleanup();
|
|
297
|
+
expect(removed).toBeGreaterThanOrEqual(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('provides rate limit info with correct fields', () => {
|
|
301
|
+
const limiter = new RateLimiter(10, 60_000);
|
|
302
|
+
const result = limiter.check('test');
|
|
303
|
+
expect(result.info.limit).toBe(10);
|
|
304
|
+
expect(result.info.remaining).toBe(9);
|
|
305
|
+
expect(result.info.resetAt).toBeGreaterThan(0);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('setRateLimitHeaders', () => {
|
|
310
|
+
it('sets standard rate limit headers', () => {
|
|
311
|
+
const res = mockRes();
|
|
312
|
+
setRateLimitHeaders(res, {
|
|
313
|
+
limit: 100,
|
|
314
|
+
remaining: 50,
|
|
315
|
+
resetAt: Math.ceil(Date.now() / 1000) + 30,
|
|
316
|
+
});
|
|
317
|
+
expect(res._headers['ratelimit-limit']).toBe('100');
|
|
318
|
+
expect(res._headers['ratelimit-remaining']).toBe('50');
|
|
319
|
+
expect(res._headers['ratelimit-reset']).toBeDefined();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('sendRateLimited', () => {
|
|
324
|
+
it('sends 429 with Retry-After', () => {
|
|
325
|
+
const res = mockRes();
|
|
326
|
+
sendRateLimited(res, {
|
|
327
|
+
limit: 100,
|
|
328
|
+
remaining: 0,
|
|
329
|
+
resetAt: Math.ceil(Date.now() / 1000) + 60,
|
|
330
|
+
});
|
|
331
|
+
expect(res._status).toBe(429);
|
|
332
|
+
expect(res._headers['retry-after']).toBeDefined();
|
|
333
|
+
expect(parseInt(res._headers['retry-after'])).toBeGreaterThan(0);
|
|
334
|
+
expect(res._body).toContain('Too Many Requests');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('parseRateLimit', () => {
|
|
339
|
+
it('parses "100/min"', () => {
|
|
340
|
+
const { max, windowMs } = parseRateLimit('100/min');
|
|
341
|
+
expect(max).toBe(100);
|
|
342
|
+
expect(windowMs).toBe(60_000);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('parses "500/hour"', () => {
|
|
346
|
+
const { max, windowMs } = parseRateLimit('500/hour');
|
|
347
|
+
expect(max).toBe(500);
|
|
348
|
+
expect(windowMs).toBe(3_600_000);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('parses "1000/day"', () => {
|
|
352
|
+
const { max, windowMs } = parseRateLimit('1000/day');
|
|
353
|
+
expect(max).toBe(1000);
|
|
354
|
+
expect(windowMs).toBe(86_400_000);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('returns defaults for invalid spec', () => {
|
|
358
|
+
const { max, windowMs } = parseRateLimit('invalid');
|
|
359
|
+
expect(max).toBe(100);
|
|
360
|
+
expect(windowMs).toBe(60_000);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
365
|
+
// Audit Rotation
|
|
366
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
367
|
+
|
|
368
|
+
describe('rotateIfNeeded', () => {
|
|
369
|
+
let tmpDir: string;
|
|
370
|
+
|
|
371
|
+
beforeEach(async () => {
|
|
372
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'sentinel-rotation-'));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('does not rotate when file is below size limit', async () => {
|
|
376
|
+
const logPath = join(tmpDir, 'test.jsonl');
|
|
377
|
+
await writeFile(logPath, 'small content');
|
|
378
|
+
const rotated = await rotateIfNeeded({ logPath, maxSizeBytes: 1_000_000 });
|
|
379
|
+
expect(rotated).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('rotates when file exceeds size limit', async () => {
|
|
383
|
+
const logPath = join(tmpDir, 'test.jsonl');
|
|
384
|
+
await writeFile(logPath, 'x'.repeat(200));
|
|
385
|
+
const rotated = await rotateIfNeeded({ logPath, maxSizeBytes: 100, maxFiles: 3 });
|
|
386
|
+
expect(rotated).toBe(true);
|
|
387
|
+
// Original moved to .1
|
|
388
|
+
const content = await readFile(logPath + '.1', 'utf-8');
|
|
389
|
+
expect(content).toBe('x'.repeat(200));
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('shifts existing rotated files', async () => {
|
|
393
|
+
const logPath = join(tmpDir, 'test.jsonl');
|
|
394
|
+
await writeFile(logPath + '.1', 'old rotation 1');
|
|
395
|
+
await writeFile(logPath, 'x'.repeat(200));
|
|
396
|
+
const rotated = await rotateIfNeeded({ logPath, maxSizeBytes: 100, maxFiles: 5 });
|
|
397
|
+
expect(rotated).toBe(true);
|
|
398
|
+
const shifted = await readFile(logPath + '.2', 'utf-8');
|
|
399
|
+
expect(shifted).toBe('old rotation 1');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('returns false when file does not exist', async () => {
|
|
403
|
+
const rotated = await rotateIfNeeded({ logPath: join(tmpDir, 'nope.jsonl') });
|
|
404
|
+
expect(rotated).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('cleanupRotatedFiles', () => {
|
|
409
|
+
let tmpDir: string;
|
|
410
|
+
|
|
411
|
+
beforeEach(async () => {
|
|
412
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'sentinel-cleanup-'));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('removes files beyond maxFiles', async () => {
|
|
416
|
+
const logPath = join(tmpDir, 'test.jsonl');
|
|
417
|
+
await writeFile(logPath + '.1', 'keep');
|
|
418
|
+
await writeFile(logPath + '.2', 'keep');
|
|
419
|
+
await writeFile(logPath + '.3', 'remove');
|
|
420
|
+
await writeFile(logPath + '.4', 'remove');
|
|
421
|
+
const removed = await cleanupRotatedFiles({ logPath, maxFiles: 2 });
|
|
422
|
+
expect(removed).toBe(2);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
427
|
+
// Security Headers
|
|
428
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
429
|
+
|
|
430
|
+
describe('applySecurityHeaders', () => {
|
|
431
|
+
it('sets standard security headers', () => {
|
|
432
|
+
const res = mockRes();
|
|
433
|
+
applySecurityHeaders(res);
|
|
434
|
+
expect(res._headers['x-content-type-options']).toBe('nosniff');
|
|
435
|
+
expect(res._headers['x-xss-protection']).toBe('0');
|
|
436
|
+
expect(res._headers['x-frame-options']).toBe('DENY');
|
|
437
|
+
expect(res._headers['content-security-policy']).toBe("default-src 'none'");
|
|
438
|
+
expect(res._headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
|
|
439
|
+
expect(res._headers['permissions-policy']).toContain('camera=()');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('does not set HSTS by default', () => {
|
|
443
|
+
const res = mockRes();
|
|
444
|
+
applySecurityHeaders(res);
|
|
445
|
+
expect(res._headers['strict-transport-security']).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('sets HSTS when enabled', () => {
|
|
449
|
+
const res = mockRes();
|
|
450
|
+
applySecurityHeaders(res, { hsts: true });
|
|
451
|
+
expect(res._headers['strict-transport-security']).toContain('max-age=31536000');
|
|
452
|
+
expect(res._headers['strict-transport-security']).toContain('includeSubDomains');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('allows custom HSTS max-age', () => {
|
|
456
|
+
const res = mockRes();
|
|
457
|
+
applySecurityHeaders(res, { hsts: true, hstsMaxAge: 86400 });
|
|
458
|
+
expect(res._headers['strict-transport-security']).toBe('max-age=86400; includeSubDomains');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('allows custom CSP', () => {
|
|
462
|
+
const res = mockRes();
|
|
463
|
+
applySecurityHeaders(res, { contentSecurityPolicy: "default-src 'self'" });
|
|
464
|
+
expect(res._headers['content-security-policy']).toBe("default-src 'self'");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('can disable X-Frame-Options', () => {
|
|
468
|
+
const res = mockRes();
|
|
469
|
+
applySecurityHeaders(res, { frameOptions: false });
|
|
470
|
+
expect(res._headers['x-frame-options']).toBeUndefined();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log rotation — prevents unbounded log file growth.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Size-based rotation: rotate when file exceeds maxSizeBytes
|
|
6
|
+
* - Time-based rotation: rotate daily/hourly
|
|
7
|
+
* - Retention: keep N rotated files, delete older ones
|
|
8
|
+
* - Compression-ready: rotated files get .N suffix (gzip can be added later)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { stat, rename, unlink, readdir } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { dirname, basename, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface RotationConfig {
|
|
18
|
+
/** Path to the active log file */
|
|
19
|
+
logPath: string;
|
|
20
|
+
/** Maximum size in bytes before rotation (default: 10MB) */
|
|
21
|
+
maxSizeBytes?: number;
|
|
22
|
+
/** Maximum number of rotated files to keep (default: 10) */
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
/** Rotation interval: 'size' | 'daily' | 'hourly' (default: 'size') */
|
|
25
|
+
interval?: 'size' | 'daily' | 'hourly';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Rotation ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if the log file needs rotation and rotate if so.
|
|
32
|
+
* Returns true if rotation occurred.
|
|
33
|
+
*/
|
|
34
|
+
export async function rotateIfNeeded(config: RotationConfig): Promise<boolean> {
|
|
35
|
+
const { logPath } = config;
|
|
36
|
+
const maxSize = config.maxSizeBytes ?? 10_485_760; // 10 MB
|
|
37
|
+
const maxFiles = config.maxFiles ?? 10;
|
|
38
|
+
|
|
39
|
+
if (!existsSync(logPath)) return false;
|
|
40
|
+
|
|
41
|
+
const interval = config.interval ?? 'size';
|
|
42
|
+
|
|
43
|
+
let shouldRotate = false;
|
|
44
|
+
|
|
45
|
+
if (interval === 'size') {
|
|
46
|
+
const fileStat = await stat(logPath);
|
|
47
|
+
shouldRotate = fileStat.size >= maxSize;
|
|
48
|
+
} else if (interval === 'daily' || interval === 'hourly') {
|
|
49
|
+
const fileStat = await stat(logPath);
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const fileTime = new Date(fileStat.mtime);
|
|
52
|
+
|
|
53
|
+
if (interval === 'daily') {
|
|
54
|
+
shouldRotate = now.toDateString() !== fileTime.toDateString();
|
|
55
|
+
} else {
|
|
56
|
+
shouldRotate = now.getHours() !== fileTime.getHours() ||
|
|
57
|
+
now.toDateString() !== fileTime.toDateString();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!shouldRotate) return false;
|
|
62
|
+
|
|
63
|
+
await rotate(logPath, maxFiles);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Perform the rotation: shift existing files and rename current log.
|
|
69
|
+
*
|
|
70
|
+
* log.jsonl → log.jsonl.1
|
|
71
|
+
* log.jsonl.1 → log.jsonl.2
|
|
72
|
+
* ...
|
|
73
|
+
* log.jsonl.N → deleted (if N > maxFiles)
|
|
74
|
+
*/
|
|
75
|
+
async function rotate(logPath: string, maxFiles: number): Promise<void> {
|
|
76
|
+
// Delete the oldest file if it exists
|
|
77
|
+
const oldest = `${logPath}.${maxFiles}`;
|
|
78
|
+
if (existsSync(oldest)) {
|
|
79
|
+
await unlink(oldest);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Shift existing rotated files
|
|
83
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
84
|
+
const from = `${logPath}.${i}`;
|
|
85
|
+
const to = `${logPath}.${i + 1}`;
|
|
86
|
+
if (existsSync(from)) {
|
|
87
|
+
await rename(from, to);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Rotate current log
|
|
92
|
+
await rename(logPath, `${logPath}.1`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clean up rotated files beyond the retention limit.
|
|
97
|
+
*/
|
|
98
|
+
export async function cleanupRotatedFiles(config: RotationConfig): Promise<number> {
|
|
99
|
+
const maxFiles = config.maxFiles ?? 10;
|
|
100
|
+
const dir = dirname(config.logPath);
|
|
101
|
+
const base = basename(config.logPath);
|
|
102
|
+
|
|
103
|
+
const files = await readdir(dir);
|
|
104
|
+
const rotatedFiles = files
|
|
105
|
+
.filter(f => f.startsWith(base + '.') && /\.\d+$/.test(f))
|
|
106
|
+
.sort((a, b) => {
|
|
107
|
+
const numA = parseInt(a.split('.').pop()!);
|
|
108
|
+
const numB = parseInt(b.split('.').pop()!);
|
|
109
|
+
return numA - numB;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let removed = 0;
|
|
113
|
+
for (const file of rotatedFiles) {
|
|
114
|
+
const num = parseInt(file.split('.').pop()!);
|
|
115
|
+
if (num > maxFiles) {
|
|
116
|
+
await unlink(join(dir, file));
|
|
117
|
+
removed++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return removed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get total size of all log files (active + rotated).
|
|
126
|
+
*/
|
|
127
|
+
export async function totalLogSize(config: RotationConfig): Promise<number> {
|
|
128
|
+
const dir = dirname(config.logPath);
|
|
129
|
+
const base = basename(config.logPath);
|
|
130
|
+
|
|
131
|
+
let total = 0;
|
|
132
|
+
|
|
133
|
+
if (existsSync(config.logPath)) {
|
|
134
|
+
total += (await stat(config.logPath)).size;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const files = await readdir(dir);
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
if (file.startsWith(base + '.') && /\.\d+$/.test(file)) {
|
|
141
|
+
total += (await stat(join(dir, file))).size;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Directory might not exist
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return total;
|
|
149
|
+
}
|