From cc4de8eb75a9f6cb695c4d958b6bdb3e57bdee46 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:03:45 +0200 Subject: [PATCH] feat(core-shared): truncateIp helper (/24 IPv4, /48 IPv6) per DPA Co-Authored-By: Claude Sonnet 4.6 --- .../core-shared/src/audit/truncate-ip.test.ts | 37 +++++++++++++++++++ packages/core-shared/src/audit/truncate-ip.ts | 27 ++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/core-shared/src/audit/truncate-ip.test.ts create mode 100644 packages/core-shared/src/audit/truncate-ip.ts diff --git a/packages/core-shared/src/audit/truncate-ip.test.ts b/packages/core-shared/src/audit/truncate-ip.test.ts new file mode 100644 index 0000000..0a35d5a --- /dev/null +++ b/packages/core-shared/src/audit/truncate-ip.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { truncateIp } from "./truncate-ip"; + +describe("truncateIp", () => { + describe("IPv4", () => { + it("truncates to /24 (zeros the last octet)", () => { + expect(truncateIp("192.168.1.42")).toBe("192.168.1.0"); + expect(truncateIp("10.0.0.255")).toBe("10.0.0.0"); + expect(truncateIp("8.8.8.8")).toBe("8.8.8.0"); + }); + + it("throws on malformed input", () => { + expect(() => truncateIp("192.168.1")).toThrow(/malformed IPv4/); + expect(() => truncateIp("192.168.1.foo")).toThrow(/malformed IPv4/); + expect(() => truncateIp("a.b.c.d")).toThrow(/malformed IPv4/); + }); + + it("throws on empty string", () => { + expect(() => truncateIp("")).toThrow(/malformed IPv4/); + }); + }); + + describe("IPv6", () => { + it("truncates to /48 (keeps first 3 hextets)", () => { + expect(truncateIp("2001:0db8:1234:5678:abcd:ef00:1234:5678")).toBe("2001:0db8:1234::"); + expect(truncateIp("2001:0db8:abcd:1234::")).toBe("2001:0db8:abcd::"); + }); + + it("lowercases hextets", () => { + expect(truncateIp("2001:0DB8:ABCD:5678::")).toBe("2001:0db8:abcd::"); + }); + + it("throws on too-few hextets", () => { + expect(() => truncateIp("2001:0db8")).toThrow(/malformed IPv6/); + }); + }); +}); diff --git a/packages/core-shared/src/audit/truncate-ip.ts b/packages/core-shared/src/audit/truncate-ip.ts new file mode 100644 index 0000000..e4a4d31 --- /dev/null +++ b/packages/core-shared/src/audit/truncate-ip.ts @@ -0,0 +1,27 @@ +/** + * Truncates an IP address per DPA: + * IPv4 → /24 ("192.168.1.42" → "192.168.1.0") + * IPv6 → /48 ("2001:0db8:1234:5678:..." → "2001:0db8:1234::") + * + * Throws on malformed input rather than silently returning the raw value — + * compliance regimes prefer hard failures over partial scrubbing. + */ +export function truncateIp(raw: string): string { + if (raw.includes(":")) { + // IPv6: keep first 3 hextets (48 bits) + const parts = raw.toLowerCase().split(":").filter((p) => p !== ""); + if (parts.length < 3) { + throw new Error(`truncateIp: malformed IPv6 address "${raw}"`); + } + return `${parts[0]}:${parts[1]}:${parts[2]}::`; + } + // IPv4: keep first 3 octets (24 bits) + const parts = raw.split("."); + if ( + parts.length !== 4 || + parts.some((p) => p === "" || isNaN(Number(p)) || !/^\d+$/.test(p)) + ) { + throw new Error(`truncateIp: malformed IPv4 address "${raw}"`); + } + return `${parts[0]}.${parts[1]}.${parts[2]}.0`; +}