@bli-cockpit/telemetry-core 0.1.41 → 0.1.42

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.
@@ -1,6 +1,3 @@
1
- import { createHash } from "node:crypto";
2
- import { z } from "zod";
3
- import { IsoDateTimeSchema } from "./common.js";
4
1
  /**
5
2
  * Secret-like content + file-name guards shared by the local collector
6
3
  * (attribution, collection) and the dashboard commit-time guard. Keeping a
@@ -16,490 +13,28 @@ import { IsoDateTimeSchema } from "./common.js";
16
13
  * harvest cares most about). Value-shaped tokens (JWT, sk-, ghp_, PEM blocks,
17
14
  * AWS key ids, Slack/Linear/Langfuse tokens) are self-identifying and stay
18
15
  * blocked regardless of surrounding context.
19
- */
20
- /**
21
- * File-path / file-name segments that must never be read at all. This is a
22
- * NAME matcher (it never reads file content), so it stays broad: a file called
23
- * `service-role.json` or a dotenv file is skipped on its name alone. The
24
- * collector imports this instead of re-declaring it, so attribution,
25
- * collection, and the path walk share one definition.
26
- */
27
- export const SECRET_FILE_SEGMENT_PATTERN = /(^|[/\\])(?:\.env(?:\..*)?|.*(?:secret|credential|private[_-]?key|service[_-]?role).*)$/i;
28
- export function isSecretLikePathSegment(value) {
29
- return SECRET_FILE_SEGMENT_PATTERN.test(value);
30
- }
31
- // Constructed from parts so the literal privileged key name never appears in
32
- // this source file (the public-package pack scanner bans it as a content
33
- // pattern, just as the original guard did).
34
- const PRIVILEGED_SUPABASE_KEY_NAME = ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("_");
35
- const SUPABASE_SERVICE_ROLE_NAME = ["SUPABASE", "SERVICE", "ROLE"].join("_");
36
- const OPENAI_KEY_NAME = ["OPENAI", "API", "KEY"].join("_");
37
- const MEM0_KEY_NAME = ["MEM0", "API", "KEY"].join("_");
38
- const SECRET_NAME_PATTERN_SOURCE = [
39
- "(?:NEXT_PUBLIC_)?SUPABASE_ANON_KEY",
40
- PRIVILEGED_SUPABASE_KEY_NAME,
41
- SUPABASE_SERVICE_ROLE_NAME,
42
- OPENAI_KEY_NAME,
43
- MEM0_KEY_NAME,
44
- "SERVICE[_\\s-]?ROLE(?:[_\\s-]?KEY)?",
45
- "LANGFUSE_(?:PUBLIC|SECRET)_KEY",
46
- "api[_-]?key",
47
- "access[_-]?token",
48
- "refresh[_-]?token",
49
- "private[_-]?key",
50
- "client[_-]?secret",
51
- "secret[_-]?access[_-]?key",
52
- ].join("|");
53
- /**
54
- * Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
55
- * (BLI-3116).
56
16
  *
57
- * Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
58
- * word character, so there is no word boundary between `AWS_` and `SECRET`:
59
- * `AWS_SECRET_ACCESS_KEY=AKIA…` the most common real spelling of the most
60
- * common real leak — never matched and uploaded unmasked from every fleet
61
- * machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
62
- * `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
63
- * `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
64
- * fence, not the vocabulary, so the whole list is fenced differently now.
65
- *
66
- * `_`, `-` and `.` are identifier JOINERS here, not boundaries:
67
- *
68
- * - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
69
- * at a real boundary, but a letter or digit immediately before it still
70
- * blocks the match (`notapikey=…` stays out, as before).
71
- * - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
72
- * `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
73
- *
74
- * Prose is unaffected because the assignment requirement below is unchanged:
75
- * "rotate your secret access key" has no `=`/`:` + opaque value and is not a
76
- * leak. The guard's job is assignments, not vocabulary.
77
- */
78
- const SECRET_NAME_MATCH_SOURCE = `(?<![A-Za-z0-9])(?:${SECRET_NAME_PATTERN_SOURCE})[A-Za-z0-9_-]{0,40}`;
79
- /**
80
- * The assignment operator, with the closing quote of a JSON/YAML key allowed
81
- * before it. Found while auditing the fence above: `{"aws_secret_access_key":
82
- * "…"}` never matched either, because the name was followed by `"` and the
83
- * pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
84
- * shape a leaked credential most often has on the way in. The sibling redactor
85
- * in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
86
- */
87
- const SECRET_ASSIGNMENT_SOURCE = `["']?\\s*[:=]\\s*["']?`;
88
- /**
89
- * Credential names that are only a leak when assigned a value (D13). The value
90
- * shape mirrors the long-standing generic pattern: an assignment operator
91
- * followed by a 12+ char opaque value. A bare env var reference (name only) and
92
- * `GRANT ... TO service_role` (no assignment) do not match; the same name with
93
- * an assigned value does.
94
- */
95
- const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE}[A-Za-z0-9_./+=-]{12,}`, "i");
96
- /**
97
- * Self-identifying secret values — blocked regardless of context because the
98
- * token shape itself is the credential, with no benign reading.
99
- *
100
- * `\b` is deliberately KEPT here, unlike the name fragments above (BLI-3116).
101
- * These patterns are whole tokens, not fragments of a longer identifier: a
102
- * `ghp_`/`sk-`/`AKIA…` run that continues into surrounding alphanumerics is not
103
- * that provider's token, and unanchoring them would mask ordinary identifiers
104
- * (`chart_m0_2024_revenue…`) for no safety gain. A real leak of any of these
105
- * shapes is preceded by a quote, whitespace, `=`, `:` or `/` — all of which
106
- * `\b` already admits.
107
- */
108
- const SECRET_VALUE_PATTERNS = [
109
- /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
110
- /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/,
111
- /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
112
- /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/,
113
- /\bsk-[A-Za-z0-9_-]{16,}/,
114
- /\bgh[pousr]_[A-Za-z0-9_]{16,}/,
115
- /\bgithub_pat_[A-Za-z0-9_]{20,}/,
116
- /\blin_api_[A-Za-z0-9]{16,}/,
117
- /\bsb_secret_[A-Za-z0-9_]{16,}/,
118
- /\bxox[baprs]-[A-Za-z0-9-]{16,}/,
119
- /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
120
- /\b(?:pk|sk)-lf-[A-Za-z0-9_-]{16,}\b/i,
121
- /\bm0[-_][A-Za-z0-9_-]{16,}\b/i,
122
- ];
123
- const SECRET_LIKE_CONTENT_PATTERNS = [
124
- SECRET_NAME_WITH_VALUE_PATTERN,
125
- ...SECRET_VALUE_PATTERNS,
126
- ];
127
- export function containsSecretLikeContent(value) {
128
- return SECRET_LIKE_CONTENT_PATTERNS.some((pattern) => pattern.test(value));
129
- }
130
- export const RawEvidenceRedactionSourceSchema = z.enum([
131
- "local_collector",
132
- "server_commit",
133
- "legacy_upload",
134
- ]);
135
- export const RawEvidenceRedactionRuleCountSchema = z
136
- .object({
137
- rule_id: z.string().trim().min(1).max(80),
138
- match_count: z.number().int().nonnegative(),
139
- redacted_char_count: z.number().int().nonnegative(),
140
- })
141
- .strict();
142
- export const RawEvidenceRedactionRangeSchema = z
143
- .object({
144
- start: z.number().int().nonnegative(),
145
- end: z.number().int().positive(),
146
- rule_id: z.string().trim().min(1).max(80),
147
- })
148
- .strict()
149
- .superRefine((range, context) => {
150
- if (range.end <= range.start) {
151
- context.addIssue({
152
- code: "custom",
153
- message: "redaction range end must be greater than start",
154
- path: ["end"],
155
- });
156
- }
157
- });
158
- /**
159
- * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
160
- *
161
- * `sanitized` means the scan matched and the uploaded bytes differ from what was
162
- * read. `scanned_clean` means the same scan ran over the same bytes and matched
163
- * nothing, so the uploaded bytes are the original ones. The second value exists
164
- * because every reader downstream asks "was this scanned?", and for 70% of
165
- * transcripts — the ones with no secret in them — the honest answer used to be
166
- * an absent record, which reads identically to "nobody ever looked".
167
- */
168
- export const RawEvidenceRedactionStatusSchema = z.enum([
169
- "sanitized",
170
- "scanned_clean",
171
- ]);
172
- export const RawEvidenceRedactionMetadataSchema = z
173
- .object({
174
- schema_version: z.literal("raw-evidence-redaction.v1"),
175
- status: RawEvidenceRedactionStatusSchema,
176
- mode: z.literal("deterministic_text_replacement"),
177
- applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
178
- rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
179
- secret_like_match_count: z.number().int().nonnegative(),
180
- redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
181
- redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
182
- original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
183
- sanitized_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
184
- original_byte_size: z.number().int().nonnegative().optional(),
185
- sanitized_byte_size: z.number().int().nonnegative().optional(),
186
- /**
187
- * When the scan ran, ISO-8601 with an offset (BLI-3290).
188
- *
189
- * Optional, and it stays optional: every receipt written before this field
190
- * existed is still valid, and the collector does not stamp it — at upload
191
- * time the ref's own `received_at` already answers "when". It exists for
192
- * receipts written over bytes that were stored long ago, where the ref's
193
- * timestamps describe the ORIGINAL upload and nothing in the record would
194
- * otherwise say when an operator re-scanned or re-masked the object.
195
- */
196
- applied_at: IsoDateTimeSchema.optional(),
197
- })
198
- .strict()
199
- // The per-status invariants the old `z.literal("sanitized")` + `.min(1)` +
200
- // `.positive()` shape enforced structurally. They still hold for a sanitized
201
- // record; a clean one has to prove the opposite — no rule fired, no range was
202
- // replaced, no field was masked, and the bytes came out the way they went in.
203
- .superRefine((metadata, context) => {
204
- if (metadata.status === "sanitized") {
205
- if (metadata.rule_counts.length === 0) {
206
- context.addIssue({
207
- code: "custom",
208
- message: "a sanitized redaction must name at least one rule",
209
- path: ["rule_counts"],
210
- });
211
- }
212
- if (metadata.secret_like_match_count < 1) {
213
- context.addIssue({
214
- code: "custom",
215
- message: "a sanitized redaction must count at least one match",
216
- path: ["secret_like_match_count"],
217
- });
218
- }
219
- return;
220
- }
221
- if (metadata.rule_counts.length > 0) {
222
- context.addIssue({
223
- code: "custom",
224
- message: "a clean scan cannot name a rule that fired",
225
- path: ["rule_counts"],
226
- });
227
- }
228
- if (metadata.secret_like_match_count !== 0) {
229
- context.addIssue({
230
- code: "custom",
231
- message: "a clean scan cannot count a match",
232
- path: ["secret_like_match_count"],
233
- });
234
- }
235
- if (metadata.redacted_ranges.length > 0) {
236
- context.addIssue({
237
- code: "custom",
238
- message: "a clean scan cannot redact a range",
239
- path: ["redacted_ranges"],
240
- });
241
- }
242
- if (metadata.redacted_fields.length > 0) {
243
- context.addIssue({
244
- code: "custom",
245
- message: "a clean scan cannot redact a field",
246
- path: ["redacted_fields"],
247
- });
248
- }
249
- if (metadata.original_content_hash_sha256 !== undefined &&
250
- metadata.sanitized_content_hash_sha256 !== undefined &&
251
- metadata.original_content_hash_sha256 !==
252
- metadata.sanitized_content_hash_sha256) {
253
- context.addIssue({
254
- code: "custom",
255
- message: "a clean scan cannot change the content hash",
256
- path: ["sanitized_content_hash_sha256"],
257
- });
258
- }
259
- if (metadata.original_byte_size !== undefined &&
260
- metadata.sanitized_byte_size !== undefined &&
261
- metadata.original_byte_size !== metadata.sanitized_byte_size) {
262
- context.addIssue({
263
- code: "custom",
264
- message: "a clean scan cannot change the byte size",
265
- path: ["sanitized_byte_size"],
266
- });
267
- }
268
- });
269
- /**
270
- * The receipt for a file the scan cleared (BLI-3277, moved here by BLI-3280).
271
- *
272
- * Same schema, same `mode` — the deterministic ruleset is what ran — with the
273
- * verdict `scanned_clean` and zero of everything else. The content fields are
274
- * the point: an evidence ref is only readable downstream when its redaction
275
- * record hashes the bytes that are actually in the bucket, and for a clean file
276
- * those are the original bytes, so both halves carry the same digest and size.
277
- *
278
- * It lives in telemetry-core rather than in the collector because two callers
279
- * now build this exact record and they must never drift: the collector writes
280
- * it at upload time, and the BLI-3280 operator backfill writes it onto refs
281
- * that were stored before the receipt existed. Both must satisfy the same
282
- * `sanitized_content_hash_sha256 === content_hash_sha256` /
283
- * `sanitized_byte_size === byte_size` equality the digest reader checks, and a
284
- * second copy of this function is a second chance to get that wrong.
285
- *
286
- * `appliedBy` is who ran the scan, and it is not decoration: a backfill passes
287
- * `legacy_upload` so a reader can tell "the collector scanned this before it
288
- * uploaded" apart from "an operator scanned the stored bytes afterwards".
289
- */
290
- export function scannedCleanRedactionMetadata(bytes, options = {}) {
291
- const digest = createHash("sha256").update(bytes).digest("hex");
292
- return {
293
- schema_version: "raw-evidence-redaction.v1",
294
- status: "scanned_clean",
295
- mode: "deterministic_text_replacement",
296
- applied_by: [options.appliedBy ?? "local_collector"],
297
- rule_counts: [],
298
- secret_like_match_count: 0,
299
- redacted_fields: [],
300
- redacted_ranges: [],
301
- original_content_hash_sha256: digest,
302
- sanitized_content_hash_sha256: digest,
303
- original_byte_size: bytes.byteLength,
304
- sanitized_byte_size: bytes.byteLength,
305
- ...(options.appliedAt === undefined ? {} : { applied_at: options.appliedAt }),
306
- };
307
- }
308
- // Same fence as the detector (BLI-3116) — the two must agree or a prefixed
309
- // name would be detected and then left unredacted, which costs whole sessions
310
- // (see findSecretRedactionMatches).
311
- const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE})([A-Za-z0-9_./+=-]{12,})`, "gi");
312
- const SECRET_REDACTION_RULES = [
313
- {
314
- ruleId: "credential_assignment",
315
- pattern: SECRET_ASSIGNMENT_REDACTION_PATTERN,
316
- secretGroup: 2,
317
- },
318
- {
319
- ruleId: "private_key_block",
320
- pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
321
- },
322
- {
323
- ruleId: "private_key_header",
324
- pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
325
- },
326
- {
327
- ruleId: "bearer_token",
328
- pattern: /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{16,})/gi,
329
- secretGroup: 2,
330
- },
331
- {
332
- ruleId: "jwt",
333
- pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
334
- },
335
- {
336
- ruleId: "stripe_token",
337
- pattern: /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
338
- },
339
- {
340
- ruleId: "openai_token",
341
- pattern: /\bsk-[A-Za-z0-9_-]{16,}/g,
342
- },
343
- {
344
- ruleId: "github_token",
345
- pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{20,})/g,
346
- },
347
- {
348
- ruleId: "linear_token",
349
- pattern: /\blin_api_[A-Za-z0-9]{16,}/g,
350
- },
351
- {
352
- ruleId: "supabase_secret_token",
353
- pattern: /\bsb_secret_[A-Za-z0-9_]{16,}/g,
354
- },
355
- {
356
- ruleId: "slack_token",
357
- pattern: /\bxox[baprs]-[A-Za-z0-9-]{16,}/g,
358
- },
359
- {
360
- ruleId: "aws_access_key_id",
361
- pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
362
- },
363
- {
364
- ruleId: "langfuse_token",
365
- pattern: /\b(?:pk|sk)-lf-[A-Za-z0-9_-]{16,}\b/gi,
366
- },
367
- {
368
- ruleId: "mem0_token",
369
- pattern: /\bm0[-_][A-Za-z0-9_-]{16,}\b/gi,
370
- },
371
- ];
372
- export function redactSecretLikeContent(value, options = {}) {
373
- const matches = findSecretRedactionMatches(value);
374
- if (matches.length === 0) {
375
- return { redacted: false, text: value, metadata: null };
376
- }
377
- let text = "";
378
- let cursor = 0;
379
- const ruleCounts = new Map();
380
- for (const match of matches) {
381
- text += value.slice(cursor, match.start);
382
- text += `[REDACTED:${match.ruleId}]`;
383
- cursor = match.end;
384
- const existing = ruleCounts.get(match.ruleId) ?? {
385
- rule_id: match.ruleId,
386
- match_count: 0,
387
- redacted_char_count: 0,
388
- };
389
- existing.match_count += 1;
390
- existing.redacted_char_count += match.end - match.start;
391
- ruleCounts.set(match.ruleId, existing);
392
- }
393
- text += value.slice(cursor);
394
- const metadata = RawEvidenceRedactionMetadataSchema.parse({
395
- schema_version: "raw-evidence-redaction.v1",
396
- status: "sanitized",
397
- mode: "deterministic_text_replacement",
398
- applied_by: [options.appliedBy ?? "local_collector"],
399
- rule_counts: [...ruleCounts.values()].sort((a, b) => a.rule_id.localeCompare(b.rule_id)),
400
- secret_like_match_count: matches.length,
401
- redacted_fields: options.redactedFields ?? [],
402
- redacted_ranges: matches.slice(0, 200).map((match) => ({
403
- start: match.start,
404
- end: match.end,
405
- rule_id: match.ruleId,
406
- })),
407
- });
408
- return { redacted: true, text, metadata };
409
- }
410
- /**
411
- * Every region the detector considers a secret, redacted — precisely where a
412
- * rule can pinpoint the value, coarsely where none can.
413
- *
414
- * `containsSecretLikeContent` and `SECRET_REDACTION_RULES` are two lists that
415
- * have to agree, and nothing used to make them. When the detector matched and
416
- * no rule produced a range, `redactSecretLikeContent` reported
417
- * `redacted: false`, and the caller dropped the entire session file
418
- * (`secret_like_content_guard`). Drift between the lists cost whole sessions:
419
- * 78 fleet-wide as of 2026-08-14, 3 of Viet's 17 Claude sessions.
420
- *
421
- * The two lists can still drift — regexes are like that — but drift now costs
422
- * precision instead of evidence. A detector hit with no matching rule redacts
423
- * the whole matched span, so `redactSecretLikeContent` always reports the
424
- * redaction it performed and the transcript survives with the value removed.
425
- */
426
- function findSecretRedactionMatches(value) {
427
- const accepted = acceptNonOverlapping(secretRuleCandidates(value));
428
- const uncovered = detectorFallbackCandidates(value).filter((candidate) => !overlapsAny(candidate, accepted));
429
- if (uncovered.length === 0)
430
- return accepted;
431
- return acceptNonOverlapping([...accepted, ...uncovered]);
432
- }
433
- function secretRuleCandidates(value) {
434
- const candidates = [];
435
- SECRET_REDACTION_RULES.forEach((rule, priority) => {
436
- const pattern = cloneGlobalPattern(rule.pattern);
437
- for (const match of value.matchAll(pattern)) {
438
- const fullMatch = match[0];
439
- const matchIndex = match.index;
440
- if (!fullMatch || matchIndex === undefined)
441
- continue;
442
- const secret = rule.secretGroup ? match[rule.secretGroup] : fullMatch;
443
- if (!secret)
444
- continue;
445
- const relativeStart = fullMatch.indexOf(secret);
446
- if (relativeStart < 0)
447
- continue;
448
- candidates.push({
449
- start: matchIndex + relativeStart,
450
- end: matchIndex + relativeStart + secret.length,
451
- ruleId: rule.ruleId,
452
- priority,
453
- });
454
- }
455
- });
456
- return candidates;
457
- }
458
- /**
459
- * Ranked below every real rule, so a rule that can name the credential always
460
- * wins and the coarse span is only used where nothing else reached.
461
- */
462
- function detectorFallbackCandidates(value) {
463
- const candidates = [];
464
- const basePriority = SECRET_REDACTION_RULES.length;
465
- SECRET_LIKE_CONTENT_PATTERNS.forEach((pattern, offset) => {
466
- for (const match of value.matchAll(cloneGlobalPattern(pattern))) {
467
- const fullMatch = match[0];
468
- const matchIndex = match.index;
469
- if (!fullMatch || matchIndex === undefined)
470
- continue;
471
- candidates.push({
472
- start: matchIndex,
473
- end: matchIndex + fullMatch.length,
474
- ruleId: "unnamed_secret_shape",
475
- priority: basePriority + offset,
476
- });
477
- }
478
- });
479
- return candidates;
480
- }
481
- function overlapsAny(candidate, accepted) {
482
- return accepted.some((other) => candidate.start < other.end && other.start < candidate.end);
483
- }
484
- function acceptNonOverlapping(candidates) {
485
- const ordered = [...candidates].sort((a, b) => {
486
- if (a.start !== b.start)
487
- return a.start - b.start;
488
- if (a.priority !== b.priority)
489
- return a.priority - b.priority;
490
- return b.end - b.start - (a.end - a.start);
491
- });
492
- const accepted = [];
493
- let lastEnd = -1;
494
- for (const candidate of ordered) {
495
- if (candidate.start < lastEnd)
496
- continue;
497
- accepted.push(candidate);
498
- lastEnd = candidate.end;
499
- }
500
- return accepted;
501
- }
502
- function cloneGlobalPattern(pattern) {
503
- const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
504
- return new RegExp(pattern.source, flags);
505
- }
17
+ * This file is the table of contents (BLI-3986). It is the rulebook's four
18
+ * questions in the order a scan asks them, one sibling each, and every public
19
+ * name is still importable from `./secret-guards.js` and from the package's
20
+ * entry:
21
+ *
22
+ * - `secret-guards-path-rules.ts` — which file NAMES are never opened.
23
+ * - `secret-guards-content-rules.ts` which CONTENT looks like a secret: the
24
+ * credential-name fence, the assignment operator, the self-identifying value
25
+ * shapes, in the order the detector tries them.
26
+ * - `secret-guards-masking.ts` how a match is MASKED and which rule gets to
27
+ * name it; the rule table's INDEX is the priority, and masking never drops a
28
+ * file.
29
+ * - `secret-guards-metadata.ts` what the scan RECORDS: the redaction receipt,
30
+ * its per-status invariants, and the clean-scan builder.
31
+ *
32
+ * Two things this family may not do quietly, both locked by
33
+ * `secret-guards-lock.test.ts`: change a rule's ORDER (the reason label a
34
+ * dashboard reads is the rule that won) and change a regex's source or flags
35
+ * (a false negative on somebody's laptop, invisible to a type checker).
36
+ */
37
+ export { SECRET_FILE_SEGMENT_PATTERN, isSecretLikePathSegment, } from "./secret-guards-path-rules.js";
38
+ export { containsSecretLikeContent } from "./secret-guards-content-rules.js";
39
+ export { redactSecretLikeContent } from "./secret-guards-masking.js";
40
+ export { RawEvidenceRedactionSourceSchema, RawEvidenceRedactionRuleCountSchema, RawEvidenceRedactionRangeSchema, RawEvidenceRedactionStatusSchema, RawEvidenceRedactionMetadataSchema, scannedCleanRedactionMetadata, } from "./secret-guards-metadata.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",