Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { scrubPII } from "./pii.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# `pii.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEvent` plus an ordered array of `ScrubRule`s and returns a new event with the affected `properties` either redacted, replaced with a deterministic fingerprint, or dropped entirely. Scrubbing runs on the tracker hot path between consent / sampling decisions and the queue write, so it must be synchronous, side-effect-free, and never throw on valid input. The function clones `properties` via `structuredClone` so the original event remains immutable.
|
||||
|
||||
## Features
|
||||
|
||||
- Three strategies: `"redact"` (replace value with `"[REDACTED]"`), `"hash"` (replace with FNV-1a fingerprint), `"drop"` (delete the key).
|
||||
- Dotted-path field resolution — `"user.email"` walks into nested objects.
|
||||
- Order-preserving — rules apply in the order given so later rules see the effect of earlier ones.
|
||||
- Pure / synchronous — no async, no I/O, no `subtle.digest`.
|
||||
- Idempotent — the same input + rules always yields the same output (including the fingerprint).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const safe = scrubPII(event, [
|
||||
{ field: "email", strategy: "redact" },
|
||||
{ field: "ssn", strategy: "drop" },
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ScrubRule } from "@suaveplan/analytics-browser/types";
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const piiRules: readonly ScrubRule[] = [
|
||||
{ field: "user.email", strategy: "redact" },
|
||||
{ field: "user.phone", strategy: "hash" },
|
||||
{ field: "payment.cardNumber", strategy: "drop" },
|
||||
];
|
||||
|
||||
function safeTrack(name: string, properties: Record<string, unknown>) {
|
||||
const wrapped = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: "s",
|
||||
anonymousId: "a",
|
||||
};
|
||||
return scrubPII(wrapped, piiRules);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `scrubPII(event: AnalyticsEvent, rules: readonly ScrubRule[]): AnalyticsEvent` — returns a fresh event with scrubbed `properties`. Returns the original event reference when `rules.length === 0`.
|
||||
- Rule shape: `{ field: string; strategy: "redact" | "hash" | "drop" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fingerprint is FNV-1a 32-bit, encoded as 8 lowercase hex chars. FNV-1a was chosen over Web Crypto's `subtle.digest` because the latter is asynchronous, which would force the entire scrub pipeline onto a Promise chain and complicate the tracker's hot path. FNV-1a is NOT a cryptographic hash — it is a redaction that preserves cross-event correlation (the same value always fingerprints to the same hex string) without revealing the value itself. If a stronger guarantee is required, downstream code SHOULD scrub via a custom rule sequence and a pre-hashed input value, or implement its own `Sampler`-style hook.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { AnalyticsEvent } from "../types/analytics.types.js";
|
||||
import { scrubPII } from "./pii.js";
|
||||
|
||||
const base: AnalyticsEvent = {
|
||||
id: "i",
|
||||
name: "n",
|
||||
properties: {
|
||||
email: "a@b.co",
|
||||
phone: "555-0100",
|
||||
ssn: "123-45-6789",
|
||||
user: { email: "nested@b.co", id: 7 },
|
||||
},
|
||||
timestamp: "t",
|
||||
sessionId: "s",
|
||||
anonymousId: "a",
|
||||
};
|
||||
|
||||
describe("scrubPII", () => {
|
||||
it("redact replaces value with [REDACTED]", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [{ field: "email", strategy: "redact" }]);
|
||||
expect(out.properties.email).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("drop removes the field entirely", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [{ field: "ssn", strategy: "drop" }]);
|
||||
expect("ssn" in out.properties).toBe(false);
|
||||
});
|
||||
|
||||
it("hash produces a deterministic fingerprint", { timeout: 5000 }, () => {
|
||||
const a = scrubPII(base, [{ field: "phone", strategy: "hash" }]);
|
||||
const b = scrubPII(base, [{ field: "phone", strategy: "hash" }]);
|
||||
expect(a.properties.phone).toBe(b.properties.phone);
|
||||
expect(a.properties.phone).not.toBe(base.properties.phone);
|
||||
expect(typeof a.properties.phone).toBe("string");
|
||||
expect((a.properties.phone as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dotted path resolves into nested objects", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [
|
||||
{ field: "user.email", strategy: "redact" },
|
||||
]);
|
||||
const nested = out.properties.user as { email: string };
|
||||
expect(nested.email).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("missing field is a no-op", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [{ field: "missing", strategy: "drop" }]);
|
||||
expect(out.properties).toEqual(base.properties);
|
||||
});
|
||||
|
||||
it("returns the original event when no rules", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, []);
|
||||
expect(out).toBe(base);
|
||||
});
|
||||
|
||||
it("hashes objects via JSON stringification", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [{ field: "user", strategy: "hash" }]);
|
||||
expect(typeof out.properties.user).toBe("string");
|
||||
});
|
||||
|
||||
it("dotted path with non-object segment is a no-op", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = scrubPII(base, [
|
||||
{ field: "email.deeper", strategy: "redact" },
|
||||
]);
|
||||
expect(out.properties.email).toBe("a@b.co");
|
||||
});
|
||||
|
||||
it("empty path is a no-op", { timeout: 5000 }, () => {
|
||||
const out = scrubPII(base, [{ field: "", strategy: "redact" }]);
|
||||
// splitting "" yields [""] — applyAt's last="" check resolves and
|
||||
// operates on the "" key, which does not exist, so it is a no-op.
|
||||
expect(out.properties).toEqual(base.properties);
|
||||
});
|
||||
|
||||
it("does not mutate the source event", { timeout: 5000 }, () => {
|
||||
const before = JSON.stringify(base.properties);
|
||||
scrubPII(base, [{ field: "email", strategy: "drop" }]);
|
||||
expect(JSON.stringify(base.properties)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AnalyticsEvent, ScrubRule } from "../types/analytics.types.js";
|
||||
|
||||
const REDACTED = "[REDACTED]";
|
||||
const FNV_OFFSET = 0x811c9dc5;
|
||||
const FNV_PRIME = 0x01000193;
|
||||
|
||||
/**
|
||||
* Deterministic 32-bit FNV-1a fingerprint of a value's stringified
|
||||
* form. Used for the `"hash"` scrub strategy. FNV-1a is fast, has good
|
||||
* avalanche behaviour for short strings, and is fully synchronous (Web
|
||||
* Crypto's `subtle.digest` is async, which would force the entire scrub
|
||||
* function into a Promise chain on the tracker hot path).
|
||||
*
|
||||
* The output is 8 lowercase hex chars; it is NOT a cryptographic hash
|
||||
* and MUST NOT be relied on for security. It is a one-way redaction
|
||||
* that preserves cross-event correlation (the same input always yields
|
||||
* the same fingerprint).
|
||||
*/
|
||||
function fingerprint(value: unknown): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
let h = FNV_OFFSET;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, FNV_PRIME);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
function applyAt(
|
||||
properties: Record<string, unknown>,
|
||||
path: readonly string[],
|
||||
strategy: ScrubRule["strategy"]
|
||||
): void {
|
||||
// `scrubPII` always splits a non-empty `rule.field`, which produces a
|
||||
// path with at least one element. The cast below documents the
|
||||
// invariant without an unreachable guard.
|
||||
const last = path[path.length - 1] as string;
|
||||
let cursor: Record<string, unknown> = properties;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const segment = path[i] as string;
|
||||
const next = cursor[segment];
|
||||
if (next === null || typeof next !== "object") {
|
||||
return;
|
||||
}
|
||||
cursor = next as Record<string, unknown>;
|
||||
}
|
||||
if (!(last in cursor)) {
|
||||
return;
|
||||
}
|
||||
switch (strategy) {
|
||||
case "redact": {
|
||||
cursor[last] = REDACTED;
|
||||
return;
|
||||
}
|
||||
case "drop": {
|
||||
delete cursor[last];
|
||||
return;
|
||||
}
|
||||
case "hash": {
|
||||
cursor[last] = fingerprint(cursor[last]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply field-level scrub rules to an analytics event, returning a new
|
||||
* event with the scrubbed properties. Rules apply in order; later rules
|
||||
* see the effect of earlier rules (e.g. a `redact` followed by a
|
||||
* `drop` will drop a missing key — a no-op).
|
||||
*/
|
||||
export function scrubPII(
|
||||
event: AnalyticsEvent,
|
||||
rules: readonly ScrubRule[]
|
||||
): AnalyticsEvent {
|
||||
if (rules.length === 0) {
|
||||
return event;
|
||||
}
|
||||
const cloned: Record<string, unknown> = structuredClone(event.properties);
|
||||
for (const rule of rules) {
|
||||
const path = rule.field.split(".");
|
||||
applyAt(cloned, path, rule.strategy);
|
||||
}
|
||||
return { ...event, properties: cloned };
|
||||
}
|
||||
Reference in New Issue
Block a user