audit the remaining remote access paths (#1129)

Only SSH terminal sessions were audited. Opening a file manager session, an RDP,
VNC or Telnet desktop, a Docker session or an SSH tunnel left no audit entry at
all — which covers most of the ways data leaves a host or a foothold is
established.

Each of those four now writes an entry when the session is established, matching
the existing ssh_connect: who, which host, from what address, and for tunnels
the endpoint and local port being forwarded.

Audit writes are fire-and-forget so they cannot delay or fail the connection,
consistent with logAudit already swallowing its own errors.

getAuditUsername was defined identically in two route files and is needed in
four more, so it moves next to logAudit.
This commit is contained in:
ZacharyZcR
2026-07-28 17:04:56 +08:00
committed by GitHub
parent 768c64bd6a
commit 2870664ee1
8 changed files with 169 additions and 14 deletions
+5 -7
View File
@@ -6,12 +6,15 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
@@ -25,11 +28,6 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
async function getAuditUsername(userId: string): Promise<string> {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
}
/**
* @openapi
* /credentials:
+5 -6
View File
@@ -47,7 +47,11 @@ import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
} from "./host-enrollment-auth.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
const router = express.Router();
@@ -55,11 +59,6 @@ const upload = multer({ storage: multer.memoryStorage() });
const STATS_SERVER_URL = "http://localhost:30005";
async function getAuditUsername(userId: string): Promise<string> {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
}
function notifyStatsHostUpdated(
hostId: number,
headers: Pick<Request["headers"], "authorization" | "cookie">,
+19
View File
@@ -2,6 +2,11 @@ import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -591,6 +596,20 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
});
void (async () => {
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "docker_connect",
resourceType: "host",
resourceId: hostId ? String(hostId) : undefined,
ipAddress,
userAgent,
success: true,
});
})();
res.json({
success: true,
message: "SSH connection established",
+23
View File
@@ -1,4 +1,9 @@
import express from "express";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import cookieParser from "cookie-parser";
import axios from "axios";
@@ -1165,6 +1170,24 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
scpLegacy: resolvedScpLegacy,
};
scheduleSessionCleanup(sessionId);
if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => {
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "file_manager_connect",
resourceType: "host",
resourceId: hostId ? String(hostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true,
});
})();
}
res.json({
status: "success",
message: "SSH connection established",
+18
View File
@@ -15,6 +15,11 @@ import {
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { waitForGuacdOpen } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
const router = express.Router();
@@ -668,6 +673,19 @@ router.post(
const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: `${connectionType}_connect`,
resourceType: "host",
resourceId: String(hostId),
resourceName: `${hostname}:${port}`,
ipAddress,
userAgent,
success: true,
});
res.json({
token,
guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
+25
View File
@@ -7,6 +7,11 @@ import type {
AuthenticatedRequest,
} from "../../../types/index.js";
import { CONNECTION_STATES } from "../../../types/index.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { tunnelLogger } from "../../utils/logger.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -363,6 +368,26 @@ export function registerTunnelRoutes(app: express.Express): void {
pendingTunnelOperations.set(tunnelName, operation);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "tunnel_connect",
resourceType: "tunnel",
resourceId: tunnelConfig.sourceHostId
? String(tunnelConfig.sourceHostId)
: undefined,
resourceName: tunnelName,
details: JSON.stringify({
endpointHost: tunnelConfig.endpointHost,
endpointPort: tunnelConfig.endpointPort,
sourcePort: tunnelConfig.sourcePort,
}),
ipAddress,
userAgent,
success: true,
});
res.json({ message: "Connection request received", tunnelName });
operation
@@ -0,0 +1,57 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const findById = vi.hoisted(() => vi.fn());
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentAuditLogRepository: () => ({ create: vi.fn() }),
createCurrentUserRepository: () => ({ findById }),
}));
const { getAuditUsername, getRequestMeta } =
await import("../../utils/audit-logger.js");
beforeEach(() => findById.mockReset());
describe("getAuditUsername", () => {
it("resolves the username to store alongside the entry", async () => {
findById.mockResolvedValueOnce({ id: "u-1", username: "alice" });
await expect(getAuditUsername("u-1")).resolves.toBe("alice");
});
it("falls back to the id for an account that no longer exists", async () => {
findById.mockResolvedValueOnce(undefined);
await expect(getAuditUsername("u-gone")).resolves.toBe("u-gone");
});
it("never throws, so it cannot break the operation being audited", async () => {
findById.mockRejectedValueOnce(new Error("database unavailable"));
await expect(getAuditUsername("u-1")).resolves.toBe("u-1");
});
});
describe("getRequestMeta", () => {
it("prefers the first x-forwarded-for hop", () => {
const meta = getRequestMeta({
headers: {
"x-forwarded-for": "203.0.113.9, 10.0.0.1",
"user-agent": "Mozilla/5.0",
},
ip: "10.0.0.1",
} as never);
expect(meta).toEqual({
ipAddress: "203.0.113.9",
userAgent: "Mozilla/5.0",
});
});
it("falls back to the socket address", () => {
const meta = getRequestMeta({ headers: {}, ip: "192.0.2.5" } as never);
expect(meta.ipAddress).toBe("192.0.2.5");
expect(meta.userAgent).toBe("");
});
});
+17 -1
View File
@@ -1,5 +1,21 @@
import type { Request } from "express";
import { createCurrentAuditLogRepository } from "../database/repositories/factory.js";
import {
createCurrentAuditLogRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
/**
* Resolves the display name to store alongside the entry. It is denormalised on
* purpose: the record has to stay readable after the account is gone.
*/
export async function getAuditUsername(userId: string): Promise<string> {
try {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
} catch {
return userId;
}
}
export interface AuditLogParams {
userId: string;