mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07: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.every": "the plugin's own timers",
|
||||||
"schedule.after": "the plugin's own timers",
|
"schedule.after": "the plugin's own timers",
|
||||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
"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",
|
currentActor: "reads the actor, cannot set it",
|
||||||
"desktop.available": "a yes/no about the environment",
|
"desktop.available": "a yes/no about the environment",
|
||||||
"http.baseUrl": "reads the request's public origin",
|
"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);
|
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", () => {
|
describe("getRequestMeta", () => {
|
||||||
it("uses the proxy-validated Express IP", () => {
|
it("uses the proxy-validated Express IP", () => {
|
||||||
const req = {
|
const req = {
|
||||||
|
|||||||
@@ -35,7 +35,49 @@ export interface AuditLogParams {
|
|||||||
errorMessage?: string;
|
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> {
|
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
|
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||||
// and must never delay or fail the audited operation.
|
// and must never delay or fail the audited operation.
|
||||||
void forwardAuditEntry(params).catch(() => {});
|
void forwardAuditEntry(params).catch(() => {});
|
||||||
|
|||||||
Reference in new issue
Block a user