@sudobility/sider_types 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -5,5 +5,6 @@ export * from "./local";
5
5
  export * from "./api";
6
6
  export * from "./actions";
7
7
  export * from "./templating";
8
+ export * from "./personal-data";
8
9
  export * from "./recipe-hash";
9
10
  export * from "./observe";
package/dist/index.js CHANGED
@@ -11,5 +11,6 @@ export * from "./local";
11
11
  export * from "./api";
12
12
  export * from "./actions";
13
13
  export * from "./templating";
14
+ export * from "./personal-data";
14
15
  export * from "./recipe-hash";
15
16
  export * from "./observe";
package/dist/observe.d.ts CHANGED
@@ -23,12 +23,27 @@ export interface SnapshotLink {
23
23
  toUrlPath: string;
24
24
  label?: string;
25
25
  }
26
+ /**
27
+ * An anchor on the page pointing at a DIFFERENT origin.
28
+ *
29
+ * Kept apart from `SnapshotLink` on purpose: `links` feeds view identity and the
30
+ * graph's declared transitions, and a cross-origin entry there would churn every
31
+ * view signature. This is evidence of one thing only — that this site offers a
32
+ * way to reach that one.
33
+ */
34
+ export interface SnapshotExternalLink {
35
+ origin: string;
36
+ /** Visible text of the link, e.g. "Careers at USF". */
37
+ label?: string;
38
+ }
26
39
  export interface PageSnapshot {
27
40
  /** Path with query VALUES dropped and keys kept: /search?q={q} */
28
41
  urlPath: string;
29
42
  title?: string;
30
43
  regions: SnapshotRegion[];
31
44
  links: SnapshotLink[];
45
+ /** Off-site origins this page links to. Absent from older extension builds. */
46
+ externalLinks?: SnapshotExternalLink[];
32
47
  /** Structural text only — headings, control names, link labels, item titles. */
33
48
  contentMd?: string;
34
49
  /** How the agent arrived here, when known. */
@@ -0,0 +1,37 @@
1
+ /** A payment card, verified by Luhn. */
2
+ export declare function isLuhnValid(digits: string): boolean;
3
+ /**
4
+ * An IBAN, verified mod-97.
5
+ *
6
+ * The four leading characters move to the end, letters become numbers, and the
7
+ * whole thing modulo 97 must be 1. A string that satisfies that is an IBAN; the
8
+ * odds of a product code doing so by accident are one in ninety-seven.
9
+ */
10
+ export declare function looksLikeIban(value: string): boolean;
11
+ /**
12
+ * A US bank routing number, verified by its ABA check digit.
13
+ *
14
+ * Nine digits alone say nothing. This weighting is what a bank uses to reject a
15
+ * mistyped number, and it rejects roughly nine in ten arbitrary sequences.
16
+ */
17
+ export declare function looksLikeRoutingNumber(value: string): boolean;
18
+ /** Whether a single value is personal on its own evidence. */
19
+ export declare function isSensitiveValue(value: string): boolean;
20
+ /**
21
+ * Free text with every recognised personal value replaced.
22
+ *
23
+ * Ordered so the most specific rules run first: a card number inside an address
24
+ * line should be removed as a card, not left behind by a street rule that
25
+ * matched around it.
26
+ */
27
+ export declare function redactSensitiveText(text: string, placeholder: string): string;
28
+ /** What a removed value becomes. A string, so an inferred shape is unchanged. */
29
+ export declare const REDACTED = "[redacted]";
30
+ /**
31
+ * Free text with every recognisable personal value removed.
32
+ *
33
+ * The one entry point both sides use: page markdown on its way to the shared
34
+ * graph, and a user's retained request on its way to the database.
35
+ */
36
+ export declare function redactPersonalText(text: string): string;
37
+ export declare function redactPersonalText(text: string | undefined): string | undefined;
@@ -0,0 +1,166 @@
1
+ // Recognising a personal value by its SHAPE, not by what a site called it.
2
+ //
3
+ // Shared rather than browser-only, because the two sides need the same answer.
4
+ // The extension redacts what it sends; the server redacts what it KEEPS — a
5
+ // user's typed request has to arrive intact for the agent to act on it, and
6
+ // nothing says the copy retained afterwards must still hold their address.
7
+ // One recogniser, so those two decisions cannot drift apart.
8
+ //
9
+ // Field names are a losing game on their own: a card arrives as `pan`, `cc1` or
10
+ // `number`, and page text has no field names at all. What travels is a body, a
11
+ // URL, the rendered text of the page and the user's own words — and prose is
12
+ // where an order confirmation prints a delivery address that no name list will
13
+ // ever see.
14
+ //
15
+ // Every rule here is CHECKSUMMED or strongly structured, and that is the whole
16
+ // design. A redactor that guesses destroys the content the corpus exists to
17
+ // hold: nine digits are a routing number, an order id, a product code and a
18
+ // timestamp, and a rule that cannot tell them apart is worse than no rule,
19
+ // because it quietly empties the corpus while claiming to protect someone.
20
+ //
21
+ // So: IBAN and routing numbers are verified by their own check digits, cards by
22
+ // Luhn, and the patterns without a checksum (address, ZIP, name) require
23
+ // context a false positive does not have.
24
+ /** An IBAN: country, check digits, then the account, verified mod-97. */
25
+ const IBAN_CANDIDATE = /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g;
26
+ /** Nine digits: a US routing number, or a great many other things. */
27
+ const ROUTING_CANDIDATE = /\b\d{9}\b/g;
28
+ /**
29
+ * A US SSN, hyphenated only.
30
+ *
31
+ * Bare nine digits are deliberately NOT matched: that is an order number as
32
+ * often as a person, and the hyphens are what make the intent unambiguous.
33
+ * The excluded ranges are the ones the SSA never issues, which removes the
34
+ * bulk of lookalike sequences.
35
+ */
36
+ const SSN_PATTERN = /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g;
37
+ /**
38
+ * A phone number that was WRITTEN as one.
39
+ *
40
+ * Separators, parentheses or a country code are required. A bare run of ten
41
+ * digits is an order id, a SKU or a tracking number far more often than a
42
+ * phone, and redacting those would gut ordinary commerce pages.
43
+ */
44
+ const PHONE_PATTERN = /(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])\d{3}[ .-]\d{4}\b/g;
45
+ /** Street suffixes, spelled out or abbreviated. */
46
+ const STREET_SUFFIX = "street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|parkway|pkwy|highway|hwy|square|sq";
47
+ /**
48
+ * A street address: a building number, a name, and a suffix.
49
+ *
50
+ * The suffix is what carries the precision. "123 Main Street" is an address;
51
+ * "123 Main" is a heading, a product name or half a sentence, and requiring the
52
+ * suffix is what separates them without a dictionary of street names.
53
+ */
54
+ const STREET_PATTERN = new RegExp(String.raw `\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,4}(?:${STREET_SUFFIX})\b\.?`, "gi");
55
+ /**
56
+ * A postal code, but only where the page says it is one.
57
+ *
58
+ * Five bare digits are a price, a year, a quantity and a part number. The
59
+ * context words are what make it an address — "Shipping to 94122" on a search
60
+ * page is the user's own location, printed by the site for them alone.
61
+ */
62
+ const CONTEXTUAL_POSTAL_PATTERN = /((?:zip|postal\s*code|ship(?:ping|s|ped)?\s*to|deliver(?:y|ed)?\s*to|located\s*in|(?:mi|miles|km)\s*from)\W{0,12})(\d{5}(?:-\d{4})?)\b/gi;
63
+ /**
64
+ * The name a page greets the signed-in user by.
65
+ *
66
+ * Names cannot be found in prose in general, and trying would ruin the text.
67
+ * A greeting is the exception: "Hi John!" is not a sentence about a person, it
68
+ * is the site telling us whose session this is — which is exactly the fact that
69
+ * must not travel. eBay's header prints it on every page.
70
+ */
71
+ const GREETING_PATTERN = /\b([Hh]i|[Hh]ello|[Hh]ey|[Ww]elcome\s+back|[Ss]igned\s+in\s+as|[Ll]ogged\s+in\s+as|[Gg]ood\s+(?:morning|afternoon|evening))([,!]?\s+)([A-Z][a-z]{1,20})\b/g;
72
+ /** A payment card, verified by Luhn. */
73
+ export function isLuhnValid(digits) {
74
+ if (!/^\d{13,19}$/.test(digits))
75
+ return false;
76
+ let sum = 0;
77
+ let double = false;
78
+ for (let i = digits.length - 1; i >= 0; i--) {
79
+ let d = digits.charCodeAt(i) - 48;
80
+ if (double) {
81
+ d *= 2;
82
+ if (d > 9)
83
+ d -= 9;
84
+ }
85
+ sum += d;
86
+ double = !double;
87
+ }
88
+ return sum % 10 === 0;
89
+ }
90
+ /**
91
+ * An IBAN, verified mod-97.
92
+ *
93
+ * The four leading characters move to the end, letters become numbers, and the
94
+ * whole thing modulo 97 must be 1. A string that satisfies that is an IBAN; the
95
+ * odds of a product code doing so by accident are one in ninety-seven.
96
+ */
97
+ export function looksLikeIban(value) {
98
+ const compact = value.replace(/\s/g, "").toUpperCase();
99
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact))
100
+ return false;
101
+ const rearranged = compact.slice(4) + compact.slice(0, 4);
102
+ let remainder = 0;
103
+ for (const ch of rearranged) {
104
+ const digits = ch >= "A" && ch <= "Z" ? String(ch.charCodeAt(0) - 55) : ch;
105
+ for (const d of digits)
106
+ remainder = (remainder * 10 + (d.charCodeAt(0) - 48)) % 97;
107
+ }
108
+ return remainder === 1;
109
+ }
110
+ /**
111
+ * A US bank routing number, verified by its ABA check digit.
112
+ *
113
+ * Nine digits alone say nothing. This weighting is what a bank uses to reject a
114
+ * mistyped number, and it rejects roughly nine in ten arbitrary sequences.
115
+ */
116
+ export function looksLikeRoutingNumber(value) {
117
+ const d = value.replace(/[\s-]/g, "");
118
+ if (!/^\d{9}$/.test(d))
119
+ return false;
120
+ const n = [...d].map(c => c.charCodeAt(0) - 48);
121
+ const sum = 3 * (n[0] + n[3] + n[6]) + 7 * (n[1] + n[4] + n[7]) + (n[2] + n[5] + n[8]);
122
+ return sum % 10 === 0 && sum > 0;
123
+ }
124
+ /** Whether a single value is personal on its own evidence. */
125
+ export function isSensitiveValue(value) {
126
+ const trimmed = value.trim();
127
+ if (!trimmed)
128
+ return false;
129
+ if (isLuhnValid(trimmed.replace(/[ -]/g, "")))
130
+ return true;
131
+ if (looksLikeIban(trimmed))
132
+ return true;
133
+ if (looksLikeRoutingNumber(trimmed))
134
+ return true;
135
+ SSN_PATTERN.lastIndex = 0;
136
+ return SSN_PATTERN.test(trimmed);
137
+ }
138
+ /**
139
+ * Free text with every recognised personal value replaced.
140
+ *
141
+ * Ordered so the most specific rules run first: a card number inside an address
142
+ * line should be removed as a card, not left behind by a street rule that
143
+ * matched around it.
144
+ */
145
+ export function redactSensitiveText(text, placeholder) {
146
+ return text
147
+ .replace(/\b\d(?:[ -]?\d){12,18}\b/g, m => (isLuhnValid(m.replace(/[ -]/g, "")) ? placeholder : m))
148
+ .replace(IBAN_CANDIDATE, m => (looksLikeIban(m) ? placeholder : m))
149
+ .replace(SSN_PATTERN, placeholder)
150
+ .replace(ROUTING_CANDIDATE, m => (looksLikeRoutingNumber(m) ? placeholder : m))
151
+ .replace(PHONE_PATTERN, placeholder)
152
+ .replace(STREET_PATTERN, placeholder)
153
+ // The context word is kept and only the value replaced: "Shipping to
154
+ // [redacted]" still tells a reader what the page was doing there.
155
+ .replace(CONTEXTUAL_POSTAL_PATTERN, (_m, lead) => `${lead}${placeholder}`)
156
+ .replace(GREETING_PATTERN, (_m, greet, gap) => `${greet}${gap}${placeholder}`);
157
+ }
158
+ /** What a removed value becomes. A string, so an inferred shape is unchanged. */
159
+ export const REDACTED = "[redacted]";
160
+ /** An email anywhere in free text. */
161
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
162
+ export function redactPersonalText(text) {
163
+ if (!text)
164
+ return text;
165
+ return redactSensitiveText(text.replace(EMAIL_PATTERN, REDACTED), REDACTED);
166
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ import { test, expect } from "bun:test";
2
+ import { isLuhnValid, isSensitiveValue, looksLikeIban, looksLikeRoutingNumber, redactPersonalText, redactSensitiveText, } from "./personal-data";
3
+ const R = "[redacted]";
4
+ const redact = (text) => redactSensitiveText(text, R);
5
+ // --- checksummed: financial -------------------------------------------------
6
+ test("an IBAN is recognised by its own check digits", () => {
7
+ expect(looksLikeIban("GB82 WEST 1234 5698 7654 32")).toBe(true);
8
+ expect(looksLikeIban("DE89370400440532013000")).toBe(true);
9
+ });
10
+ test("a string shaped like an IBAN but failing mod-97 is left alone", () => {
11
+ // The odds of a product code passing by accident are one in ninety-seven,
12
+ // which is what makes the rule safe to run over an entire page.
13
+ expect(looksLikeIban("GB82WEST12345698765433")).toBe(false);
14
+ expect(looksLikeIban("US12ORDERNUMBER1234567")).toBe(false);
15
+ });
16
+ test("a routing number is recognised by its ABA check digit", () => {
17
+ expect(looksLikeRoutingNumber("021000021")).toBe(true); // JPMorgan Chase
18
+ expect(looksLikeRoutingNumber("011401533")).toBe(true); // Bank of America
19
+ });
20
+ test("nine digits that are not a routing number survive", () => {
21
+ // Nine digits are an order id far more often than a bank. Redacting them all
22
+ // would empty the corpus while protecting nobody.
23
+ expect(looksLikeRoutingNumber("123456789")).toBe(false);
24
+ expect(looksLikeRoutingNumber("987654321")).toBe(false);
25
+ });
26
+ test("a card is recognised by Luhn wherever it hides", () => {
27
+ expect(isLuhnValid("4111111111111111")).toBe(true);
28
+ expect(isLuhnValid("4111111111111112")).toBe(false);
29
+ });
30
+ // --- text: what actually appeared on the page -------------------------------
31
+ test("removes the name a site greets the user by", () => {
32
+ // eBay prints this in the header of every page, and the whole page text was
33
+ // being sent as evidence when an action was blocked.
34
+ expect(redact("Hi John! Deals Brand Outlet")).toBe(`Hi ${R}! Deals Brand Outlet`);
35
+ expect(redact("Welcome back, Sarah")).toBe(`Welcome back, ${R}`);
36
+ });
37
+ test("removes a postal code the page says is a postal code", () => {
38
+ expect(redact("Shipping to 94122")).toBe(`Shipping to ${R}`);
39
+ expect(redact("Zip 10001-1234")).toBe(`Zip ${R}`);
40
+ });
41
+ test("leaves five bare digits alone", () => {
42
+ // A price, a year, a quantity, a part number. Without the context word there
43
+ // is nothing to say this is anyone's address.
44
+ expect(redact("Only 94122 units sold")).toBe("Only 94122 units sold");
45
+ expect(redact("$12345 or best offer")).toBe("$12345 or best offer");
46
+ });
47
+ test("removes a street address", () => {
48
+ expect(redact("Ship to 1600 Amphitheatre Parkway")).toContain(R);
49
+ expect(redact("742 Evergreen Terrace")).toBe(R);
50
+ });
51
+ test("leaves a house number with no street suffix alone", () => {
52
+ // "123 Main" is a heading or half a sentence; the suffix is what makes an
53
+ // address an address without a dictionary of every street name.
54
+ expect(redact("123 Main")).toBe("123 Main");
55
+ expect(redact("2000 watts of power")).toBe("2000 watts of power");
56
+ });
57
+ test("removes a written phone number but not an order id", () => {
58
+ expect(redact("call (415) 555-1234 now")).toBe(`call ${R} now`);
59
+ expect(redact("+1 415-555-1234")).toBe(R);
60
+ // Ten bare digits: a tracking number as often as a phone.
61
+ expect(redact("Tracking 4155551234")).toBe("Tracking 4155551234");
62
+ });
63
+ test("removes an SSN only when written as one", () => {
64
+ expect(redact("SSN 123-45-6789")).toBe(`SSN ${R}`);
65
+ // Ranges the SSA never issues, so a lookalike sequence is not a person.
66
+ expect(redact("000-45-6789")).toBe("000-45-6789");
67
+ // Bare nine digits stay: that is an order number as often as a person.
68
+ expect(redact("Order 123456789")).toBe("Order 123456789");
69
+ });
70
+ test("removes a card number from prose", () => {
71
+ expect(redact("paid with 4111 1111 1111 1111 today")).toBe(`paid with ${R} today`);
72
+ });
73
+ test("keeps ordinary listing text untouched", () => {
74
+ // The corpus this protects is also the corpus the graph exists to hold.
75
+ const listing = "Microsoft Surface Pro 9 13in Touch i7 12th Gen 16GB RAM 256GB SSD Win11H Black " +
76
+ "$520.00 or Best Offer Free delivery Located in United States 99.1% positive (147)";
77
+ expect(redact(listing)).toBe(listing);
78
+ });
79
+ // --- values -----------------------------------------------------------------
80
+ test("judges a single value on its own evidence", () => {
81
+ expect(isSensitiveValue("4111111111111111")).toBe(true);
82
+ expect(isSensitiveValue("GB82 WEST 1234 5698 7654 32")).toBe(true);
83
+ expect(isSensitiveValue("021000021")).toBe(true);
84
+ expect(isSensitiveValue("123-45-6789")).toBe(true);
85
+ expect(isSensitiveValue("Surface Pro 9")).toBe(false);
86
+ expect(isSensitiveValue("")).toBe(false);
87
+ });
88
+ test("removes a postal code phrased as a distance", () => {
89
+ // The same postcode survived under different wording: eBay prints "Shipping
90
+ // to 94122" in one place and "15 mi from 94122" in another, and protecting
91
+ // only the first phrasing protects nobody.
92
+ expect(redact("Free pickup: 15 mi from 94122")).toBe(`Free pickup: 15 mi from ${R}`);
93
+ // Still not a bare number: "from 12345 reviews" is not an address.
94
+ expect(redact("from 12345 reviews")).toBe("from 12345 reviews");
95
+ });
96
+ test("redactPersonalText is the one entry point both sides use", () => {
97
+ // The extension redacts what it SENDS; the server redacts what it KEEPS.
98
+ // One recogniser, so the two cannot drift apart.
99
+ expect(redactPersonalText("write to me@example.com")).toBe(`write to ${R}`);
100
+ expect(redactPersonalText("Order this and ship to 1600 Amphitheatre Parkway")).toContain(R);
101
+ expect(redactPersonalText("Find Apple Lisa computer for sale")).toBe("Find Apple Lisa computer for sale");
102
+ expect(redactPersonalText(undefined)).toBeUndefined();
103
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/sider_types",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Shared domain types for Sider — AI assistance for generic web apps.",
5
5
  "license": "BUSL-1.1",
6
6
  "publishConfig": {
@@ -15,9 +15,11 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc -p tsconfig.json",
18
- "typecheck": "tsc --noEmit"
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test"
19
20
  },
20
21
  "devDependencies": {
22
+ "@types/bun": "^1.3.14",
21
23
  "typescript": "^5.9.3"
22
24
  }
23
25
  }
package/src/index.ts CHANGED
@@ -12,5 +12,6 @@ export * from "./local";
12
12
  export * from "./api";
13
13
  export * from "./actions";
14
14
  export * from "./templating";
15
+ export * from "./personal-data";
15
16
  export * from "./recipe-hash";
16
17
  export * from "./observe";
package/src/observe.ts CHANGED
@@ -35,12 +35,28 @@ export interface SnapshotLink {
35
35
  label?: string;
36
36
  }
37
37
 
38
+ /**
39
+ * An anchor on the page pointing at a DIFFERENT origin.
40
+ *
41
+ * Kept apart from `SnapshotLink` on purpose: `links` feeds view identity and the
42
+ * graph's declared transitions, and a cross-origin entry there would churn every
43
+ * view signature. This is evidence of one thing only — that this site offers a
44
+ * way to reach that one.
45
+ */
46
+ export interface SnapshotExternalLink {
47
+ origin: string;
48
+ /** Visible text of the link, e.g. "Careers at USF". */
49
+ label?: string;
50
+ }
51
+
38
52
  export interface PageSnapshot {
39
53
  /** Path with query VALUES dropped and keys kept: /search?q={q} */
40
54
  urlPath: string;
41
55
  title?: string;
42
56
  regions: SnapshotRegion[];
43
57
  links: SnapshotLink[];
58
+ /** Off-site origins this page links to. Absent from older extension builds. */
59
+ externalLinks?: SnapshotExternalLink[];
44
60
  /** Structural text only — headings, control names, link labels, item titles. */
45
61
  contentMd?: string;
46
62
  /** How the agent arrived here, when known. */
@@ -0,0 +1,132 @@
1
+ import { test, expect } from "bun:test";
2
+ import {
3
+ isLuhnValid,
4
+ isSensitiveValue,
5
+ looksLikeIban,
6
+ looksLikeRoutingNumber,
7
+ redactPersonalText,
8
+ redactSensitiveText,
9
+ } from "./personal-data";
10
+
11
+ const R = "[redacted]";
12
+ const redact = (text: string) => redactSensitiveText(text, R);
13
+
14
+ // --- checksummed: financial -------------------------------------------------
15
+
16
+ test("an IBAN is recognised by its own check digits", () => {
17
+ expect(looksLikeIban("GB82 WEST 1234 5698 7654 32")).toBe(true);
18
+ expect(looksLikeIban("DE89370400440532013000")).toBe(true);
19
+ });
20
+
21
+ test("a string shaped like an IBAN but failing mod-97 is left alone", () => {
22
+ // The odds of a product code passing by accident are one in ninety-seven,
23
+ // which is what makes the rule safe to run over an entire page.
24
+ expect(looksLikeIban("GB82WEST12345698765433")).toBe(false);
25
+ expect(looksLikeIban("US12ORDERNUMBER1234567")).toBe(false);
26
+ });
27
+
28
+ test("a routing number is recognised by its ABA check digit", () => {
29
+ expect(looksLikeRoutingNumber("021000021")).toBe(true); // JPMorgan Chase
30
+ expect(looksLikeRoutingNumber("011401533")).toBe(true); // Bank of America
31
+ });
32
+
33
+ test("nine digits that are not a routing number survive", () => {
34
+ // Nine digits are an order id far more often than a bank. Redacting them all
35
+ // would empty the corpus while protecting nobody.
36
+ expect(looksLikeRoutingNumber("123456789")).toBe(false);
37
+ expect(looksLikeRoutingNumber("987654321")).toBe(false);
38
+ });
39
+
40
+ test("a card is recognised by Luhn wherever it hides", () => {
41
+ expect(isLuhnValid("4111111111111111")).toBe(true);
42
+ expect(isLuhnValid("4111111111111112")).toBe(false);
43
+ });
44
+
45
+ // --- text: what actually appeared on the page -------------------------------
46
+
47
+ test("removes the name a site greets the user by", () => {
48
+ // eBay prints this in the header of every page, and the whole page text was
49
+ // being sent as evidence when an action was blocked.
50
+ expect(redact("Hi John! Deals Brand Outlet")).toBe(`Hi ${R}! Deals Brand Outlet`);
51
+ expect(redact("Welcome back, Sarah")).toBe(`Welcome back, ${R}`);
52
+ });
53
+
54
+ test("removes a postal code the page says is a postal code", () => {
55
+ expect(redact("Shipping to 94122")).toBe(`Shipping to ${R}`);
56
+ expect(redact("Zip 10001-1234")).toBe(`Zip ${R}`);
57
+ });
58
+
59
+ test("leaves five bare digits alone", () => {
60
+ // A price, a year, a quantity, a part number. Without the context word there
61
+ // is nothing to say this is anyone's address.
62
+ expect(redact("Only 94122 units sold")).toBe("Only 94122 units sold");
63
+ expect(redact("$12345 or best offer")).toBe("$12345 or best offer");
64
+ });
65
+
66
+ test("removes a street address", () => {
67
+ expect(redact("Ship to 1600 Amphitheatre Parkway")).toContain(R);
68
+ expect(redact("742 Evergreen Terrace")).toBe(R);
69
+ });
70
+
71
+ test("leaves a house number with no street suffix alone", () => {
72
+ // "123 Main" is a heading or half a sentence; the suffix is what makes an
73
+ // address an address without a dictionary of every street name.
74
+ expect(redact("123 Main")).toBe("123 Main");
75
+ expect(redact("2000 watts of power")).toBe("2000 watts of power");
76
+ });
77
+
78
+ test("removes a written phone number but not an order id", () => {
79
+ expect(redact("call (415) 555-1234 now")).toBe(`call ${R} now`);
80
+ expect(redact("+1 415-555-1234")).toBe(R);
81
+ // Ten bare digits: a tracking number as often as a phone.
82
+ expect(redact("Tracking 4155551234")).toBe("Tracking 4155551234");
83
+ });
84
+
85
+ test("removes an SSN only when written as one", () => {
86
+ expect(redact("SSN 123-45-6789")).toBe(`SSN ${R}`);
87
+ // Ranges the SSA never issues, so a lookalike sequence is not a person.
88
+ expect(redact("000-45-6789")).toBe("000-45-6789");
89
+ // Bare nine digits stay: that is an order number as often as a person.
90
+ expect(redact("Order 123456789")).toBe("Order 123456789");
91
+ });
92
+
93
+ test("removes a card number from prose", () => {
94
+ expect(redact("paid with 4111 1111 1111 1111 today")).toBe(`paid with ${R} today`);
95
+ });
96
+
97
+ test("keeps ordinary listing text untouched", () => {
98
+ // The corpus this protects is also the corpus the graph exists to hold.
99
+ const listing =
100
+ "Microsoft Surface Pro 9 13in Touch i7 12th Gen 16GB RAM 256GB SSD Win11H Black " +
101
+ "$520.00 or Best Offer Free delivery Located in United States 99.1% positive (147)";
102
+ expect(redact(listing)).toBe(listing);
103
+ });
104
+
105
+ // --- values -----------------------------------------------------------------
106
+
107
+ test("judges a single value on its own evidence", () => {
108
+ expect(isSensitiveValue("4111111111111111")).toBe(true);
109
+ expect(isSensitiveValue("GB82 WEST 1234 5698 7654 32")).toBe(true);
110
+ expect(isSensitiveValue("021000021")).toBe(true);
111
+ expect(isSensitiveValue("123-45-6789")).toBe(true);
112
+ expect(isSensitiveValue("Surface Pro 9")).toBe(false);
113
+ expect(isSensitiveValue("")).toBe(false);
114
+ });
115
+
116
+ test("removes a postal code phrased as a distance", () => {
117
+ // The same postcode survived under different wording: eBay prints "Shipping
118
+ // to 94122" in one place and "15 mi from 94122" in another, and protecting
119
+ // only the first phrasing protects nobody.
120
+ expect(redact("Free pickup: 15 mi from 94122")).toBe(`Free pickup: 15 mi from ${R}`);
121
+ // Still not a bare number: "from 12345 reviews" is not an address.
122
+ expect(redact("from 12345 reviews")).toBe("from 12345 reviews");
123
+ });
124
+
125
+ test("redactPersonalText is the one entry point both sides use", () => {
126
+ // The extension redacts what it SENDS; the server redacts what it KEEPS.
127
+ // One recogniser, so the two cannot drift apart.
128
+ expect(redactPersonalText("write to me@example.com")).toBe(`write to ${R}`);
129
+ expect(redactPersonalText("Order this and ship to 1600 Amphitheatre Parkway")).toContain(R);
130
+ expect(redactPersonalText("Find Apple Lisa computer for sale")).toBe("Find Apple Lisa computer for sale");
131
+ expect(redactPersonalText(undefined)).toBeUndefined();
132
+ });
@@ -0,0 +1,189 @@
1
+ // Recognising a personal value by its SHAPE, not by what a site called it.
2
+ //
3
+ // Shared rather than browser-only, because the two sides need the same answer.
4
+ // The extension redacts what it sends; the server redacts what it KEEPS — a
5
+ // user's typed request has to arrive intact for the agent to act on it, and
6
+ // nothing says the copy retained afterwards must still hold their address.
7
+ // One recogniser, so those two decisions cannot drift apart.
8
+ //
9
+ // Field names are a losing game on their own: a card arrives as `pan`, `cc1` or
10
+ // `number`, and page text has no field names at all. What travels is a body, a
11
+ // URL, the rendered text of the page and the user's own words — and prose is
12
+ // where an order confirmation prints a delivery address that no name list will
13
+ // ever see.
14
+ //
15
+ // Every rule here is CHECKSUMMED or strongly structured, and that is the whole
16
+ // design. A redactor that guesses destroys the content the corpus exists to
17
+ // hold: nine digits are a routing number, an order id, a product code and a
18
+ // timestamp, and a rule that cannot tell them apart is worse than no rule,
19
+ // because it quietly empties the corpus while claiming to protect someone.
20
+ //
21
+ // So: IBAN and routing numbers are verified by their own check digits, cards by
22
+ // Luhn, and the patterns without a checksum (address, ZIP, name) require
23
+ // context a false positive does not have.
24
+
25
+ /** An IBAN: country, check digits, then the account, verified mod-97. */
26
+ const IBAN_CANDIDATE = /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g;
27
+
28
+ /** Nine digits: a US routing number, or a great many other things. */
29
+ const ROUTING_CANDIDATE = /\b\d{9}\b/g;
30
+
31
+ /**
32
+ * A US SSN, hyphenated only.
33
+ *
34
+ * Bare nine digits are deliberately NOT matched: that is an order number as
35
+ * often as a person, and the hyphens are what make the intent unambiguous.
36
+ * The excluded ranges are the ones the SSA never issues, which removes the
37
+ * bulk of lookalike sequences.
38
+ */
39
+ const SSN_PATTERN = /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g;
40
+
41
+ /**
42
+ * A phone number that was WRITTEN as one.
43
+ *
44
+ * Separators, parentheses or a country code are required. A bare run of ten
45
+ * digits is an order id, a SKU or a tracking number far more often than a
46
+ * phone, and redacting those would gut ordinary commerce pages.
47
+ */
48
+ const PHONE_PATTERN =
49
+ /(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])\d{3}[ .-]\d{4}\b/g;
50
+
51
+ /** Street suffixes, spelled out or abbreviated. */
52
+ const STREET_SUFFIX =
53
+ "street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|parkway|pkwy|highway|hwy|square|sq";
54
+
55
+ /**
56
+ * A street address: a building number, a name, and a suffix.
57
+ *
58
+ * The suffix is what carries the precision. "123 Main Street" is an address;
59
+ * "123 Main" is a heading, a product name or half a sentence, and requiring the
60
+ * suffix is what separates them without a dictionary of street names.
61
+ */
62
+ const STREET_PATTERN = new RegExp(
63
+ String.raw`\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,4}(?:${STREET_SUFFIX})\b\.?`,
64
+ "gi",
65
+ );
66
+
67
+ /**
68
+ * A postal code, but only where the page says it is one.
69
+ *
70
+ * Five bare digits are a price, a year, a quantity and a part number. The
71
+ * context words are what make it an address — "Shipping to 94122" on a search
72
+ * page is the user's own location, printed by the site for them alone.
73
+ */
74
+ const CONTEXTUAL_POSTAL_PATTERN =
75
+ /((?:zip|postal\s*code|ship(?:ping|s|ped)?\s*to|deliver(?:y|ed)?\s*to|located\s*in|(?:mi|miles|km)\s*from)\W{0,12})(\d{5}(?:-\d{4})?)\b/gi;
76
+
77
+ /**
78
+ * The name a page greets the signed-in user by.
79
+ *
80
+ * Names cannot be found in prose in general, and trying would ruin the text.
81
+ * A greeting is the exception: "Hi John!" is not a sentence about a person, it
82
+ * is the site telling us whose session this is — which is exactly the fact that
83
+ * must not travel. eBay's header prints it on every page.
84
+ */
85
+ const GREETING_PATTERN =
86
+ /\b([Hh]i|[Hh]ello|[Hh]ey|[Ww]elcome\s+back|[Ss]igned\s+in\s+as|[Ll]ogged\s+in\s+as|[Gg]ood\s+(?:morning|afternoon|evening))([,!]?\s+)([A-Z][a-z]{1,20})\b/g;
87
+
88
+ /** A payment card, verified by Luhn. */
89
+ export function isLuhnValid(digits: string): boolean {
90
+ if (!/^\d{13,19}$/.test(digits)) return false;
91
+ let sum = 0;
92
+ let double = false;
93
+ for (let i = digits.length - 1; i >= 0; i--) {
94
+ let d = digits.charCodeAt(i) - 48;
95
+ if (double) {
96
+ d *= 2;
97
+ if (d > 9) d -= 9;
98
+ }
99
+ sum += d;
100
+ double = !double;
101
+ }
102
+ return sum % 10 === 0;
103
+ }
104
+
105
+ /**
106
+ * An IBAN, verified mod-97.
107
+ *
108
+ * The four leading characters move to the end, letters become numbers, and the
109
+ * whole thing modulo 97 must be 1. A string that satisfies that is an IBAN; the
110
+ * odds of a product code doing so by accident are one in ninety-seven.
111
+ */
112
+ export function looksLikeIban(value: string): boolean {
113
+ const compact = value.replace(/\s/g, "").toUpperCase();
114
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) return false;
115
+
116
+ const rearranged = compact.slice(4) + compact.slice(0, 4);
117
+ let remainder = 0;
118
+ for (const ch of rearranged) {
119
+ const digits = ch >= "A" && ch <= "Z" ? String(ch.charCodeAt(0) - 55) : ch;
120
+ for (const d of digits) remainder = (remainder * 10 + (d.charCodeAt(0) - 48)) % 97;
121
+ }
122
+ return remainder === 1;
123
+ }
124
+
125
+ /**
126
+ * A US bank routing number, verified by its ABA check digit.
127
+ *
128
+ * Nine digits alone say nothing. This weighting is what a bank uses to reject a
129
+ * mistyped number, and it rejects roughly nine in ten arbitrary sequences.
130
+ */
131
+ export function looksLikeRoutingNumber(value: string): boolean {
132
+ const d = value.replace(/[\s-]/g, "");
133
+ if (!/^\d{9}$/.test(d)) return false;
134
+ const n = [...d].map(c => c.charCodeAt(0) - 48);
135
+ const sum =
136
+ 3 * (n[0]! + n[3]! + n[6]!) + 7 * (n[1]! + n[4]! + n[7]!) + (n[2]! + n[5]! + n[8]!);
137
+ return sum % 10 === 0 && sum > 0;
138
+ }
139
+
140
+ /** Whether a single value is personal on its own evidence. */
141
+ export function isSensitiveValue(value: string): boolean {
142
+ const trimmed = value.trim();
143
+ if (!trimmed) return false;
144
+ if (isLuhnValid(trimmed.replace(/[ -]/g, ""))) return true;
145
+ if (looksLikeIban(trimmed)) return true;
146
+ if (looksLikeRoutingNumber(trimmed)) return true;
147
+ SSN_PATTERN.lastIndex = 0;
148
+ return SSN_PATTERN.test(trimmed);
149
+ }
150
+
151
+ /**
152
+ * Free text with every recognised personal value replaced.
153
+ *
154
+ * Ordered so the most specific rules run first: a card number inside an address
155
+ * line should be removed as a card, not left behind by a street rule that
156
+ * matched around it.
157
+ */
158
+ export function redactSensitiveText(text: string, placeholder: string): string {
159
+ return text
160
+ .replace(/\b\d(?:[ -]?\d){12,18}\b/g, m => (isLuhnValid(m.replace(/[ -]/g, "")) ? placeholder : m))
161
+ .replace(IBAN_CANDIDATE, m => (looksLikeIban(m) ? placeholder : m))
162
+ .replace(SSN_PATTERN, placeholder)
163
+ .replace(ROUTING_CANDIDATE, m => (looksLikeRoutingNumber(m) ? placeholder : m))
164
+ .replace(PHONE_PATTERN, placeholder)
165
+ .replace(STREET_PATTERN, placeholder)
166
+ // The context word is kept and only the value replaced: "Shipping to
167
+ // [redacted]" still tells a reader what the page was doing there.
168
+ .replace(CONTEXTUAL_POSTAL_PATTERN, (_m, lead: string) => `${lead}${placeholder}`)
169
+ .replace(GREETING_PATTERN, (_m, greet: string, gap: string) => `${greet}${gap}${placeholder}`);
170
+ }
171
+
172
+ /** What a removed value becomes. A string, so an inferred shape is unchanged. */
173
+ export const REDACTED = "[redacted]";
174
+
175
+ /** An email anywhere in free text. */
176
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
177
+
178
+ /**
179
+ * Free text with every recognisable personal value removed.
180
+ *
181
+ * The one entry point both sides use: page markdown on its way to the shared
182
+ * graph, and a user's retained request on its way to the database.
183
+ */
184
+ export function redactPersonalText(text: string): string;
185
+ export function redactPersonalText(text: string | undefined): string | undefined;
186
+ export function redactPersonalText(text: string | undefined): string | undefined {
187
+ if (!text) return text;
188
+ return redactSensitiveText(text.replace(EMAIL_PATTERN, REDACTED), REDACTED);
189
+ }