U+0085 (NEL), U+2028 (LINE SEPARATOR), and U+2029 (PARAGRAPH SEPARATOR) are treated as line terminators by some log shippers and by JS's own lexical grammar (and are not escaped by JSON.stringify by default), so they could still be used to forge audit log entries even after the initial C0/DEL fix. logSafe now strips all five categories.
77 lines
2.3 KiB
TypeScript
77 lines
2.3 KiB
TypeScript
import type { AuthzScope } from "./types.ts";
|
|
|
|
export interface AuthzAuditEvent {
|
|
subjectId?: string;
|
|
scope?: AuthzScope;
|
|
permission: string;
|
|
allowed: boolean;
|
|
reason?: string;
|
|
policy?: string;
|
|
/** Epoch milliseconds. */
|
|
at: number;
|
|
}
|
|
|
|
export interface AuthzAuditSink {
|
|
record(event: AuthzAuditEvent): void | Promise<void>;
|
|
}
|
|
|
|
export interface MemoryAuditSink extends AuthzAuditSink {
|
|
events: AuthzAuditEvent[];
|
|
clear(): void;
|
|
}
|
|
|
|
export function memoryAuditSink(): MemoryAuditSink {
|
|
const events: AuthzAuditEvent[] = [];
|
|
return {
|
|
events,
|
|
record: (event) => void events.push(event),
|
|
clear: () => void events.splice(0, events.length),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Subject ids, tenant ids, and denial reasons trace back to request input, so
|
|
* a newline in one would forge a second audit line indistinguishable from a
|
|
* real entry. Strip control characters before interpolating.
|
|
*/
|
|
function logSafe(value: string): string {
|
|
let out = "";
|
|
for (const character of value) {
|
|
const code = character.codePointAt(0)!;
|
|
// C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some
|
|
// log shippers and JSON consumers also treat as line terminators.
|
|
const isLineBreaking =
|
|
code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029;
|
|
out += isLineBreaking ? " " : character;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function consoleAuditSink(): AuthzAuditSink {
|
|
return {
|
|
record(event) {
|
|
const verdict = event.allowed ? "allow" : "deny";
|
|
console.info(
|
|
`[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` +
|
|
`subject=${logSafe(event.subjectId ?? "anonymous")}` +
|
|
`${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` +
|
|
`${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` +
|
|
`${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Record without ever letting a sink failure escape into the request path. */
|
|
export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void {
|
|
if (!sink) return;
|
|
try {
|
|
const result = sink.record(event);
|
|
if (result instanceof Promise) {
|
|
result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error));
|
|
}
|
|
} catch (error) {
|
|
console.warn("[wrnexus:authz] audit sink failed", error);
|
|
}
|
|
}
|