Skip to content

Commit 2341526

Browse files
feat(security): Add security event logging + input length validation
- Add security-logger.ts with audit trail for security events - Log: webhook requests, rate limits, signature failures, action execution - Log: body too large, invalid content type, config validation errors, cleanup - Add input length validation (1000 char SMS body, 20 char phone number) - Logs written to ~/.stackmemory/logs/security.log with 0600 permissions - IP addresses masked for privacy (keep first two octets only) - Auto-rotation at 10000 entries Completes security audit recommendations.
1 parent 8a82817 commit 2341526

3 files changed

Lines changed: 291 additions & 6 deletions

File tree

src/hooks/schemas.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { z } from 'zod';
7+
import { logConfigInvalid } from './security-logger.js';
78

89
// SMS/WhatsApp notification schemas
910
export const PromptOptionSchema = z.object({
@@ -111,11 +112,10 @@ export function parseConfigSafe<T>(
111112
if (result.success) {
112113
return result.data;
113114
}
114-
console.error(
115-
`[hooks] Invalid ${configName} config:`,
116-
result.error.issues
117-
.map((i) => `${i.path.join('.')}: ${i.message}`)
118-
.join(', ')
115+
const errors = result.error.issues.map(
116+
(i) => `${i.path.join('.')}: ${i.message}`
119117
);
118+
logConfigInvalid(configName, errors);
119+
console.error(`[hooks] Invalid ${configName} config:`, errors.join(', '));
120120
return defaultValue;
121121
}

src/hooks/security-logger.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* Security Event Logger for hooks
3+
* Logs security-relevant events for audit trail
4+
*/
5+
6+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'fs';
7+
import { join } from 'path';
8+
import { homedir } from 'os';
9+
import { ensureSecureDir } from './secure-fs.js';
10+
11+
const LOG_DIR = join(homedir(), '.stackmemory', 'logs');
12+
const SECURITY_LOG = join(LOG_DIR, 'security.log');
13+
const MAX_LOG_ENTRIES = 10000;
14+
15+
export type SecurityEventType =
16+
| 'auth_success'
17+
| 'auth_failure'
18+
| 'rate_limit'
19+
| 'action_allowed'
20+
| 'action_blocked'
21+
| 'config_invalid'
22+
| 'config_loaded'
23+
| 'webhook_request'
24+
| 'signature_invalid'
25+
| 'body_too_large'
26+
| 'content_type_invalid'
27+
| 'cleanup';
28+
29+
export interface SecurityEvent {
30+
timestamp: string;
31+
type: SecurityEventType;
32+
source: string;
33+
message: string;
34+
details?: Record<string, unknown>;
35+
ip?: string;
36+
}
37+
38+
let logCount = 0;
39+
40+
/**
41+
* Log a security event
42+
*/
43+
export function logSecurityEvent(
44+
type: SecurityEventType,
45+
source: string,
46+
message: string,
47+
details?: Record<string, unknown>,
48+
ip?: string
49+
): void {
50+
try {
51+
ensureSecureDir(LOG_DIR);
52+
53+
const event: SecurityEvent = {
54+
timestamp: new Date().toISOString(),
55+
type,
56+
source,
57+
message,
58+
...(details && { details }),
59+
...(ip && { ip: maskIp(ip) }),
60+
};
61+
62+
const logLine = JSON.stringify(event) + '\n';
63+
appendFileSync(SECURITY_LOG, logLine, { mode: 0o600 });
64+
65+
logCount++;
66+
67+
// Rotate log if too large (simple rotation - truncate)
68+
if (logCount > MAX_LOG_ENTRIES) {
69+
rotateLog();
70+
}
71+
} catch {
72+
// Don't let logging failures break the application
73+
}
74+
}
75+
76+
/**
77+
* Mask IP address for privacy (keep first two octets)
78+
*/
79+
function maskIp(ip: string): string {
80+
if (!ip) return 'unknown';
81+
82+
// Handle IPv6 localhost
83+
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.x';
84+
85+
// Handle IPv4
86+
const parts = ip.replace('::ffff:', '').split('.');
87+
if (parts.length === 4) {
88+
return `${parts[0]}.${parts[1]}.x.x`;
89+
}
90+
91+
// Handle IPv6 - mask last 64 bits
92+
if (ip.includes(':')) {
93+
const segments = ip.split(':');
94+
if (segments.length >= 4) {
95+
return segments.slice(0, 4).join(':') + ':x:x:x:x';
96+
}
97+
}
98+
99+
return 'masked';
100+
}
101+
102+
/**
103+
* Simple log rotation - keep last half of entries
104+
*/
105+
function rotateLog(): void {
106+
try {
107+
if (existsSync(SECURITY_LOG)) {
108+
const content = readFileSync(SECURITY_LOG, 'utf8');
109+
const lines = content.trim().split('\n');
110+
const keepLines = lines.slice(-MAX_LOG_ENTRIES / 2);
111+
writeFileSync(SECURITY_LOG, keepLines.join('\n') + '\n', { mode: 0o600 });
112+
logCount = keepLines.length;
113+
}
114+
} catch {
115+
// Ignore rotation errors
116+
}
117+
}
118+
119+
// Convenience functions for common events
120+
121+
export function logAuthSuccess(
122+
source: string,
123+
details?: Record<string, unknown>
124+
): void {
125+
logSecurityEvent(
126+
'auth_success',
127+
source,
128+
'Authentication successful',
129+
details
130+
);
131+
}
132+
133+
export function logAuthFailure(
134+
source: string,
135+
reason: string,
136+
ip?: string,
137+
details?: Record<string, unknown>
138+
): void {
139+
logSecurityEvent(
140+
'auth_failure',
141+
source,
142+
`Authentication failed: ${reason}`,
143+
details,
144+
ip
145+
);
146+
}
147+
148+
export function logRateLimit(source: string, ip: string): void {
149+
logSecurityEvent('rate_limit', source, 'Rate limit exceeded', undefined, ip);
150+
}
151+
152+
export function logActionAllowed(source: string, action: string): void {
153+
logSecurityEvent(
154+
'action_allowed',
155+
source,
156+
`Action executed: ${action.substring(0, 100)}`
157+
);
158+
}
159+
160+
export function logActionBlocked(
161+
source: string,
162+
action: string,
163+
reason: string
164+
): void {
165+
logSecurityEvent('action_blocked', source, `Action blocked: ${reason}`, {
166+
action: action.substring(0, 100),
167+
});
168+
}
169+
170+
export function logConfigInvalid(source: string, errors: string[]): void {
171+
logSecurityEvent('config_invalid', source, 'Invalid config rejected', {
172+
errors: errors.slice(0, 5),
173+
});
174+
}
175+
176+
export function logWebhookRequest(
177+
source: string,
178+
method: string,
179+
path: string,
180+
ip?: string
181+
): void {
182+
logSecurityEvent(
183+
'webhook_request',
184+
source,
185+
`${method} ${path}`,
186+
undefined,
187+
ip
188+
);
189+
}
190+
191+
export function logSignatureInvalid(source: string, ip?: string): void {
192+
logSecurityEvent(
193+
'signature_invalid',
194+
source,
195+
'Invalid request signature',
196+
undefined,
197+
ip
198+
);
199+
}
200+
201+
export function logBodyTooLarge(
202+
source: string,
203+
size: number,
204+
ip?: string
205+
): void {
206+
logSecurityEvent(
207+
'body_too_large',
208+
source,
209+
`Request body too large: ${size} bytes`,
210+
undefined,
211+
ip
212+
);
213+
}
214+
215+
export function logContentTypeInvalid(
216+
source: string,
217+
contentType: string,
218+
ip?: string
219+
): void {
220+
logSecurityEvent(
221+
'content_type_invalid',
222+
source,
223+
`Invalid content type: ${contentType}`,
224+
undefined,
225+
ip
226+
);
227+
}
228+
229+
export function logCleanup(
230+
source: string,
231+
expiredPrompts: number,
232+
oldActions: number
233+
): void {
234+
if (expiredPrompts > 0 || oldActions > 0) {
235+
logSecurityEvent('cleanup', source, 'Cleanup completed', {
236+
expiredPrompts,
237+
oldActions,
238+
});
239+
}
240+
}

src/hooks/sms-webhook.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,24 @@ import {
2828
cleanupOldActions,
2929
} from './sms-action-runner.js';
3030
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
31+
import {
32+
logWebhookRequest,
33+
logRateLimit,
34+
logSignatureInvalid,
35+
logBodyTooLarge,
36+
logContentTypeInvalid,
37+
logActionAllowed,
38+
logActionBlocked,
39+
logCleanup,
40+
} from './security-logger.js';
3141

3242
// Cleanup interval (5 minutes)
3343
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
3444

45+
// Input validation constants
46+
const MAX_SMS_BODY_LENGTH = 1000;
47+
const MAX_PHONE_LENGTH = 20;
48+
3549
// Security constants
3650
const MAX_BODY_SIZE = 50 * 1024; // 50KB max body
3751
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
@@ -132,6 +146,17 @@ export function handleSMSWebhook(payload: TwilioWebhookPayload): {
132146
} {
133147
const { From, Body } = payload;
134148

149+
// Input length validation
150+
if (Body && Body.length > MAX_SMS_BODY_LENGTH) {
151+
console.log(`[sms-webhook] Body too long: ${Body.length} chars`);
152+
return { response: 'Message too long. Max 1000 characters.' };
153+
}
154+
155+
if (From && From.length > MAX_PHONE_LENGTH) {
156+
console.log(`[sms-webhook] Invalid phone number length`);
157+
return { response: 'Invalid phone number.' };
158+
}
159+
135160
console.log(`[sms-webhook] Received from ${From}: ${Body}`);
136161

137162
const result = processIncomingResponse(From, Body);
@@ -165,6 +190,7 @@ export function handleSMSWebhook(payload: TwilioWebhookPayload): {
165190
);
166191

167192
if (actionResult.success) {
193+
logActionAllowed('sms-webhook', result.action);
168194
console.log(
169195
`[sms-webhook] Action completed: ${(actionResult.output || '').substring(0, 200)}`
170196
);
@@ -175,6 +201,11 @@ export function handleSMSWebhook(payload: TwilioWebhookPayload): {
175201
queued: false,
176202
};
177203
} else {
204+
logActionBlocked(
205+
'sms-webhook',
206+
result.action,
207+
actionResult.error || 'unknown'
208+
);
178209
console.log(`[sms-webhook] Action failed: ${actionResult.error}`);
179210

180211
// Queue for retry
@@ -281,9 +312,19 @@ export function startWebhookServer(port: number = 3456): void {
281312
url.pathname === '/webhook') &&
282313
req.method === 'POST'
283314
) {
284-
// Rate limiting
285315
const clientIp = req.socket.remoteAddress || 'unknown';
316+
317+
// Log webhook request
318+
logWebhookRequest(
319+
'sms-webhook',
320+
req.method || 'POST',
321+
url.pathname || '/sms',
322+
clientIp
323+
);
324+
325+
// Rate limiting
286326
if (!checkRateLimit(clientIp)) {
327+
logRateLimit('sms-webhook', clientIp);
287328
res.writeHead(429, {
288329
'Content-Type': 'text/xml',
289330
'Retry-After': '60',
@@ -295,6 +336,7 @@ export function startWebhookServer(port: number = 3456): void {
295336
// Content-type validation
296337
const contentType = req.headers['content-type'] || '';
297338
if (!contentType.includes('application/x-www-form-urlencoded')) {
339+
logContentTypeInvalid('sms-webhook', contentType, clientIp);
298340
res.writeHead(400, { 'Content-Type': 'text/xml' });
299341
res.end(twimlResponse('Invalid content type'));
300342
return;
@@ -308,6 +350,7 @@ export function startWebhookServer(port: number = 3456): void {
308350
// Body size limit
309351
if (body.length > MAX_BODY_SIZE) {
310352
bodyTooLarge = true;
353+
logBodyTooLarge('sms-webhook', body.length, clientIp);
311354
req.destroy();
312355
}
313356
});
@@ -336,6 +379,7 @@ export function startWebhookServer(port: number = 3456): void {
336379
twilioSignature
337380
)
338381
) {
382+
logSignatureInvalid('sms-webhook', clientIp);
339383
console.error('[sms-webhook] Invalid Twilio signature');
340384
res.writeHead(401, { 'Content-Type': 'text/xml' });
341385
res.end(twimlResponse('Unauthorized'));
@@ -426,6 +470,7 @@ export function startWebhookServer(port: number = 3456): void {
426470
const expiredPrompts = cleanupExpiredPrompts();
427471
const oldActions = cleanupOldActions();
428472
if (expiredPrompts > 0 || oldActions > 0) {
473+
logCleanup('sms-webhook', expiredPrompts, oldActions);
429474
console.log(
430475
`[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions`
431476
);

0 commit comments

Comments
 (0)