@bli-cockpit/telemetry-core 0.1.40 → 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.
@@ -0,0 +1,192 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { IsoDateTimeSchema } from "./common.js";
4
+ /**
5
+ * What a secret scan RECORDS: the redaction receipt every uploaded object
6
+ * carries, and the one builder of the clean half of it.
7
+ *
8
+ * Owned by `secret-guards.ts` (BLI-3986); the rules that produce these records
9
+ * live in `secret-guards-content-rules.ts` and `secret-guards-masking.ts`.
10
+ * Nothing here decides what a secret IS — it decides what a reader downstream
11
+ * is told about a scan that already ran, which is why the per-status
12
+ * invariants below are the load-bearing part: a record that claims a
13
+ * sanitization that never happened is worse than a missing record.
14
+ */
15
+ export const RawEvidenceRedactionSourceSchema = z.enum([
16
+ "local_collector",
17
+ "server_commit",
18
+ "legacy_upload",
19
+ ]);
20
+ export const RawEvidenceRedactionRuleCountSchema = z
21
+ .object({
22
+ rule_id: z.string().trim().min(1).max(80),
23
+ match_count: z.number().int().nonnegative(),
24
+ redacted_char_count: z.number().int().nonnegative(),
25
+ })
26
+ .strict();
27
+ export const RawEvidenceRedactionRangeSchema = z
28
+ .object({
29
+ start: z.number().int().nonnegative(),
30
+ end: z.number().int().positive(),
31
+ rule_id: z.string().trim().min(1).max(80),
32
+ })
33
+ .strict()
34
+ .superRefine((range, context) => {
35
+ if (range.end <= range.start) {
36
+ context.addIssue({
37
+ code: "custom",
38
+ message: "redaction range end must be greater than start",
39
+ path: ["end"],
40
+ });
41
+ }
42
+ });
43
+ /**
44
+ * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
45
+ *
46
+ * `sanitized` means the scan matched and the uploaded bytes differ from what was
47
+ * read. `scanned_clean` means the same scan ran over the same bytes and matched
48
+ * nothing, so the uploaded bytes are the original ones. The second value exists
49
+ * because every reader downstream asks "was this scanned?", and for 70% of
50
+ * transcripts — the ones with no secret in them — the honest answer used to be
51
+ * an absent record, which reads identically to "nobody ever looked".
52
+ */
53
+ export const RawEvidenceRedactionStatusSchema = z.enum([
54
+ "sanitized",
55
+ "scanned_clean",
56
+ ]);
57
+ export const RawEvidenceRedactionMetadataSchema = z
58
+ .object({
59
+ schema_version: z.literal("raw-evidence-redaction.v1"),
60
+ status: RawEvidenceRedactionStatusSchema,
61
+ mode: z.literal("deterministic_text_replacement"),
62
+ applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
63
+ rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
64
+ secret_like_match_count: z.number().int().nonnegative(),
65
+ redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
66
+ redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
67
+ original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
68
+ sanitized_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
69
+ original_byte_size: z.number().int().nonnegative().optional(),
70
+ sanitized_byte_size: z.number().int().nonnegative().optional(),
71
+ /**
72
+ * When the scan ran, ISO-8601 with an offset (BLI-3290).
73
+ *
74
+ * Optional, and it stays optional: every receipt written before this field
75
+ * existed is still valid, and the collector does not stamp it — at upload
76
+ * time the ref's own `received_at` already answers "when". It exists for
77
+ * receipts written over bytes that were stored long ago, where the ref's
78
+ * timestamps describe the ORIGINAL upload and nothing in the record would
79
+ * otherwise say when an operator re-scanned or re-masked the object.
80
+ */
81
+ applied_at: IsoDateTimeSchema.optional(),
82
+ })
83
+ .strict()
84
+ // The per-status invariants the old `z.literal("sanitized")` + `.min(1)` +
85
+ // `.positive()` shape enforced structurally. They still hold for a sanitized
86
+ // record; a clean one has to prove the opposite — no rule fired, no range was
87
+ // replaced, no field was masked, and the bytes came out the way they went in.
88
+ .superRefine((metadata, context) => {
89
+ if (metadata.status === "sanitized") {
90
+ if (metadata.rule_counts.length === 0) {
91
+ context.addIssue({
92
+ code: "custom",
93
+ message: "a sanitized redaction must name at least one rule",
94
+ path: ["rule_counts"],
95
+ });
96
+ }
97
+ if (metadata.secret_like_match_count < 1) {
98
+ context.addIssue({
99
+ code: "custom",
100
+ message: "a sanitized redaction must count at least one match",
101
+ path: ["secret_like_match_count"],
102
+ });
103
+ }
104
+ return;
105
+ }
106
+ if (metadata.rule_counts.length > 0) {
107
+ context.addIssue({
108
+ code: "custom",
109
+ message: "a clean scan cannot name a rule that fired",
110
+ path: ["rule_counts"],
111
+ });
112
+ }
113
+ if (metadata.secret_like_match_count !== 0) {
114
+ context.addIssue({
115
+ code: "custom",
116
+ message: "a clean scan cannot count a match",
117
+ path: ["secret_like_match_count"],
118
+ });
119
+ }
120
+ if (metadata.redacted_ranges.length > 0) {
121
+ context.addIssue({
122
+ code: "custom",
123
+ message: "a clean scan cannot redact a range",
124
+ path: ["redacted_ranges"],
125
+ });
126
+ }
127
+ if (metadata.redacted_fields.length > 0) {
128
+ context.addIssue({
129
+ code: "custom",
130
+ message: "a clean scan cannot redact a field",
131
+ path: ["redacted_fields"],
132
+ });
133
+ }
134
+ if (metadata.original_content_hash_sha256 !== undefined &&
135
+ metadata.sanitized_content_hash_sha256 !== undefined &&
136
+ metadata.original_content_hash_sha256 !==
137
+ metadata.sanitized_content_hash_sha256) {
138
+ context.addIssue({
139
+ code: "custom",
140
+ message: "a clean scan cannot change the content hash",
141
+ path: ["sanitized_content_hash_sha256"],
142
+ });
143
+ }
144
+ if (metadata.original_byte_size !== undefined &&
145
+ metadata.sanitized_byte_size !== undefined &&
146
+ metadata.original_byte_size !== metadata.sanitized_byte_size) {
147
+ context.addIssue({
148
+ code: "custom",
149
+ message: "a clean scan cannot change the byte size",
150
+ path: ["sanitized_byte_size"],
151
+ });
152
+ }
153
+ });
154
+ /**
155
+ * The receipt for a file the scan cleared (BLI-3277, moved here by BLI-3280).
156
+ *
157
+ * Same schema, same `mode` — the deterministic ruleset is what ran — with the
158
+ * verdict `scanned_clean` and zero of everything else. The content fields are
159
+ * the point: an evidence ref is only readable downstream when its redaction
160
+ * record hashes the bytes that are actually in the bucket, and for a clean file
161
+ * those are the original bytes, so both halves carry the same digest and size.
162
+ *
163
+ * It lives in telemetry-core rather than in the collector because two callers
164
+ * now build this exact record and they must never drift: the collector writes
165
+ * it at upload time, and the BLI-3280 operator backfill writes it onto refs
166
+ * that were stored before the receipt existed. Both must satisfy the same
167
+ * `sanitized_content_hash_sha256 === content_hash_sha256` /
168
+ * `sanitized_byte_size === byte_size` equality the digest reader checks, and a
169
+ * second copy of this function is a second chance to get that wrong.
170
+ *
171
+ * `appliedBy` is who ran the scan, and it is not decoration: a backfill passes
172
+ * `legacy_upload` so a reader can tell "the collector scanned this before it
173
+ * uploaded" apart from "an operator scanned the stored bytes afterwards".
174
+ */
175
+ export function scannedCleanRedactionMetadata(bytes, options = {}) {
176
+ const digest = createHash("sha256").update(bytes).digest("hex");
177
+ return {
178
+ schema_version: "raw-evidence-redaction.v1",
179
+ status: "scanned_clean",
180
+ mode: "deterministic_text_replacement",
181
+ applied_by: [options.appliedBy ?? "local_collector"],
182
+ rule_counts: [],
183
+ secret_like_match_count: 0,
184
+ redacted_fields: [],
185
+ redacted_ranges: [],
186
+ original_content_hash_sha256: digest,
187
+ sanitized_content_hash_sha256: digest,
188
+ original_byte_size: bytes.byteLength,
189
+ sanitized_byte_size: bytes.byteLength,
190
+ ...(options.appliedAt === undefined ? {} : { applied_at: options.appliedAt }),
191
+ };
192
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which file NAMES are never opened at all.
3
+ *
4
+ * Owned by `secret-guards.ts` (BLI-3986). This is the cheapest of the guard's
5
+ * questions and the only one answered without reading a byte of content, which
6
+ * is why it stays broad and why it is asked first: the collector's attribution
7
+ * pass, its collection pass and the path walk all share this one definition
8
+ * instead of re-declaring a pattern each.
9
+ */
10
+ /**
11
+ * File-path / file-name segments that must never be read at all. This is a
12
+ * NAME matcher (it never reads file content), so it stays broad: a file called
13
+ * `service-role.json` or a dotenv file is skipped on its name alone. The
14
+ * collector imports this instead of re-declaring it, so attribution,
15
+ * collection, and the path walk share one definition.
16
+ */
17
+ export declare const SECRET_FILE_SEGMENT_PATTERN: RegExp;
18
+ /**
19
+ * Does this path segment name a file the collector must not open? One place
20
+ * asks the pattern so every caller skips the same names.
21
+ */
22
+ export declare function isSecretLikePathSegment(value: string): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Which file NAMES are never opened at all.
3
+ *
4
+ * Owned by `secret-guards.ts` (BLI-3986). This is the cheapest of the guard's
5
+ * questions and the only one answered without reading a byte of content, which
6
+ * is why it stays broad and why it is asked first: the collector's attribution
7
+ * pass, its collection pass and the path walk all share this one definition
8
+ * instead of re-declaring a pattern each.
9
+ */
10
+ /**
11
+ * File-path / file-name segments that must never be read at all. This is a
12
+ * NAME matcher (it never reads file content), so it stays broad: a file called
13
+ * `service-role.json` or a dotenv file is skipped on its name alone. The
14
+ * collector imports this instead of re-declaring it, so attribution,
15
+ * collection, and the path walk share one definition.
16
+ */
17
+ export const SECRET_FILE_SEGMENT_PATTERN = /(^|[/\\])(?:\.env(?:\..*)?|.*(?:secret|credential|private[_-]?key|service[_-]?role).*)$/i;
18
+ /**
19
+ * Does this path segment name a file the collector must not open? One place
20
+ * asks the pattern so every caller skips the same names.
21
+ */
22
+ export function isSecretLikePathSegment(value) {
23
+ return SECRET_FILE_SEGMENT_PATTERN.test(value);
24
+ }
@@ -1,4 +1,3 @@
1
- import { z } from "zod";
2
1
  /**
3
2
  * Secret-like content + file-name guards shared by the local collector
4
3
  * (attribution, collection) and the dashboard commit-time guard. Keeping a
@@ -14,113 +13,30 @@ import { z } from "zod";
14
13
  * harvest cares most about). Value-shaped tokens (JWT, sk-, ghp_, PEM blocks,
15
14
  * AWS key ids, Slack/Linear/Langfuse tokens) are self-identifying and stay
16
15
  * blocked regardless of surrounding context.
17
- */
18
- /**
19
- * File-path / file-name segments that must never be read at all. This is a
20
- * NAME matcher (it never reads file content), so it stays broad: a file called
21
- * `service-role.json` or a dotenv file is skipped on its name alone. The
22
- * collector imports this instead of re-declaring it, so attribution,
23
- * collection, and the path walk share one definition.
24
- */
25
- export declare const SECRET_FILE_SEGMENT_PATTERN: RegExp;
26
- export declare function isSecretLikePathSegment(value: string): boolean;
27
- export declare function containsSecretLikeContent(value: string): boolean;
28
- export declare const RawEvidenceRedactionSourceSchema: z.ZodEnum<{
29
- local_collector: "local_collector";
30
- server_commit: "server_commit";
31
- legacy_upload: "legacy_upload";
32
- }>;
33
- export type RawEvidenceRedactionSource = z.infer<typeof RawEvidenceRedactionSourceSchema>;
34
- export declare const RawEvidenceRedactionRuleCountSchema: z.ZodObject<{
35
- rule_id: z.ZodString;
36
- match_count: z.ZodNumber;
37
- redacted_char_count: z.ZodNumber;
38
- }, z.core.$strict>;
39
- export type RawEvidenceRedactionRuleCount = z.infer<typeof RawEvidenceRedactionRuleCountSchema>;
40
- export declare const RawEvidenceRedactionRangeSchema: z.ZodObject<{
41
- start: z.ZodNumber;
42
- end: z.ZodNumber;
43
- rule_id: z.ZodString;
44
- }, z.core.$strict>;
45
- export type RawEvidenceRedactionRange = z.infer<typeof RawEvidenceRedactionRangeSchema>;
46
- /**
47
- * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
48
- *
49
- * `sanitized` means the scan matched and the uploaded bytes differ from what was
50
- * read. `scanned_clean` means the same scan ran over the same bytes and matched
51
- * nothing, so the uploaded bytes are the original ones. The second value exists
52
- * because every reader downstream asks "was this scanned?", and for 70% of
53
- * transcripts — the ones with no secret in them — the honest answer used to be
54
- * an absent record, which reads identically to "nobody ever looked".
55
- */
56
- export declare const RawEvidenceRedactionStatusSchema: z.ZodEnum<{
57
- sanitized: "sanitized";
58
- scanned_clean: "scanned_clean";
59
- }>;
60
- export type RawEvidenceRedactionStatus = z.infer<typeof RawEvidenceRedactionStatusSchema>;
61
- export declare const RawEvidenceRedactionMetadataSchema: z.ZodObject<{
62
- schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
63
- status: z.ZodEnum<{
64
- sanitized: "sanitized";
65
- scanned_clean: "scanned_clean";
66
- }>;
67
- mode: z.ZodLiteral<"deterministic_text_replacement">;
68
- applied_by: z.ZodArray<z.ZodEnum<{
69
- local_collector: "local_collector";
70
- server_commit: "server_commit";
71
- legacy_upload: "legacy_upload";
72
- }>>;
73
- rule_counts: z.ZodArray<z.ZodObject<{
74
- rule_id: z.ZodString;
75
- match_count: z.ZodNumber;
76
- redacted_char_count: z.ZodNumber;
77
- }, z.core.$strict>>;
78
- secret_like_match_count: z.ZodNumber;
79
- redacted_fields: z.ZodDefault<z.ZodArray<z.ZodString>>;
80
- redacted_ranges: z.ZodDefault<z.ZodArray<z.ZodObject<{
81
- start: z.ZodNumber;
82
- end: z.ZodNumber;
83
- rule_id: z.ZodString;
84
- }, z.core.$strict>>>;
85
- original_content_hash_sha256: z.ZodOptional<z.ZodString>;
86
- sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
87
- original_byte_size: z.ZodOptional<z.ZodNumber>;
88
- sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
89
- applied_at: z.ZodOptional<z.ZodString>;
90
- }, z.core.$strict>;
91
- export type RawEvidenceRedactionMetadata = z.infer<typeof RawEvidenceRedactionMetadataSchema>;
92
- /**
93
- * The receipt for a file the scan cleared (BLI-3277, moved here by BLI-3280).
94
16
  *
95
- * Same schema, same `mode` the deterministic ruleset is what ran — with the
96
- * verdict `scanned_clean` and zero of everything else. The content fields are
97
- * the point: an evidence ref is only readable downstream when its redaction
98
- * record hashes the bytes that are actually in the bucket, and for a clean file
99
- * those are the original bytes, so both halves carry the same digest and size.
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:
100
21
  *
101
- * It lives in telemetry-core rather than in the collector because two callers
102
- * now build this exact record and they must never drift: the collector writes
103
- * it at upload time, and the BLI-3280 operator backfill writes it onto refs
104
- * that were stored before the receipt existed. Both must satisfy the same
105
- * `sanitized_content_hash_sha256 === content_hash_sha256` /
106
- * `sanitized_byte_size === byte_size` equality the digest reader checks, and a
107
- * second copy of this function is a second chance to get that wrong.
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.
108
31
  *
109
- * `appliedBy` is who ran the scan, and it is not decoration: a backfill passes
110
- * `legacy_upload` so a reader can tell "the collector scanned this before it
111
- * uploaded" apart from "an operator scanned the stored bytes afterwards".
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).
112
36
  */
113
- export declare function scannedCleanRedactionMetadata(bytes: Uint8Array, options?: {
114
- appliedBy?: RawEvidenceRedactionSource;
115
- /** ISO-8601 with offset. Omitted entirely when absent (BLI-3290). */
116
- appliedAt?: string;
117
- }): RawEvidenceRedactionMetadata;
118
- export interface SecretRedactionResult {
119
- redacted: boolean;
120
- text: string;
121
- metadata: RawEvidenceRedactionMetadata | null;
122
- }
123
- export declare function redactSecretLikeContent(value: string, options?: {
124
- appliedBy?: RawEvidenceRedactionSource;
125
- redactedFields?: string[];
126
- }): SecretRedactionResult;
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 type { SecretRedactionResult } from "./secret-guards-masking.js";
41
+ export { RawEvidenceRedactionSourceSchema, RawEvidenceRedactionRuleCountSchema, RawEvidenceRedactionRangeSchema, RawEvidenceRedactionStatusSchema, RawEvidenceRedactionMetadataSchema, scannedCleanRedactionMetadata, } from "./secret-guards-metadata.js";
42
+ export type { RawEvidenceRedactionSource, RawEvidenceRedactionRuleCount, RawEvidenceRedactionRange, RawEvidenceRedactionStatus, RawEvidenceRedactionMetadata, } from "./secret-guards-metadata.js";