Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
187 changes: 187 additions & 0 deletions src/bsm-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down
61 changes: 60 additions & 1 deletion src/bsm-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading