diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index aacd37635..5fffa2b86 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; export * from "./queryConnectionAuditLog"; export * from "./exportConnectionAuditLog"; +export * from "./logAccessAuditAttempt"; diff --git a/server/private/routers/auditLogs/logAccessAuditAttempt.ts b/server/private/routers/auditLogs/logAccessAuditAttempt.ts new file mode 100644 index 000000000..5b0fb9c92 --- /dev/null +++ b/server/private/routers/auditLogs/logAccessAuditAttempt.ts @@ -0,0 +1,95 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; + +export const logAccessAuditAttemptSchema = z.object({ + resourceId: z.number().int().positive(), + action: z.boolean(), + type: z.enum(["login", "ssh", "vnc", "rdp"]) +}); + +export const logAccessAuditAttemptParams = z.object({ + orgId: z.string() +}); + +export async function logAccessAuditAttempt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error) + ) + ); + } + const parsedParams = logAccessAuditAttemptParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const { resourceId, action, type } = parsedBody.data; + + const username = req.user?.username; + const userId = req.user?.userId; + + await logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: action, + ...(username && userId + ? { + user: { + username, + userId + } + } + : {}), + type: type, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit attempt logged successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 0feca4154..9e819db64 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -22,7 +22,7 @@ import { import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -120,7 +120,10 @@ function getWhere(data: Q) { lt(accessAuditLog.timestamp, data.timeEnd), eq(accessAuditLog.orgId, data.orgId), data.resourceId - ? eq(accessAuditLog.resourceId, data.resourceId) + ? or( + eq(accessAuditLog.resourceId, data.resourceId), + eq(accessAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, data.actorType @@ -233,7 +236,6 @@ async function enrichWithResourceDetails( const details = siteResourceMap.get(log.siteResourceId); return { ...log, - resourceId: log.siteResourceId, resourceName: details?.name ?? null, resourceNiceId: details?.niceId ?? null }; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 3cecd2ebb..179ec89c7 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -878,3 +878,9 @@ authenticated.post( verifyClientAccess, client.rebuildClientAssociationsCacheRoute ); + +authenticated.post( + "/org/:orgId/logs/access/attempt", + verifyOrgAccess, + logs.logAccessAuditAttempt +); diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index b8168ef1e..15ca1e87e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = { actorType: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceName: string | null; resourceNiceId: string | null; ip: string | null; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index fc0660ebb..6ac2a30e8 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -342,7 +342,7 @@ export default function GeneralPage() { return ( { const typeLabel = - row.original.type === "ssh" - ? "SSH" + row.original.type === "ssh" || + row.original.type === "rdp" || + row.original.type === "vnc" + ? row.original.type.toUpperCase() : row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1); return {typeLabel || "-"}; @@ -513,7 +517,15 @@ export default function GeneralPage() { function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"]; - const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"]; + const types = [ + "password", + "pincode", + "login", + "whitelistedEmail", + "ssh", + "rdp", + "vnc" + ]; const actors = [ "alice@example.com", "bob@example.com", @@ -538,6 +550,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { actor, actorId: actor ? `user-${i}` : null, resourceId: Math.floor(Math.random() * 5) + 1, + siteResourceId: null, resourceNiceId: `resource-${(i % 3) + 1}`, resourceName: `Resource ${(i % 3) + 1}`, ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`, diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 1c7d77eb9..668a42cec 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -42,6 +42,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; declare module "react" { namespace JSX { @@ -96,6 +98,7 @@ export default function RdpClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_rdp_credentials"; const resourceName = target?.name?.trim() || null; @@ -311,6 +314,11 @@ export default function RdpClient({ values, target.authToken ); + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "rdp" + }); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); @@ -320,6 +328,11 @@ export default function RdpClient({ fileTransferRef.current = null; setShowLogin(true); } catch (err) { + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "rdp" + }); setConnecting(false); setShowLogin(true); if (isIronError(err)) { diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 7a2bcd425..8fc0423e7 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -36,6 +36,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type AuthTab = "password" | "privateKey"; @@ -73,6 +75,7 @@ export default function SshClient({ }) { const STORAGE_KEY = "pangolin_ssh_credentials"; const t = useTranslations(); + const api = createApiClient(useEnvContext()); const resourceName = target?.name?.trim() || null; const passwordTabSchema = z.object({ @@ -263,6 +266,17 @@ export default function SshClient({ let authConfirmed = false; let authErrorShown = false; let socketOpened = false; + let auditLogged = false; + + const logAudit = (action: boolean) => { + if (auditLogged || !target) return; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action, + type: "ssh" + }); + }; ws.onopen = () => { socketOpened = true; @@ -294,6 +308,7 @@ export default function SshClient({ if (msg.type === "data" && msg.data) { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -301,6 +316,7 @@ export default function SshClient({ } else if (msg.type === "error") { if (!authConfirmed) { authErrorShown = true; + logAudit(false); setConnecting(false); setConnectError( msg.error ?? t("sshErrorAuthFailed") @@ -323,6 +339,7 @@ export default function SshClient({ evt.data.text().then((text) => { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -332,6 +349,7 @@ export default function SshClient({ }; ws.onerror = () => { + logAudit(false); setConnecting(false); setConnected(false); setConnectError(t("sshErrorWebSocket")); @@ -355,6 +373,7 @@ export default function SshClient({ ); } if (!authConfirmed && !authErrorShown) { + logAudit(false); setConnectError(t("sshErrorConnectionClosed")); } }; diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index ac50bb587..b44ab9168 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -33,6 +33,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type VncCredentialsForm = { password: string; @@ -52,6 +54,7 @@ export default function VncClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_vnc_credentials"; const resourceName = target?.name?.trim() || null; @@ -179,6 +182,7 @@ export default function VncClient({ } let authConfirmed = false; + let auditLogged = false; rfb.scaleViewport = true; rfb.resizeSession = true; @@ -190,6 +194,12 @@ export default function VncClient({ target.authToken ); authConfirmed = true; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "vnc" + }); setConnecting(false); setConnected(true); }); @@ -201,6 +211,17 @@ export default function VncClient({ setConnecting(false); setConnected(false); if (!authConfirmed && !e.detail.clean) { + if (!auditLogged) { + auditLogged = true; + void api.post( + `/org/${target.orgId}/logs/access/attempt`, + { + resourceId: target.resourceId, + action: false, + type: "vnc" + } + ); + } setConnectError(t("sshErrorConnectionClosed")); } } @@ -209,6 +230,12 @@ export default function VncClient({ rfb.addEventListener( "securityfailure", (e: { detail: { status: number; reason?: string } }) => { + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "vnc" + }); disconnect(); setConnectError( e.detail.reason ??