Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions packages/gateway/src/audit/audit-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { AuditEvent } from "../state/types.js";

export function authFailureEvent(deviceId: string, reason: string): AuditEvent {
return {
timestamp: Date.now(),
event: "auth_failure",
outcome: "failure",
deviceId,
details: { reason },
};
}

export function authSuccessEvent(deviceId: string): AuditEvent {
return {
timestamp: Date.now(),
event: "auth_success",
outcome: "success",
deviceId,
};
}

export function nonceReplayEvent(deviceId: string, nonce: string): AuditEvent {
return {
timestamp: Date.now(),
event: "nonce_replay",
outcome: "failure",
deviceId,
details: { nonce },
};
}

export function connectionEvent(
deviceId: string,
action: "connected" | "disconnected",
): AuditEvent {
return {
timestamp: Date.now(),
event: "connection",
outcome: "success",
deviceId,
details: { action },
};
}

export function deviceRegisteredEvent(
deviceId: string,
actor?: string,
): AuditEvent {
const event: AuditEvent = {
timestamp: Date.now(),
event: "device_registered",
outcome: "success",
deviceId,
};
if (actor !== undefined) {
event.actor = actor;
}
return event;
}

export function deviceApprovedEvent(
deviceId: string,
actor: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "device_approved",
outcome: "success",
deviceId,
actor,
};
}

export function deviceRevokedEvent(
deviceId: string,
actor: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "device_revoked",
outcome: "success",
deviceId,
actor,
};
}

export function rpcDeniedEvent(
deviceId: string,
method: string,
reason: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "rpc_denied",
outcome: "denied",
deviceId,
details: { method, reason },
};
}

export function execApprovalEvent(
deviceId: string,
requestId: string,
approved: boolean,
actor: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "exec_approval",
outcome: approved ? "success" : "denied",
deviceId,
actor,
details: { requestId, approved },
};
}

export function pluginDisabledEvent(
pluginId: string,
actor: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "plugin_disabled",
outcome: "success",
actor,
details: { pluginId },
};
}

export function secretAccessedEvent(key: string, actor: string): AuditEvent {
return {
timestamp: Date.now(),
event: "secret_accessed",
outcome: "success",
actor,
details: { key },
};
}

export function secretStoredEvent(key: string, actor: string): AuditEvent {
return {
timestamp: Date.now(),
event: "secret_stored",
outcome: "success",
actor,
details: { key },
};
}

export function fileAccessViolationEvent(
path: string,
deviceId: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "file_access_violation",
outcome: "denied",
deviceId,
details: { path },
};
}

export function transcriptWriteErrorEvent(
sessionId: string,
error: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "transcript_write_error",
outcome: "failure",
details: { sessionId, error },
};
}
80 changes: 37 additions & 43 deletions packages/gateway/src/audit/audit-log.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,58 @@
import { appendFile, mkdir } from "node:fs/promises";
import { appendFile, mkdir, open } from "node:fs/promises";
import { dirname, join } from "node:path";

import type { AuditEvent } from "../state/types.js";
import { redactFields } from "./redact.js";

type FileSystem = Pick<
typeof import("node:fs/promises"),
"appendFile" | "mkdir"
"appendFile" | "mkdir" | "open"
>;

export interface AuditLogOptions {
datasync?: boolean;
}

export class AuditLog {
private readonly auditPath: string;
private readonly fs: FileSystem;
private readonly datasync: boolean;

public constructor(dataDir: string, fs: FileSystem = { appendFile, mkdir }) {
public constructor(
dataDir: string,
options?: AuditLogOptions,
fs: FileSystem = { appendFile, mkdir, open },
) {
this.auditPath = join(dataDir, "audit.jsonl");
this.fs = fs;
this.datasync = options?.datasync ?? false;
}

public async log(event: AuditEvent): Promise<void> {
const redacted: AuditEvent = event.details
? { ...event, details: redactFields(event.details) }
: event;

await this.fs.mkdir(dirname(this.auditPath), { recursive: true });
await this.fs.appendFile(
this.auditPath,
`${JSON.stringify(event)}\n`,
"utf8",
);
const line = `${JSON.stringify(redacted)}\n`;

if (this.datasync) {
const fh = await this.fs.open(this.auditPath, "a");
try {
await fh.write(line, undefined, "utf8");
await fh.datasync();
} finally {
await fh.close();
}
} else {
await this.fs.appendFile(this.auditPath, line, "utf8");
}
}
}

export function createAuthFailureEvent(
deviceId: string,
reason: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "auth_failure",
deviceId,
details: { reason },
};
}

