mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-29 09:20:39 +02:00
feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy
On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// Audit + attempts logging tests. Codex-flagged: identity must be hashed in
|
||||
// attempts.jsonl (no raw identity leak), rotation works, sanitize-replacer
|
||||
// strips lone surrogates.
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from '../src/audit';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-audit-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('writeAudit', () => {
|
||||
test('appends a JSONL row', async () => {
|
||||
const path = join(tmpDir, 'audit.jsonl');
|
||||
await writeAudit({
|
||||
ts: '2026-05-18T00:00:00Z',
|
||||
identity: 'u@e.com',
|
||||
device_udid: 'UDID-1',
|
||||
endpoint: 'POST /tap',
|
||||
session_id: 'S1',
|
||||
capability: 'interact',
|
||||
request_id: 'req-1',
|
||||
status: 200,
|
||||
}, path);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0]!).identity).toBe('u@e.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeAttempt', () => {
|
||||
test('hashes raw identity with the device salt (no raw leak)', async () => {
|
||||
const auditPath = join(tmpDir, 'attempts.jsonl');
|
||||
await writeAttempt({
|
||||
rawIdentity: 'attacker@evil.com',
|
||||
endpoint: 'POST /auth/mint',
|
||||
reason: 'identity_not_allowed',
|
||||
path: auditPath,
|
||||
});
|
||||
const lines = readFileSync(auditPath, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const row = JSON.parse(lines[0]!);
|
||||
expect(row.reason).toBe('identity_not_allowed');
|
||||
expect(row.identity_canon).not.toBe('attacker@evil.com');
|
||||
expect(row.identity_canon).toMatch(/^[a-f0-9]{16}$/); // 16-char hex
|
||||
});
|
||||
|
||||
test('does NOT log the raw identity anywhere in the row', async () => {
|
||||
const path = join(tmpDir, 'attempts.jsonl');
|
||||
await writeAttempt({
|
||||
rawIdentity: 'secret@example.com',
|
||||
endpoint: 'POST /auth/mint',
|
||||
reason: 'identity_not_allowed',
|
||||
path,
|
||||
});
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
expect(raw).not.toContain('secret@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeReplacer', () => {
|
||||
// Helper: check every UTF-16 code unit in a string. Returns true iff any
|
||||
// unpaired surrogate is present. More reliable than .toContain('\uD800')
|
||||
// since Bun's matcher does UTF-8 byte comparison for non-ASCII.
|
||||
const hasUnpairedSurrogate = (s: string): boolean => {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
if (c >= 0xD800 && c <= 0xDBFF) {
|
||||
const next = s.charCodeAt(i + 1);
|
||||
if (!(next >= 0xDC00 && next <= 0xDFFF)) return true;
|
||||
i++; // skip the valid pair
|
||||
} else if (c >= 0xDC00 && c <= 0xDFFF) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
test('replaces lone high surrogates with U+FFFD', () => {
|
||||
const out = JSON.stringify({ s: 'before\uD800after' }, sanitizeReplacer);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(true);
|
||||
});
|
||||
|
||||
test('replaces lone low surrogates with U+FFFD', () => {
|
||||
const out = JSON.stringify({ s: 'before\uDC00after' }, sanitizeReplacer);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(true);
|
||||
});
|
||||
|
||||
test('preserves valid surrogate pairs', () => {
|
||||
// 😀 = U+1F600 = surrogate pair D83D DE00. Must stay intact.
|
||||
const out = JSON.stringify({ s: '😀' }, sanitizeReplacer);
|
||||
expect(out.includes('😀')).toBe(true);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(false);
|
||||
});
|
||||
|
||||
test('passes through non-string values', () => {
|
||||
expect(JSON.stringify({ n: 42, b: true, x: null }, sanitizeReplacer)).toBe('{"n":42,"b":true,"x":null}');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user