audit.ts
97 lines · ios-qa/daemon/src/audit.ts at 3f79d81
1// Audit + attempts logging. Reuses the same rotation primitives as
2// browse/src/tunnel-denial-log.ts (10MB rotation, 5 generations).
4import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises';
5import { join, dirname } from 'path';
6import { createHash } from 'crypto';
7import type { AuditRow, AttemptRow } from './types';
8import { resolveStateRoot } from '../../../lib/state-root';
10const MAX_BYTES = 10 * 1024 * 1024;
11const MAX_GENS = 5;
13export function defaultAuditPath(): string {
14 return process.env.GSTACK_IOS_AUDIT_PATH
15 ?? join(resolveStateRoot(), 'security', 'ios-qa-audit.jsonl');
16}
18export function defaultAttemptsPath(): string {
19 return process.env.GSTACK_IOS_ATTEMPTS_PATH
20 ?? join(resolveStateRoot(), 'security', 'attempts.jsonl');
21}
23let _saltCache: string | null = null;
25async function loadDeviceSalt(): Promise<string> {
26 if (_saltCache) return _saltCache;
27 const path = join(resolveStateRoot(), 'security', 'device-salt');
28 try {
29 _saltCache = (await readFile(path, 'utf-8')).trim();
30 } catch {
31 // No salt; generate ephemeral. Real install writes one via /setup.
32 const { randomBytes } = await import('crypto');
33 _saltCache = randomBytes(32).toString('hex');
34 }
35 return _saltCache!;
36}
38async function rotateIfNeeded(path: string): Promise<void> {
39 try {
40 const s = await stat(path);
41 if (s.size < MAX_BYTES) return;
42 } catch {
43 return; // file doesn't exist yet
44 }
45 // Rotate: path → path.1 → path.2 → ... → path.MAX_GENS
46 for (let i = MAX_GENS - 1; i >= 0; i--) {
47 const src = i === 0 ? path : `${path}.${i}`;
48 const dst = `${path}.${i + 1}`;
49 try {
50 await rename(src, dst);
51 } catch {
52 // best-effort
53 }
54 }
55}
57export async function writeAudit(row: AuditRow, path: string = defaultAuditPath()): Promise<void> {
58 await mkdir(dirname(path), { recursive: true, mode: 0o700 });
59 await rotateIfNeeded(path);
60 await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
61}
63// Non-reversible identifier for tokens/identities in logs and API responses.
64// Same device salt as the attempts log, so ids correlate across both.
65export async function saltedHash(raw: string): Promise<string> {
66 const salt = await loadDeviceSalt();
67 return createHash('sha256').update(salt + ':' + raw).digest('hex').slice(0, 16);
68}
70export async function writeAttempt(opts: {
71 rawIdentity: string;
72 endpoint: string;
73 reason: AttemptRow['reason'];
74 path?: string;
75}): Promise<void> {
76 const hash = await saltedHash(opts.rawIdentity);
77 const row: AttemptRow = {
78 ts: new Date().toISOString(),
79 identity_canon: hash,
80 endpoint: opts.endpoint,
81 reason: opts.reason,
82 };
83 const path = opts.path ?? defaultAttemptsPath();
84 await mkdir(dirname(path), { recursive: true, mode: 0o700 });
85 await rotateIfNeeded(path);
86 await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
87}
89// Sanitize-replacer for JSON responses — mirrors browse's sanitize-replacer.ts.
90// Strips lone UTF-16 surrogate halves that would otherwise reach the
91// Anthropic API as \uD800-style escapes and trigger 400.
92export function sanitizeReplacer(_key: string, value: unknown): unknown {
93 if (typeof value !== 'string') return value;
94 // Replace lone high surrogates not followed by low surrogates, and lone
95 // low surrogates not preceded by high surrogates.
96 return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�');
97}