export function createNonceReplayEvent(
deviceId: string,
nonce: string,
): AuditEvent {
return {
timestamp: Date.now(),
event: "nonce_replay",
deviceId,
details: { nonce },
};
}

export function createConnectionEvent(
deviceId: string,
action: "connected" | "disconnected",
): AuditEvent {
return {
timestamp: Date.now(),
event: "connection",
deviceId,
details: { action },
};
}
// Backward-compatible factory aliases
export {
authFailureEvent as createAuthFailureEvent,
connectionEvent as createConnectionEvent,
nonceReplayEvent as createNonceReplayEvent,
} from "./audit-events.js";
5 changes: 4 additions & 1 deletion packages/gateway/src/audit/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from "./audit-log.js";
export * from "./audit-events.js";
export type { AuditLogOptions } from "./audit-log.js";
export { AuditLog } from "./audit-log.js";
export { redactFields, redactSecret } from "./redact.js";
46 changes: 46 additions & 0 deletions packages/gateway/src/audit/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const DEFAULT_REDACT_FIELDS = [
"sharedSecret",
"token",
"apiKey",
"passphrase",
"password",
"secret",
];

export function redactSecret(value: string): string {
if (value.length > 4) {
return `****${value.slice(-4)}`;
}
return "****";
}

export function redactFields<T extends Record<string, unknown>>(
obj: T,
fields?: string[],
): T {
const redactSet = new Set(fields ?? DEFAULT_REDACT_FIELDS);
return redactDeep(obj, redactSet) as T;
}

function redactDeep(value: unknown, fields: Set<string>): unknown {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => redactDeep(item, fields));
}
if (typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (fields.has(key) && typeof val === "string") {
result[key] = redactSecret(val);
} else if (typeof val === "object" && val !== null) {
result[key] = redactDeep(val, fields);
} else {
result[key] = val;
}
}
return result;
}
return value;
}
5 changes: 5 additions & 0 deletions packages/gateway/src/config/gateway-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export interface GatewayServerConfig {
idempotencyCleanupIntervalMs: number;
dataDir: string;
sqlitePath?: string;
secretsBackend: "encrypted-file" | "keychain";
masterPassphrase?: string;
fsyncWrites: boolean;
jwtSecret?: string;
rateLimits: {
perIpConnectionsPerMinute: number;
Expand Down Expand Up @@ -38,6 +41,8 @@ export function createDefaultConfig(): GatewayServerConfig {
idempotencyTtlMs: 86_400_000,
idempotencyCleanupIntervalMs: 3_600_000,
dataDir: ".homeagent",
secretsBackend: "encrypted-file",
fsyncWrites: true,
rateLimits: {
perIpConnectionsPerMinute: 10,
perDeviceRpcPerMinute: 60,
Expand Down
7 changes: 5 additions & 2 deletions packages/gateway/src/config/parse-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ function parseArgs(args: string[]): ParsedArgs {
continue;
}

const [rawFlag, inlineValue] = current.split("=", 2);
const [rawFlag, inlineValue] = current.split("=", 2) as [
string,
string | undefined,
];
const booleanFlags = new Set([
"--insecure",
"--no-strict-origin",
"--no-strict-cors",
]);
const needsValue = !booleanFlags.has(rawFlag!);
const needsValue = !booleanFlags.has(rawFlag);
let value = inlineValue;
if (needsValue && value === undefined) {
const next = args[i + 1];
Expand Down
1 change: 1 addition & 0 deletions packages/gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./config/gateway-config.js";
export * from "./config/parse-cli.js";
export * from "./idempotency/index.js";
export * from "./network/index.js";
export * from "./persistence/index.js";
export * from "./rpc/index.js";
export * from "./server/index.js";
export * from "./state/index.js";
Expand Down
Loading