mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)
Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time.
This commit is contained in:
1 parent
11f6910d51
commit
893e7e5a4b
3 files changed
+140
-2
No files matched your search
@@ -75,7 +75,8 @@ const UNGATED: Record<string, string> = {
|
||||
"schedule.every": "the plugin's own timers",
|
||||
"schedule.after": "the plugin's own timers",
|
||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||
asUser: "always audited as plugin_as_user",
|
||||
asUser:
|
||||
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
||||
currentActor: "reads the actor, cannot set it",
|
||||
"desktop.available": "a yes/no about the environment",
|
||||
"http.baseUrl": "reads the request's public origin",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const createMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -60,6 +60,101 @@ describe("logAudit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("logAudit plugin routine coalescing", () => {
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
let run = 0;
|
||||
let details = "";
|
||||
|
||||
const routine = (overrides: Record<string, unknown> = {}) =>
|
||||
logAudit({
|
||||
userId: null,
|
||||
username: "plugin:automations",
|
||||
action: "plugin_as_user",
|
||||
resourceType: "plugin",
|
||||
resourceId: "automations",
|
||||
details,
|
||||
success: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createMock.mockResolvedValue(undefined);
|
||||
vi.useFakeTimers();
|
||||
// The coalescing memory is module state: a fresh key per test keeps them
|
||||
// independent.
|
||||
details = `automations ran background work as user-${++run}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS - 1);
|
||||
await routine();
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin service calls in the window", async () => {
|
||||
const service = {
|
||||
action: "plugin_service_host_metrics_open",
|
||||
resourceId: "host-metrics",
|
||||
};
|
||||
await routine(service);
|
||||
await routine(service);
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes again once the window has passed", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("always writes failures", async () => {
|
||||
await routine();
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never coalesces non-plugin or non-routine actions", async () => {
|
||||
const login = {
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
};
|
||||
await routine(login);
|
||||
await routine(login);
|
||||
const secretRead = { action: "plugin_secret_shared_read" };
|
||||
await routine(secretRead);
|
||||
await routine(secretRead);
|
||||
const notPlugin = { username: "alice" };
|
||||
await routine(notPlugin);
|
||||
await routine(notPlugin);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not coalesce entries with different details or resourceId", async () => {
|
||||
await routine();
|
||||
await routine({ details: `${details} (other)` });
|
||||
await routine({ resourceId: "other-plugin" });
|
||||
await routine({ userId: "user-2" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("uses the proxy-validated Express IP", () => {
|
||||
const req = {
|
||||
|
||||
@@ -35,7 +35,49 @@ export interface AuditLogParams {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
|
||||
// service) repeat identically every few seconds; written each time they push
|
||||
// real security entries out of the capped log. Only successful plugin_as_user
|
||||
// and plugin_service_* lines are coalesced; failures and everything else are
|
||||
// always written.
|
||||
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
|
||||
const ROUTINE_COALESCE_MAX_KEYS = 5000;
|
||||
const routineLastWritten = new Map<string, number>();
|
||||
|
||||
function isCoalescedRoutine(params: AuditLogParams): boolean {
|
||||
if (
|
||||
!params.success ||
|
||||
!params.username.startsWith("plugin:") ||
|
||||
!(
|
||||
params.action === "plugin_as_user" ||
|
||||
params.action.startsWith("plugin_service_")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
params.userId,
|
||||
params.username,
|
||||
params.action,
|
||||
params.resourceType,
|
||||
params.resourceId,
|
||||
params.details,
|
||||
]);
|
||||
const now = Date.now();
|
||||
const last = routineLastWritten.get(key);
|
||||
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
|
||||
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
|
||||
for (const [k, at] of routineLastWritten) {
|
||||
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
|
||||
}
|
||||
}
|
||||
routineLastWritten.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||
if (isCoalescedRoutine(params)) return;
|
||||
|
||||
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||
// and must never delay or fail the audited operation.
|
||||
void forwardAuditEntry(params).catch(() => {});
|
||||
|
||||
Reference in new issue
Block a user