@vieko/pi-redact 0.1.1 → 0.2.0
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/NOTICE +3 -0
- package/README.md +6 -2
- package/extensions/pi-redact.ts +136 -8
- package/package.json +39 -10
- package/src/detect.ts +37 -7
- package/src/family.ts +14 -0
package/NOTICE
CHANGED
|
@@ -7,6 +7,9 @@ Local changes in src/detect.ts are marked with "pi-redact:" comments:
|
|
|
7
7
|
- keep the macOS per-user temp dir (/var/folders/xx/<random>/T/)
|
|
8
8
|
- name Linear (lin_api_) and Vercel AI Gateway (vck_) keys as vendor shapes
|
|
9
9
|
- export findVendorKeys() for the history scan
|
|
10
|
+
- mint placeholders as a keyed HMAC of the value (setTagKey()) instead of
|
|
11
|
+
random bytes, so a reloaded process mints the same tags; the vendored
|
|
12
|
+
test/random-tags.vendor.mts is replaced by test/stable-tags.test.ts
|
|
10
13
|
|
|
11
14
|
In test/detect.vendor.mts:
|
|
12
15
|
- the 5 s timing limit can be raised with REDACT_PERF_LIMIT_MS (CI uses 10 s)
|
package/README.md
CHANGED
|
@@ -25,9 +25,13 @@ pi install npm:@vieko/pi-redact
|
|
|
25
25
|
|
|
26
26
|
Placeholders stay placeholders in `git commit`/`git tag` and `gh pr|issue|api|...`, and in every tool that is not in the restore list (`codemode` itself, `send_message`, MCP tools, web tools).
|
|
27
27
|
|
|
28
|
+
## Placeholders
|
|
29
|
+
|
|
30
|
+
A placeholder is an HMAC-SHA256 of the value. The key belongs to the session lineage (a fork or clone uses its root's key) and comes from a machine key in `~/.pi/agent/pi-redact.key`, created with mode 0600 on first use. After `/reload`, a restart or a resume, the same value gets the same placeholder, so the history pi sends does not change and the prompt cache holds. Two unrelated sessions get different placeholders. The machine key never reaches the model, so a placeholder cannot be matched to a guessed value. If the key file cannot be read or written, placeholders fall back to random tags per process.
|
|
31
|
+
|
|
28
32
|
## Tracking
|
|
29
33
|
|
|
30
|
-
Each value seen for the first time in a session is logged as a `pi-redact` custom entry
|
|
34
|
+
Each value seen for the first time in a session is logged as a `pi-redact` custom entry. Its fields are `source` (channel), `values` (new distinct values), `vendor` (new values with a known vendor shape), and `families` (family name to count, summing to `values`). The entry holds counts only, never the value or a piece of it, and is not sent to the model. Families are `uuid`, `prefixed-id`, `identifier`, and `high-entropy`; vendor-shaped values use the detector's public prefix or pattern as their family.
|
|
31
35
|
|
|
32
36
|
## Old sessions
|
|
33
37
|
|
|
@@ -56,7 +60,7 @@ Optional `~/.pi/agent/pi-redact.json`, same keys as the upstream plugin:
|
|
|
56
60
|
|
|
57
61
|
## Limits
|
|
58
62
|
|
|
59
|
-
- The vault lives in memory. After `/reload`, a restart or a resume, old
|
|
63
|
+
- The vault lives in memory. After `/reload`, a restart or a resume, an old placeholder maps to its value again only when that value turns up again; until then it reaches tools as written.
|
|
60
64
|
- Output of `!cmd` is persisted by pi before any hook sees it. The model gets placeholders (the `context` net), but the raw output stays in the session file.
|
|
61
65
|
- Custom messages appended while pi is idle are persisted without `message_end`; the `context` net covers what the model sees.
|
|
62
66
|
- Sessions recorded before you install it keep their raw values on disk until you run `scripts/scrub-sessions.ts`.
|
package/extensions/pi-redact.ts
CHANGED
|
@@ -21,11 +21,20 @@
|
|
|
21
21
|
* custom entry (counts and channel only, never the value), so audits can
|
|
22
22
|
* tell how often and how secrets reach a session.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Placeholders are keyed per session lineage (a fork or clone shares its
|
|
25
|
+
* root's key) with a machine key in ~/.pi/agent/pi-redact.key, so /reload,
|
|
26
|
+
* a restart or a resume mints the same placeholder for the same value and
|
|
27
|
+
* the prompt cache survives. The vault (placeholder -> value) lives in
|
|
28
|
+
* memory: after /reload or a resume, old placeholders no longer map to a
|
|
29
|
+
* value until the value is seen again, and reach tools as written.
|
|
30
|
+
*
|
|
31
|
+
* The footer shows `redact:N`: distinct values kept out of this session's
|
|
32
|
+
* transcript. It counts the `pi-redact` entries on the branch, so the
|
|
33
|
+
* number survives /reload and resume even though the vault does not.
|
|
26
34
|
*/
|
|
27
35
|
|
|
28
|
-
import {
|
|
36
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
37
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
38
|
import { homedir } from "node:os";
|
|
30
39
|
import { join } from "node:path";
|
|
31
40
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -39,12 +48,15 @@ import {
|
|
|
39
48
|
restore,
|
|
40
49
|
scrubPii,
|
|
41
50
|
scrubSecrets,
|
|
51
|
+
setTagKey,
|
|
42
52
|
type WalkState,
|
|
43
53
|
walk,
|
|
44
54
|
} from "../src/detect.ts";
|
|
55
|
+
import { familyOf } from "../src/family.ts";
|
|
45
56
|
|
|
46
57
|
const NAME = "pi-redact";
|
|
47
58
|
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-redact.json");
|
|
59
|
+
const KEY_PATH = join(homedir(), ".pi", "agent", "pi-redact.key");
|
|
48
60
|
|
|
49
61
|
/** Tools that run on this machine and may receive real values. */
|
|
50
62
|
export const DEFAULT_RESTORE_TOOLS = "bash,powershell,write,edit,read,grep,find,ls";
|
|
@@ -109,15 +121,93 @@ function mapContent<T>(content: T, fn: (s: string) => string): T {
|
|
|
109
121
|
export interface RedactOptions {
|
|
110
122
|
/** Overrides ~/.pi/agent/pi-redact.json, for tests. */
|
|
111
123
|
options?: Record<string, unknown>;
|
|
124
|
+
/** Machine key file; defaults to ~/.pi/agent/pi-redact.key. */
|
|
125
|
+
keyPath?: string;
|
|
126
|
+
/** Machine key itself, skipping the file, for tests. */
|
|
127
|
+
machineKey?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The machine key, created on first use (0600). It never reaches the model;
|
|
132
|
+
* without it a placeholder cannot be tied to a guessed value. Undefined when
|
|
133
|
+
* the file cannot be read or written: tags then stay per-process random.
|
|
134
|
+
*/
|
|
135
|
+
export function loadMachineKey(path: string): string | undefined {
|
|
136
|
+
try {
|
|
137
|
+
const key = readFileSync(path, "utf8").trim();
|
|
138
|
+
if (/^[0-9a-f]{64}$/.test(key)) return key;
|
|
139
|
+
} catch {
|
|
140
|
+
// Missing: create it below.
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, { mode: 0o600, flag: "wx" });
|
|
144
|
+
} catch {
|
|
145
|
+
// Another process won the race, or the directory is read-only.
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const key = readFileSync(path, "utf8").trim();
|
|
149
|
+
return /^[0-9a-f]{64}$/.test(key) ? key : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type Header = { id?: string; parentSession?: string } | null | undefined;
|
|
156
|
+
|
|
157
|
+
/** Id of the oldest session this one was forked or cloned from. */
|
|
158
|
+
export function lineageId(header: Header): string | undefined {
|
|
159
|
+
let id = header?.id;
|
|
160
|
+
let parent = header?.parentSession;
|
|
161
|
+
for (let hop = 0; parent && hop < 16; hop++) {
|
|
162
|
+
try {
|
|
163
|
+
const first = readFileSync(parent, "utf8").split("\n", 1)[0] ?? "";
|
|
164
|
+
const up = JSON.parse(first) as { type?: string; id?: string; parentSession?: string };
|
|
165
|
+
if (up.type !== "session" || !up.id) break;
|
|
166
|
+
id = up.id;
|
|
167
|
+
parent = up.parentSession;
|
|
168
|
+
} catch {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return id;
|
|
112
173
|
}
|
|
113
174
|
|
|
114
175
|
export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void {
|
|
115
176
|
const cfg = buildConfig(init.options ?? loadOptions(CONFIG_PATH));
|
|
116
177
|
let enabled = true;
|
|
117
178
|
let hiddenTotal = 0;
|
|
179
|
+
let kept = 0;
|
|
180
|
+
let ui: ExtensionContext | undefined;
|
|
181
|
+
const showStatus = (): void => {
|
|
182
|
+
if (!ui?.hasUI || kept <= 0) return;
|
|
183
|
+
try {
|
|
184
|
+
ui.ui.setStatus(NAME, ui.ui.theme.fg("dim", `redact:${kept}`));
|
|
185
|
+
} catch {
|
|
186
|
+
// The context went stale (session replaced); the next session_start resets it.
|
|
187
|
+
}
|
|
188
|
+
};
|
|
118
189
|
const handedById = new Map<string, Set<string>>();
|
|
119
|
-
|
|
190
|
+
let contextMemo = new WeakMap<object, AnyMessage>();
|
|
191
|
+
let contextMemoById = new Map<string, AnyMessage>();
|
|
120
192
|
const tracked = new Set<string>();
|
|
193
|
+
let machineKey = init.machineKey;
|
|
194
|
+
let sessionKey: string | undefined;
|
|
195
|
+
|
|
196
|
+
// One key per session lineage. A new lineage starts a new vault, so tags
|
|
197
|
+
// do not link one session to another.
|
|
198
|
+
const keySession = (header: Header): void => {
|
|
199
|
+
machineKey ??= loadMachineKey(init.keyPath ?? KEY_PATH);
|
|
200
|
+
const lineage = lineageId(header);
|
|
201
|
+
if (!machineKey || !lineage) return;
|
|
202
|
+
const key = createHmac("sha256", machineKey).update(`pi-redact/tags/v1\u0000${lineage}`).digest();
|
|
203
|
+
const hex = key.toString("hex");
|
|
204
|
+
if (hex === sessionKey) return;
|
|
205
|
+
sessionKey = hex;
|
|
206
|
+
setTagKey(key);
|
|
207
|
+
contextMemo = new WeakMap();
|
|
208
|
+
contextMemoById = new Map();
|
|
209
|
+
tracked.clear();
|
|
210
|
+
};
|
|
121
211
|
|
|
122
212
|
// One entry per channel event that brought in values not seen before.
|
|
123
213
|
// `vendor` counts the ones with a known vendor shape (high confidence);
|
|
@@ -126,9 +216,17 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
|
|
|
126
216
|
const fresh = [...tags].filter((t) => !t.startsWith("[REDACTED-LITERAL-") && !tracked.has(t));
|
|
127
217
|
if (fresh.length === 0) return;
|
|
128
218
|
for (const t of fresh) tracked.add(t);
|
|
129
|
-
|
|
219
|
+
kept += fresh.length;
|
|
220
|
+
showStatus();
|
|
221
|
+
const values = fresh.map((t) => restore(t));
|
|
222
|
+
const vendor = values.filter((value) => findVendorKeys(value).length > 0).length;
|
|
223
|
+
const families: Record<string, number> = {};
|
|
224
|
+
for (const value of values) {
|
|
225
|
+
const family = familyOf(value);
|
|
226
|
+
families[family] = (families[family] ?? 0) + 1;
|
|
227
|
+
}
|
|
130
228
|
try {
|
|
131
|
-
pi.appendEntry(NAME, { source, values: fresh.length, vendor });
|
|
229
|
+
pi.appendEntry(NAME, { source, values: fresh.length, vendor, families });
|
|
132
230
|
} catch {
|
|
133
231
|
// Tracking is best-effort; redaction already happened.
|
|
134
232
|
}
|
|
@@ -146,7 +244,6 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
|
|
|
146
244
|
if (n <= 0) return;
|
|
147
245
|
hiddenTotal += n;
|
|
148
246
|
if (!ctx?.hasUI) return;
|
|
149
|
-
ctx.ui.setStatus(NAME, `redact ${hiddenTotal}`);
|
|
150
247
|
if (cfg.notify && where) ctx.ui.notify(`${NAME}: hid ${n} value(s) from ${where}`, "info");
|
|
151
248
|
};
|
|
152
249
|
|
|
@@ -161,6 +258,18 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
|
|
|
161
258
|
return out;
|
|
162
259
|
};
|
|
163
260
|
|
|
261
|
+
pi.on("session_start", (_event, ctx) => {
|
|
262
|
+
ui = ctx;
|
|
263
|
+
kept = 0;
|
|
264
|
+
const sm = ctx.sessionManager as { getHeader?: () => Header };
|
|
265
|
+
keySession(typeof sm.getHeader === "function" ? sm.getHeader() : undefined);
|
|
266
|
+
for (const e of ctx.sessionManager.getBranch() as { type: string; customType?: string; data?: { values?: number } }[]) {
|
|
267
|
+
if (e.type === "custom" && e.customType === NAME) kept += e.data?.values ?? 0;
|
|
268
|
+
}
|
|
269
|
+
if (ctx.hasUI) ctx.ui.setStatus(NAME, undefined);
|
|
270
|
+
showStatus();
|
|
271
|
+
});
|
|
272
|
+
|
|
164
273
|
// 1. What the user types. `!cmd` is the user's own shell and runs as typed.
|
|
165
274
|
pi.on("input", (event, ctx) => {
|
|
166
275
|
if (!enabled || event.text.startsWith("!")) return { action: "continue" as const };
|
|
@@ -264,12 +373,30 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
|
|
|
264
373
|
}
|
|
265
374
|
});
|
|
266
375
|
|
|
376
|
+
// pi may rebuild message objects on each request, so key the memo by the
|
|
377
|
+
// unscrubbed message's digest as well as by object. A digest, not role +
|
|
378
|
+
// timestamp: two messages that share a timestamp must never share output.
|
|
379
|
+
const contextMessageId = (message: AnyMessage): string | undefined => {
|
|
380
|
+
try {
|
|
381
|
+
return createHash("sha256").update(JSON.stringify(message)).digest("hex");
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
267
387
|
// 6. The last net, request-local: bashExecution output, custom messages
|
|
268
388
|
// appended while idle, and history from sessions recorded before this
|
|
269
|
-
// extension.
|
|
389
|
+
// extension. Freeze each message's first scrub result: the echo set may
|
|
390
|
+
// grow later, but already-sent bytes must not change.
|
|
270
391
|
const cleanMessage = (message: AnyMessage): AnyMessage => {
|
|
271
392
|
const cached = contextMemo.get(message);
|
|
272
393
|
if (cached) return cached;
|
|
394
|
+
const id = contextMessageId(message);
|
|
395
|
+
const stableCached = id === undefined ? undefined : contextMemoById.get(id);
|
|
396
|
+
if (stableCached) {
|
|
397
|
+
contextMemo.set(message, stableCached);
|
|
398
|
+
return stableCached;
|
|
399
|
+
}
|
|
273
400
|
let out = message;
|
|
274
401
|
switch (message.role) {
|
|
275
402
|
case "user":
|
|
@@ -293,6 +420,7 @@ export function createRedactor(pi: ExtensionAPI, init: RedactOptions = {}): void
|
|
|
293
420
|
}
|
|
294
421
|
}
|
|
295
422
|
contextMemo.set(message, out);
|
|
423
|
+
if (id !== undefined) contextMemoById.set(id, out);
|
|
296
424
|
return out;
|
|
297
425
|
};
|
|
298
426
|
|
package/package.json
CHANGED
|
@@ -1,25 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vieko/pi-redact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Keeps secrets out of pi session transcripts: stable placeholders in, real values back for local tools.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"secrets",
|
|
9
|
+
"redaction",
|
|
10
|
+
"privacy"
|
|
11
|
+
],
|
|
6
12
|
"license": "MIT",
|
|
7
13
|
"author": "Vieko Franetovic",
|
|
8
|
-
"repository": {
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/vieko/pi-redact.git"
|
|
17
|
+
},
|
|
9
18
|
"homepage": "https://github.com/vieko/pi-redact#readme",
|
|
10
|
-
"bugs": {
|
|
11
|
-
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/vieko/pi-redact/issues"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
25
|
+
},
|
|
12
26
|
"type": "module",
|
|
13
|
-
"files": [
|
|
27
|
+
"files": [
|
|
28
|
+
"extensions",
|
|
29
|
+
"src",
|
|
30
|
+
"scripts",
|
|
31
|
+
"README.md",
|
|
32
|
+
"NOTICE",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
14
35
|
"scripts": {
|
|
15
36
|
"typecheck": "tsc -p tsconfig.json",
|
|
16
|
-
"test:vendor": "node test/detect.vendor.mts && node test/pii-options.vendor.mts && node test/
|
|
37
|
+
"test:vendor": "node test/detect.vendor.mts && node test/pii-options.vendor.mts && node test/session-privacy.vendor.mts",
|
|
17
38
|
"test": "node --test \"test/*.test.ts\" && npm run test:vendor",
|
|
18
39
|
"check": "npm run typecheck && npm run test"
|
|
19
40
|
},
|
|
20
|
-
"engines": {
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.18"
|
|
43
|
+
},
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./extensions/pi-redact.ts"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0"
|
|
51
|
+
},
|
|
23
52
|
"devDependencies": {
|
|
24
53
|
"@earendil-works/pi-coding-agent": "*",
|
|
25
54
|
"@types/node": "^24.0.0",
|
package/src/detect.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Nothing is written to disk. The vault dies with the session.
|
|
18
18
|
|
|
19
|
+
import { createHash, type Hash, randomBytes } from 'node:crypto'
|
|
20
|
+
|
|
19
21
|
export type Kind = 'SECRET' | 'EMAIL' | 'IP' | 'LITERAL'
|
|
20
22
|
|
|
21
23
|
export type Config = {
|
|
@@ -51,12 +53,39 @@ let minted: Set<string> | null = null
|
|
|
51
53
|
const TAG = /\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]/g
|
|
52
54
|
const WHOLE_TAG = /^\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]$/
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
// pi-redact: a tag is a keyed hash of the value, not a random draw, so a
|
|
57
|
+
// reloaded or restarted process mints the same tag for the same value and
|
|
58
|
+
// the history it sends keeps its prompt-cache prefix. The host sets the key
|
|
59
|
+
// per session with setTagKey(); without one, each module load draws a random
|
|
60
|
+
// key, so tags stay unlinkable across processes as upstream intends.
|
|
61
|
+
let tagKey: Uint8Array = randomBytes(32)
|
|
62
|
+
let pads = hmacPads(tagKey)
|
|
63
|
+
|
|
64
|
+
// HMAC-SHA256 with the two padded key blocks hashed once and copied per tag:
|
|
65
|
+
// createHmac() per value made the vendored timing cases twice as slow.
|
|
66
|
+
function hmacPads(key: Uint8Array): { inner: Hash; outer: Hash } {
|
|
67
|
+
const block = Buffer.alloc(64)
|
|
68
|
+
Buffer.from(key.length > 64 ? createHash('sha256').update(key).digest() : key).copy(block)
|
|
69
|
+
const inner = createHash('sha256').update(block.map(b => b ^ 0x36))
|
|
70
|
+
const outer = createHash('sha256').update(block.map(b => b ^ 0x5c))
|
|
71
|
+
return { inner, outer }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tagHex(kind: Kind, value: string, attempt: number): string {
|
|
75
|
+
const inner = pads.inner.copy().update(`${kind}\u0000${attempt}\u0000${value}`).digest()
|
|
76
|
+
return pads.outer.copy().update(inner).digest('hex').slice(0, 8)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// pi-redact: swaps the tag key. A different key starts a new vault: tags
|
|
80
|
+
// minted under the old key would no longer match what a fresh process mints.
|
|
81
|
+
export function setTagKey(key: Uint8Array): void {
|
|
82
|
+
if (Buffer.from(key).equals(Buffer.from(tagKey))) return
|
|
83
|
+
tagKey = Uint8Array.from(key)
|
|
84
|
+
pads = hmacPads(tagKey)
|
|
85
|
+
byValue.clear()
|
|
86
|
+
byTag.clear()
|
|
87
|
+
echo.clear()
|
|
88
|
+
echoIndex = null
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
// Swaps a value for its placeholder and remembers both. A value that already
|
|
@@ -72,7 +101,8 @@ export function mint(kind: Kind, raw: string): string {
|
|
|
72
101
|
return seen
|
|
73
102
|
}
|
|
74
103
|
let tag: string
|
|
75
|
-
|
|
104
|
+
let attempt = 0
|
|
105
|
+
do tag = `[REDACTED-${kind}-${tagHex(kind, value, attempt++)}]`
|
|
76
106
|
while (byTag.has(tag))
|
|
77
107
|
byValue.set(value, tag)
|
|
78
108
|
byTag.set(tag, value)
|
package/src/family.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { findVendorKeys } from "./detect.ts";
|
|
2
|
+
|
|
3
|
+
/** Shape classes for values without a vendor-specific family. */
|
|
4
|
+
export const FAMILIES = ["uuid", "prefixed-id", "identifier", "high-entropy"] as const;
|
|
5
|
+
|
|
6
|
+
// Vendor rules take precedence so audit counts retain their public shape label.
|
|
7
|
+
export function familyOf(value: string): string {
|
|
8
|
+
const vendor = findVendorKeys(value)[0];
|
|
9
|
+
if (vendor) return vendor.family;
|
|
10
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) return "uuid";
|
|
11
|
+
if (/^[a-z][a-z0-9]{1,15}_[A-Za-z0-9]{8,}$/.test(value)) return "prefixed-id";
|
|
12
|
+
if (/^(?=.*[a-z])(?=.*[A-Z])[A-Za-z0-9]+$/.test(value)) return "identifier";
|
|
13
|
+
return "high-entropy";
|
|
14
|
+
}
|