From eb9c7d836af1086e878c26d63266d9f6c3da29ff Mon Sep 17 00:00:00 2001 From: Abdulrahman Firdausi Onize <138733058+nanaabdul1172@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:26:23 +0000 Subject: [PATCH 1/4] Added log ip-masking --- api/pnpm-lock.yaml | 38 +++++++++ api/src/audit/audit.interceptor.ts | 4 +- api/src/common/ip-mask.spec.ts | 33 ++++++++ api/src/common/ip-mask.ts | 78 +++++++++++++++++++ api/src/config/env.ts | 3 + .../middleware/request-logger.middleware.ts | 7 +- 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 api/src/common/ip-mask.spec.ts create mode 100644 api/src/common/ip-mask.ts diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 28f8d7b..e06ee35 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -622,6 +622,18 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -2023,6 +2035,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3445,6 +3460,27 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + '@types/bcrypt@6.0.0': dependencies: '@types/node': 20.19.30 @@ -5140,6 +5176,8 @@ snapshots: node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} diff --git a/api/src/audit/audit.interceptor.ts b/api/src/audit/audit.interceptor.ts index c15d234..0cce800 100644 --- a/api/src/audit/audit.interceptor.ts +++ b/api/src/audit/audit.interceptor.ts @@ -7,6 +7,8 @@ import { import { Observable, tap } from "rxjs" import { Request } from "express" import { AuditService } from "./audit.service" +import { env } from "../config/env" +import { getRequestIp, maskRequestIp } from "../common/ip-mask" const SENSITIVE_ACTIONS: Record = { "POST /auth/login": "login", @@ -28,7 +30,7 @@ export class AuditInterceptor implements NestInterceptor { if (!action) return next.handle() - const ip = (req.headers["x-forwarded-for"] as string) ?? req.ip ?? "" + const ip = maskRequestIp(getRequestIp(req), env.LOG_IP_MASKING) ?? "" const userId = (req as Request & { user?: { id: number } }).user?.id ?? null return next.handle().pipe( diff --git a/api/src/common/ip-mask.spec.ts b/api/src/common/ip-mask.spec.ts new file mode 100644 index 0000000..bae90ab --- /dev/null +++ b/api/src/common/ip-mask.spec.ts @@ -0,0 +1,33 @@ +import { maskRequestIp } from "./ip-mask" + +describe("maskRequestIp", () => { + it("returns null when the input is null", () => { + expect(maskRequestIp(null, "last-octet")).toBeNull() + }) + + it("returns the original IP when mode is none", () => { + expect(maskRequestIp("192.168.1.42", "none")).toBe("192.168.1.42") + expect(maskRequestIp("2001:db8::1", "none")).toBe("2001:db8::1") + }) + + it("masks the last octet for IPv4 addresses", () => { + expect(maskRequestIp("192.168.1.42", "last-octet")).toBe("192.168.1.0") + }) + + it("masks IPv4-mapped IPv6 addresses by zeroing the IPv4 octet", () => { + expect(maskRequestIp("::ffff:192.168.1.42", "last-octet")).toBe("::ffff:192.168.1.0") + }) + + it("masks the last 64 bits of IPv6 addresses", () => { + expect(maskRequestIp("2001:db8:85a3::8a2e:370:7334", "last-octet")).toBe( + "2001:db8:85a3:0:0:0:0:0", + ) + expect(maskRequestIp("2001:db8::1", "last-octet")).toBe( + "2001:db8:0:0:0:0:0:0", + ) + }) + + it("hashes the IP for full-hash mode", () => { + expect(maskRequestIp("192.168.1.42", "full-hash")).toMatch(/^sha256:[0-9a-f]{64}$/) + }) +}) diff --git a/api/src/common/ip-mask.ts b/api/src/common/ip-mask.ts new file mode 100644 index 0000000..87c6412 --- /dev/null +++ b/api/src/common/ip-mask.ts @@ -0,0 +1,78 @@ +import { createHash } from "crypto" +import { isIP } from "net" +import type { Request } from "express" + +export type LogIpMasking = "none" | "last-octet" | "full-hash" + +export function getRequestIp(req: Request): string | null { + return ( + (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? + req.ip ?? + null + ) +} + +export function maskRequestIp( + ip: string | null | undefined, + mode: LogIpMasking, +): string | null { + if (ip == null) return null + if (mode === "none") return ip + if (mode === "full-hash") return hashIp(ip) + return maskIpLastOctet(ip) +} + +function hashIp(ip: string): string { + return `sha256:${createHash("sha256").update(ip).digest("hex")}` +} + +function maskIpLastOctet(ip: string): string { + const trimmed = ip.trim() + if (!trimmed) return trimmed + + const v4Candidate = trimmed.split("/")[0] + if (isIP(v4Candidate) === 4) { + return maskIpv4(v4Candidate) + } + + if (isIpv4MappedIpv6(trimmed)) { + const mapped = trimmed.substring(trimmed.lastIndexOf(":") + 1) + return `::ffff:${maskIpv4(mapped)}` + } + + if (isIP(trimmed) === 6) { + return maskIpv6Last64(trimmed) + } + + return trimmed +} + +function maskIpv4(ip: string): string { + const parts = ip.split(".") + if (parts.length !== 4) return ip + parts[3] = "0" + return parts.join(".") +} + +function isIpv4MappedIpv6(ip: string): boolean { + return /^::ffff:(\d{1,3}\.){3}\d{1,3}$/i.test(ip) +} + +function maskIpv6Last64(ip: string): string { + const normalized = expandIpv6(ip) + const blocks = normalized.split(":") + return `${blocks.slice(0, 4).join(":")}:0:0:0:0` +} + +function expandIpv6(ip: string): string { + if (!ip.includes("::")) { + return ip + } + + const [left, right] = ip.split("::") + const leftBlocks = left ? left.split(":").filter(Boolean) : [] + const rightBlocks = right ? right.split(":").filter(Boolean) : [] + const missing = 8 - leftBlocks.length - rightBlocks.length + const zeros = Array(Math.max(0, missing)).fill("0") + return [...leftBlocks, ...zeros, ...rightBlocks].join(":") +} diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 2d9b2fd..43617d5 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -6,6 +6,9 @@ const envSchema = z.object({ DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), STREAM_API_KEY: z.string().min(1, "STREAM_API_KEY is required"), + LOG_IP_MASKING: z + .enum(["none", "last-octet", "full-hash"]) + .default("last-octet"), }) export type Env = z.infer diff --git a/api/src/middleware/request-logger.middleware.ts b/api/src/middleware/request-logger.middleware.ts index c19cae8..d1d9fde 100644 --- a/api/src/middleware/request-logger.middleware.ts +++ b/api/src/middleware/request-logger.middleware.ts @@ -1,5 +1,7 @@ import { Injectable, NestMiddleware } from "@nestjs/common" import { Request, Response, NextFunction } from "express" +import { env } from "../config/env" +import { getRequestIp, maskRequestIp } from "../common/ip-mask" const SENSITIVE_PATH_PATTERNS: RegExp[] = [/^\/auth\b/] @@ -11,10 +13,7 @@ export class RequestLoggerMiddleware implements NestMiddleware { const start = process.hrtime.bigint() const isSensitive = SENSITIVE_PATH_PATTERNS.some((re) => re.test(req.path)) const userId = req.user?.id ?? null - const ip = - (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? - req.ip ?? - null + const ip = maskRequestIp(getRequestIp(req), env.LOG_IP_MASKING) res.on("finish", () => { const durationMs = From cc518fb23460b03f87ca6bb4d2f7b0322e08e66f Mon Sep 17 00:00:00 2001 From: Abdulrahman Firdausi Onize <138733058+nanaabdul1172@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:35:19 +0000 Subject: [PATCH 2/4] fix(api): update request logger test for masked IP and add env defaults for Jest import --- .../middleware/request-logger.middleware.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api/src/middleware/request-logger.middleware.spec.ts b/api/src/middleware/request-logger.middleware.spec.ts index 7cb4130..001fbdc 100644 --- a/api/src/middleware/request-logger.middleware.spec.ts +++ b/api/src/middleware/request-logger.middleware.spec.ts @@ -1,7 +1,17 @@ import { Response } from "express" -import { RequestLoggerMiddleware } from "./request-logger.middleware" import type { AuthedRequest } from "./request-logger.middleware" +let RequestLoggerMiddleware: typeof import("./request-logger.middleware").RequestLoggerMiddleware + +beforeAll(() => { + process.env.DATABASE_URL ??= "postgres://localhost/test" + process.env.JWT_SECRET ??= "test-jwt-secret" + process.env.STREAM_API_KEY ??= "test-stream-api-key" + process.env.LOG_IP_MASKING ??= "last-octet" + jest.resetModules() + RequestLoggerMiddleware = require("./request-logger.middleware").RequestLoggerMiddleware +}) + type FakeResOptions = { statusCode?: number contentLength?: string | number | null @@ -98,7 +108,7 @@ describe("RequestLoggerMiddleware", () => { path: "/streams", statusCode: 201, userId: 42, - ip: "1.2.3.4", + ip: "1.2.3.0", bodyRedacted: false, contentLength: "128", }) From 502aedae811e6434020a83b86c0f00459b2a2b5a Mon Sep 17 00:00:00 2001 From: Abdulrahman Firdausi Onize <138733058+nanaabdul1172@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:01:33 +0000 Subject: [PATCH 3/4] fix(ci): update request logger IP masking assertion and fix test typing for lint --- api/src/auth/auth.service.spec.ts | 7 ++--- api/src/gateways/streams.gateway.spec.ts | 31 ++++++++++--------- .../request-logger.middleware.spec.ts | 5 +-- .../__tests__/metrics.test.ts | 12 ++++--- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 0de8150..caf29d3 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -4,6 +4,7 @@ import * as bcrypt from "bcrypt" import { AuthService } from "./auth.service" import { User, UsersRepository } from "./users.repository" import { TokenDenylistService } from "./token-denylist.service" +import { PasswordResetService } from "./password-reset.service" jest.mock("bcrypt", () => ({ hash: jest.fn(), @@ -58,10 +59,6 @@ function mockPasswordResetService(): MockPasswordResetService { } } -interface MockJwtDecodedPayload { - exp?: number -} - function makeService( jwt: MockJwtService, users: MockUsersRepository, @@ -71,7 +68,7 @@ function makeService( return new AuthService( jwt as unknown as JwtService, users as unknown as UsersRepository, - passwordReset as unknown as any, + passwordReset as unknown as PasswordResetService, tokenDenylist as unknown as TokenDenylistService, ) } diff --git a/api/src/gateways/streams.gateway.spec.ts b/api/src/gateways/streams.gateway.spec.ts index bf90984..01814a2 100644 --- a/api/src/gateways/streams.gateway.spec.ts +++ b/api/src/gateways/streams.gateway.spec.ts @@ -1,5 +1,6 @@ import { Test } from "@nestjs/testing" import { JwtService } from "@nestjs/jwt" +import type { Server } from "socket.io" import { StreamsGateway } from "./streams.gateway" import { STREAM_EVENTS } from "./stream-events" @@ -36,6 +37,8 @@ function makeSocket(overrides: Partial = {}): FakeSocket { } } +type TestSocket = FakeSocket & { data: { userId?: string | number } } + function makeServer(): { server: FakeServer events: Array<{ room: string; event: string; payload: unknown }> @@ -74,7 +77,7 @@ describe("StreamsGateway", () => { }) jwtService.verifyAsync.mockResolvedValue({ sub: 42 }) - await gateway.handleConnection(socket as unknown as any) + await gateway.handleConnection(socket as TestSocket) expect(jwtService.verifyAsync).toHaveBeenCalledWith("valid-token") expect(socket.data.userId).toBe(42) @@ -86,7 +89,7 @@ describe("StreamsGateway", () => { const socket = makeSocket({ handshake: { auth: { token: "bad-token" } } }) jwtService.verifyAsync.mockRejectedValue(new Error("jwt malformed")) - await gateway.handleConnection(socket as unknown as any) + await gateway.handleConnection(socket as TestSocket) expect(socket.emit).toHaveBeenCalledWith( STREAM_EVENTS.ERROR, @@ -101,7 +104,7 @@ describe("StreamsGateway", () => { it("rejects a client with no token", async () => { const socket = makeSocket({ handshake: { auth: {} } }) - await gateway.handleConnection(socket as unknown as any) + await gateway.handleConnection(socket as TestSocket) expect(socket.emit).toHaveBeenCalledWith( STREAM_EVENTS.ERROR, @@ -123,7 +126,7 @@ describe("StreamsGateway", () => { }) jwtService.verifyAsync.mockResolvedValue({ sub: "user-99" }) - await gateway.handleConnection(socket as unknown as any) + await gateway.handleConnection(socket as TestSocket) expect(jwtService.verifyAsync).toHaveBeenCalledWith("header-token") expect(socket.emit).toHaveBeenCalledWith("connected", { @@ -135,7 +138,7 @@ describe("StreamsGateway", () => { describe("stream room lifecycle", () => { it("allows an authenticated client to subscribe", () => { const socket = makeSocket({ data: { userId: 55 } }) - const result = gateway.handleSubscribe(socket as unknown as any, { + const result = gateway.handleSubscribe(socket as TestSocket, { streamId: "abc", }) @@ -145,7 +148,7 @@ describe("StreamsGateway", () => { it("rejects an unauthenticated client from subscribing", () => { const socket = makeSocket({ data: {} }) - const result = gateway.handleSubscribe(socket as unknown as any, { + const result = gateway.handleSubscribe(socket as TestSocket, { streamId: "abc", }) @@ -155,7 +158,7 @@ describe("StreamsGateway", () => { it("rejects an authenticated client from subscribing without a streamId", () => { const socket = makeSocket({ data: { userId: 55 } }) - const result = gateway.handleSubscribe(socket as unknown as any, {}) + const result = gateway.handleSubscribe(socket as TestSocket, {}) expect(result).toEqual({ ok: false, error: "streamId required" }) expect(socket.join).not.toHaveBeenCalled() @@ -163,7 +166,7 @@ describe("StreamsGateway", () => { it("allows an authenticated client to unsubscribe", () => { const socket = makeSocket({ data: { userId: 55 } }) - const result = gateway.handleUnsubscribe(socket as unknown as any, { + const result = gateway.handleUnsubscribe(socket as TestSocket, { streamId: "abc", }) @@ -173,7 +176,7 @@ describe("StreamsGateway", () => { it("rejects an unauthenticated client from unsubscribing", () => { const socket = makeSocket({ data: {} }) - const result = gateway.handleUnsubscribe(socket as unknown as any, { + const result = gateway.handleUnsubscribe(socket as TestSocket, { streamId: "abc", }) @@ -183,7 +186,7 @@ describe("StreamsGateway", () => { it("rejects unsubscribe calls without a streamId", () => { const socket = makeSocket({ data: { userId: 55 } }) - const result = gateway.handleUnsubscribe(socket as unknown as any, {}) + const result = gateway.handleUnsubscribe(socket as TestSocket, {}) expect(result).toEqual({ ok: false, error: "streamId required" }) expect(socket.leave).not.toHaveBeenCalled() @@ -191,8 +194,8 @@ describe("StreamsGateway", () => { it("supports duplicate subscriptions without failure", () => { const socket = makeSocket({ data: { userId: 55 } }) - gateway.handleSubscribe(socket as unknown as any, { streamId: "abc" }) - const second = gateway.handleSubscribe(socket as unknown as any, { + gateway.handleSubscribe(socket as TestSocket, { streamId: "abc" }) + const second = gateway.handleSubscribe(socket as TestSocket, { streamId: "abc", }) @@ -204,7 +207,7 @@ describe("StreamsGateway", () => { describe("emit helpers", () => { it("broadcasts only to the correct stream room", () => { const { server, events } = makeServer() - gateway.server = server as unknown as any + gateway.server = server as unknown as Server gateway.emitStarted({ streamId: "1", @@ -260,7 +263,7 @@ describe("StreamsGateway", () => { it("does not throw when a socket disconnects", () => { const socket = makeSocket({ data: { userId: 99 } }) expect(() => - gateway.handleDisconnect(socket as unknown as any), + gateway.handleDisconnect(socket as TestSocket), ).not.toThrow() }) }) diff --git a/api/src/middleware/request-logger.middleware.spec.ts b/api/src/middleware/request-logger.middleware.spec.ts index 001fbdc..3b5cb7d 100644 --- a/api/src/middleware/request-logger.middleware.spec.ts +++ b/api/src/middleware/request-logger.middleware.spec.ts @@ -3,13 +3,14 @@ import type { AuthedRequest } from "./request-logger.middleware" let RequestLoggerMiddleware: typeof import("./request-logger.middleware").RequestLoggerMiddleware -beforeAll(() => { +beforeAll(async () => { process.env.DATABASE_URL ??= "postgres://localhost/test" process.env.JWT_SECRET ??= "test-jwt-secret" process.env.STREAM_API_KEY ??= "test-stream-api-key" process.env.LOG_IP_MASKING ??= "last-octet" jest.resetModules() - RequestLoggerMiddleware = require("./request-logger.middleware").RequestLoggerMiddleware + const module = await import("./request-logger.middleware") + RequestLoggerMiddleware = module.RequestLoggerMiddleware }) type FakeResOptions = { diff --git a/xstreamroll-processing/__tests__/metrics.test.ts b/xstreamroll-processing/__tests__/metrics.test.ts index 9c74a61..c1795db 100644 --- a/xstreamroll-processing/__tests__/metrics.test.ts +++ b/xstreamroll-processing/__tests__/metrics.test.ts @@ -116,8 +116,9 @@ describe("metrics server", () => { await expect(axios.get(`${baseUrl}/invalid-route`)).rejects.toThrow() try { await axios.get(`${baseUrl}/invalid-route`) - } catch (err: any) { - expect(err.response.status).toBe(404) + } catch (err: unknown) { + const axiosError = err as { response?: { status: number } } + expect(axiosError.response?.status).toBe(404) } }) @@ -147,9 +148,10 @@ describe("metrics server", () => { try { await axios.get(`${baseUrl}/healthz`) throw new Error("expected request to fail") - } catch (err: any) { - expect(err.response.status).toBe(503) - expect(err.response.data.status).toBe("shutting-down") + } catch (err: unknown) { + const axiosError = err as { response?: { status: number; data?: { status: string } } } + expect(axiosError.response?.status).toBe(503) + expect(axiosError.response?.data?.status).toBe("shutting-down") } }) }) From 7b7f77cb7e46cebcd17a28a4ce4306fe76b3be8a Mon Sep 17 00:00:00 2001 From: Abdulrahman Firdausi Onize <138733058+nanaabdul1172@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:08:00 +0000 Subject: [PATCH 4/4] chore(ci): rerun quality and bundle analysis