diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 427deef..2d2f300 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3" + bun-version: "1.3.11" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24250d7..75bfd4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,8 @@ jobs: - name: Typecheck run: bun run typecheck - - name: Run tests - run: bun test + - name: Run tests with coverage + run: bun test --coverage build: name: Build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4555548 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# bsmtap + +BSM-to-JSON streaming daemon — taps macOS OpenBSM audit events from `/dev/auditpipe` into structured JSON Lines. + +## Commands + +- `bun test` — run all tests +- `bun test --coverage` — run tests with coverage +- `bun run typecheck` — typecheck (tsc --noEmit) +- `bun run lint:fix` — auto-fix lint + formatting +- `bun run format` — auto-fix formatting only +- `bun run lint:ci` — lint check as CI runs it + +### Pre-push verification + +Run all three CI checks before pushing: + +```sh +bun run lint:ci && bun run typecheck && bun test --coverage +``` + +## Code style + +Enforced by Biome — do not override manually: + +- Double quotes, trailing commas, 2-space indent, 120-char line width +- Imports are auto-organized by Biome + +## Architecture + +All source is in `src/`: + +- `schema.ts` — `AuditEvent` and related types (the shared contract) +- `bsm-parser.ts` — binary token parser: each token type has a `parse*` function that takes `(view, offset, [buf], event)` and returns the new offset. All multi-byte reads are big-endian. +- `reader.ts` — `AuditPipeReader`: reads raw bytes from `/dev/auditpipe`, uses `extractRecords()` to frame and parse +- `writer.ts` — `JsonLineFileWriter`: writes `AuditEvent` objects as JSON Lines with SIGHUP-based file rotation +- `main.ts` — wires reader → writer + +## Gotchas + +- Adding a field to `AuditEvent` in `schema.ts` requires updating the initializer in `bsm-parser.ts:parseRecord` AND `makeEvent()` in `writer.test.ts` — typecheck catches this +- Token parser tests use binary builder helpers (`bsm()`, `buildRecord()`, token-specific builders) at the top of `bsm-parser.test.ts` +- CI runs on Ubuntu but the daemon targets macOS — reader tests use temp FIFOs to simulate `/dev/auditpipe` diff --git a/package.json b/package.json index acff0a5..a157cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsmtap", - "version": "1.0.2", + "version": "1.0.3", "description": "BSM-to-JSON streaming daemon — taps macOS OpenBSM audit events into structured JSON Lines", "type": "module", "license": "MIT", diff --git a/src/bsm-parser.test.ts b/src/bsm-parser.test.ts index 4c74a0e..43b300e 100644 --- a/src/bsm-parser.test.ts +++ b/src/bsm-parser.test.ts @@ -325,6 +325,59 @@ function arg64Token(argNum: number, value: number, desc: string): Uint8Array { return concat(bsm(0x71, argNum, ["u64", value]), bsmString(desc)); } +/** + * Build an AUT_DATA token (0x21). + * Format: token_id(1) + how_to_print(1) + basic_unit(1) + unit_count(1) + data(variable) + * Unit sizes: 0=byte(1), 1=short(2), 2=int32(4), 3=int64(8) + */ +function dataToken(howToPrint: number, basicUnit: number, unitCount: number, data: Uint8Array): Uint8Array { + return concat(bsm(0x21, howToPrint, basicUnit, unitCount), data); +} + +/** Build an AUT_IN_ADDR token (0x2a). Format: token_id(1) + ipv4_addr(4) */ +function inAddrToken(a: number, b: number, c: number, d: number): Uint8Array { + return bsm(0x2a, a, b, c, d); +} + +/** + * Build an AUT_IDENTITY token (0xed). + * Format: token_id(1) + signer_type(4) + signing_id_len(2) + signing_id(n) + + * signing_id_truncated(1) + team_id_len(2) + team_id(n) + + * team_id_truncated(1) + cdhash_len(2) + cdhash(n) + */ +function identityToken(opts: { + signerType?: number; + signingId?: string; + teamId?: string; + cdhash?: Uint8Array; +}): Uint8Array { + const signerType = opts.signerType ?? 0; + const signingId = opts.signingId ?? ""; + const teamId = opts.teamId ?? ""; + const cdhash = opts.cdhash ?? new Uint8Array(20); + + const signingIdEncoded = new TextEncoder().encode(signingId); + const signingIdLen = signingIdEncoded.byteLength + 1; // include NUL + const signingIdBuf = new Uint8Array(signingIdLen); + signingIdBuf.set(signingIdEncoded); + + const teamIdEncoded = new TextEncoder().encode(teamId); + const teamIdLen = teamIdEncoded.byteLength + 1; + const teamIdBuf = new Uint8Array(teamIdLen); + teamIdBuf.set(teamIdEncoded); + + return concat( + bsm(0xed, ["u32", signerType], ["u16", signingIdLen]), + signingIdBuf, + bsm(0), // signing_id_truncated + bsm(["u16", teamIdLen]), + teamIdBuf, + bsm(0), // team_id_truncated + bsm(["u16", cdhash.byteLength]), + cdhash, + ); +} + /** Build a record with header64 (0x74). */ function buildRecord64(eventType: number, ...tokens: Uint8Array[]): Uint8Array { // Header64: 1(id) + 4(size) + 1(ver) + 2(event) + 2(mod) + 8(sec) + 8(usec) = 26 @@ -739,6 +792,140 @@ describe("parseRecord", () => { expect(event.paths).toEqual([""]); }); + // --- AUT_DATA (0x21) --- + + test("parses record containing AUT_DATA with byte units", () => { + const data = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const record = buildRecord( + 180, + subjectToken({ pid: 1234 }), + dataToken(3, 0, 4, data), // hex print, byte unit, 4 bytes + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.event).toBe("execve(2)"); + expect(event.subject!.pid).toBe("1234"); + expect(event.return!.errval).toBe("success"); + }); + + test("parses record containing AUT_DATA with int32 units", () => { + const data = bsm(["u32", 42], ["u32", 99]); + const record = buildRecord( + 180, + subjectToken({ pid: 5678 }), + dataToken(2, 2, 2, data), // decimal print, int32 unit, 2 items + pathToken("/usr/bin/ls"), + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.paths).toEqual(["/usr/bin/ls"]); + expect(event.return!.errval).toBe("success"); + }); + + test("parses record containing AUT_DATA with short units", () => { + const data = bsm(["u16", 1], ["u16", 2], ["u16", 3]); + const record = buildRecord( + 180, + dataToken(2, 1, 3, data), // decimal print, short unit, 3 items + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.return!.errval).toBe("success"); + }); + + test("parses record containing AUT_DATA with int64 units", () => { + const data = bsm(["u64", 0xdeadbeef]); + const record = buildRecord( + 180, + dataToken(3, 3, 1, data), // hex print, int64 unit, 1 item + returnToken(0, 42), + ); + const event = parseRecord(record); + expect(event.return!.retval).toBe("42"); + }); + + // --- AUT_IN_ADDR (0x2a) --- + + test("parses record containing AUT_IN_ADDR", () => { + const record = buildRecord(180, subjectToken({ pid: 1234 }), inAddrToken(10, 0, 1, 5), returnToken(0, 0)); + const event = parseRecord(record); + expect(event.subject!.pid).toBe("1234"); + expect(event.return!.errval).toBe("success"); + }); + + // --- AUT_IDENTITY (0xed) --- + + test("parses record containing AUT_IDENTITY", () => { + const cdhash = new Uint8Array([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, + 0x14, + ]); + const record = buildRecord( + 180, + subjectToken({ pid: 1234 }), + identityToken({ + signerType: 3, + signingId: "com.apple.ls", + teamId: "ABCDE12345", + cdhash, + }), + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.event).toBe("execve(2)"); + expect(event.subject!.pid).toBe("1234"); + expect(event.identity).not.toBeNull(); + expect(event.identity!.signerType).toBe(3); + expect(event.identity!.signingId).toBe("com.apple.ls"); + expect(event.identity!.teamId).toBe("ABCDE12345"); + expect(event.identity!.cdhash).toBe("0102030405060708090a0b0c0d0e0f1011121314"); + expect(event.return!.errval).toBe("success"); + }); + + test("parses AUT_IDENTITY with empty signing ID and team ID", () => { + const cdhash = new Uint8Array(20); // all zeros + const record = buildRecord( + 180, + identityToken({ signerType: 0, signingId: "", teamId: "", cdhash }), + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.identity).not.toBeNull(); + expect(event.identity!.signerType).toBe(0); + expect(event.identity!.signingId).toBe(""); + expect(event.identity!.teamId).toBe(""); + }); + + // --- Realistic macOS execve record with all three new tokens --- + + test("parses realistic macOS execve record with data, identity, and other tokens", () => { + const cdhash = new Uint8Array(20).fill(0xab); + const record = buildRecord( + 180, // execve(2) + subjectToken({ pid: 42, uid: 501 }), + execArgsToken("/usr/bin/ls", "-la"), + dataToken(3, 0, 2, new Uint8Array([0x01, 0x02])), // AUT_DATA + pathToken("/usr/bin/ls"), + attrToken(0o100755, 0, 0, 16777220, 12345678, 16777220), + identityToken({ + signerType: 3, + signingId: "com.apple.ls", + teamId: "", + cdhash, + }), + returnToken(0, 0), + ); + const event = parseRecord(record); + expect(event.event).toBe("execve(2)"); + expect(event.subject!.pid).toBe("42"); + expect(event.execArgs).toEqual(["/usr/bin/ls", "-la"]); + expect(event.paths).toEqual(["/usr/bin/ls"]); + expect(event.attributes).toHaveLength(1); + expect(event.identity).not.toBeNull(); + expect(event.identity!.signingId).toBe("com.apple.ls"); + expect(event.return!.errval).toBe("success"); + }); + test("survives exec_args with count exceeding buffer", () => { // Exec args token claiming 100 args but only containing 1 const header = bsm(0x14, ["u32", 40], 11, ["u16", 180], ["u16", 0], ["u32", 1776000000], ["u32", 123000]); diff --git a/src/bsm-parser.ts b/src/bsm-parser.ts index b9144ef..2b62dbd 100644 --- a/src/bsm-parser.ts +++ b/src/bsm-parser.ts @@ -7,7 +7,7 @@ * Reference: openbsm/openbsm bsm_token.h, bsm_io.c */ -import type { AuditArgument, AuditAttribute, AuditEvent, AuditReturn, AuditSubject } from "./schema.ts"; +import type { AuditArgument, AuditAttribute, AuditEvent, AuditIdentity, AuditReturn, AuditSubject } from "./schema.ts"; // --- Token type IDs --- @@ -27,8 +27,11 @@ const AUT_TEXT = 0x28; const AUT_EXEC_ARGS = 0x3c; const AUT_ARG32 = 0x2d; const AUT_ARG64 = 0x71; +const AUT_DATA = 0x21; +const AUT_IN_ADDR = 0x2a; const AUT_ATTR32 = 0x3e; const AUT_ATTR64 = 0x73; +const AUT_IDENTITY = 0xed; const TRAILER_MAGIC = 0xb105; const textDecoder = new TextDecoder(); @@ -131,6 +134,7 @@ export function parseRecord(buf: Uint8Array): AuditEvent { text: [], attributes: [], arguments: [], + identity: null, }; // Record size is always at byte 1 (right after the 1-byte token ID) for all header types @@ -178,6 +182,12 @@ export function parseRecord(buf: Uint8Array): AuditEvent { case AUT_TEXT: offset = parseText(view, offset, buf, event); break; + case AUT_DATA: + offset = parseData(view, offset, buf); + break; + case AUT_IN_ADDR: + offset = offset + 4; + break; case AUT_EXEC_ARGS: offset = parseExecArgs(view, offset, buf, event); break; @@ -193,6 +203,9 @@ export function parseRecord(buf: Uint8Array): AuditEvent { case AUT_ATTR64: offset = parseAttr64(view, offset, event); break; + case AUT_IDENTITY: + offset = parseIdentity(view, offset, buf, event); + break; case AUT_TRAILER: offset = parseTrailer(view, offset, recordSize); break; @@ -379,6 +392,22 @@ function parseExecArgs(view: DataView, off: number, buf: Uint8Array, event: Audi return pos; } +// --- Data token parser --- + +// Byte sizes indexed by OpenBSM basic_unit: AUR_BYTE=0(1), AUR_SHORT=1(2), AUR_INT32=2(4), AUR_INT64=3(8) +const DATA_UNIT_SIZES = [1, 2, 4, 8] as const; + +function parseData(view: DataView, off: number, buf: Uint8Array): number { + // how_to_print(1) + basic_unit(1) + unit_count(1) + data(variable) + const basicUnit = view.getUint8(off + 1); + const unitCount = view.getUint8(off + 2); + // Fallback to 1-byte units for unrecognized basicUnit values (defensive) + const unitSize = DATA_UNIT_SIZES[basicUnit] ?? 1; + const end = off + 3 + unitCount * unitSize; + if (end > buf.byteLength) return buf.byteLength; + return end; +} + // --- Argument parsers --- function parseArg32(view: DataView, off: number, buf: Uint8Array, event: AuditEvent): number { @@ -430,6 +459,36 @@ function readAttrFields(view: DataView, off: number, devSize: number): AuditAttr }; } +// --- Identity parser (Apple AUT_IDENTITY 0xed) --- + +function parseIdentity(view: DataView, off: number, buf: Uint8Array, event: AuditEvent): number { + // signer_type(4) + signing_id_len(2) + signing_id(n) + truncated(1) + + // team_id_len(2) + team_id(n) + truncated(1) + cdhash_len(2) + cdhash(n) + if (off + 6 > buf.byteLength) return buf.byteLength; + const signerType = view.getUint32(off, false); + const signingIdLen = view.getUint16(off + 4, false); + if (off + 6 + signingIdLen + 1 > buf.byteLength) return buf.byteLength; + const signingId = decodeString(buf, off + 6, signingIdLen); + let pos = off + 6 + signingIdLen + 1; // +1 for truncated flag + + if (pos + 2 > buf.byteLength) return buf.byteLength; + const teamIdLen = view.getUint16(pos, false); + if (pos + 2 + teamIdLen + 1 > buf.byteLength) return buf.byteLength; + const teamId = decodeString(buf, pos + 2, teamIdLen); + pos = pos + 2 + teamIdLen + 1; // +1 for truncated flag + + if (pos + 2 > buf.byteLength) return buf.byteLength; + const cdhashLen = view.getUint16(pos, false); + if (pos + 2 + cdhashLen > buf.byteLength) return buf.byteLength; + const cdhashBytes = buf.subarray(pos + 2, pos + 2 + cdhashLen); + const cdhash = Array.from(cdhashBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + event.identity = { signerType, signingId, teamId, cdhash }; + return pos + 2 + cdhashLen; +} + // --- Trailer --- function parseTrailer(view: DataView, off: number, expectedSize: number): number { diff --git a/src/schema.ts b/src/schema.ts index f39cb09..8144347 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -22,6 +22,8 @@ export interface AuditEvent { attributes: AuditAttribute[]; /** Argument tokens (syscall arguments). */ arguments: AuditArgument[]; + /** Code signing identity from AUT_IDENTITY token (macOS). */ + identity: AuditIdentity | null; } export interface AuditSubject { @@ -65,6 +67,17 @@ export interface AuditArgument { desc: string; } +export interface AuditIdentity { + /** Signer type (e.g., 0=unsigned, 3=Apple system). */ + signerType: number; + /** Code signing identifier (e.g., "com.apple.ls"). */ + signingId: string; + /** Team identifier (e.g., "ABCDE12345"). */ + teamId: string; + /** Code directory hash, hex-encoded. */ + cdhash: string; +} + /** Writer interface for pluggable output sinks. */ export interface Writer { write(event: AuditEvent): Promise; diff --git a/src/writer.test.ts b/src/writer.test.ts index b91775e..f7b4741 100644 --- a/src/writer.test.ts +++ b/src/writer.test.ts @@ -18,6 +18,7 @@ function makeEvent(overrides: Partial = {}): AuditEvent { text: [], attributes: [], arguments: [], + identity: null, ...overrides, }; }