@bli-cockpit/telemetry-core 0.1.19 → 0.1.22

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.
@@ -19,11 +19,22 @@ export const RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES = RAW_EVIDENCE_UPLOAD_MAX_C
19
19
  // by Vercel's ~4.5MB request-body limit (see header comment). 192 chunks
20
20
  // * 3 MiB gives a 576 MiB protocol ceiling above the current file cap.
21
21
  export const RAW_EVIDENCE_UPLOAD_MAX_CHUNK_COUNT = 192;
22
- // Final-object cap. The commit route still assembles and redaction-scans text
23
- // in memory, so keep this below V8's string ceiling with practical headroom for
24
- // buffers and redaction copies. 256 MiB covers the largest observed fleet
25
- // session (118 MiB) with >2x headroom. Raising it requires a streaming commit.
26
- export const RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES = 256 * 1024 * 1024;
22
+ // Final-object cap. The server's Supabase project-level (global) storage
23
+ // limit was raised from 50 MiB to 512 MiB on 2026-07-04 (BLI-2528; see
24
+ // docs/runbooks/cockpit-stuck-evidence-uploads.md) after that ceiling silently
25
+ // rejected every larger raw-evidence object for 57 days, so this constant must
26
+ // track the server's real ceiling rather than trail it. It cannot simply BE
27
+ // 512 MiB, though: the commit route (apps/dashboard .../evidence/upload/commit)
28
+ // still assembles the object into one Buffer and calls
29
+ // `assembled.toString("utf8")` for the secret-content scan, and 512 MiB
30
+ // (536,870,912 bytes) is 24 bytes PAST V8's `MAX_STRING_LENGTH`
31
+ // (536,870,888 on Node 22 / this repo's pinned runtime) — a file at the literal
32
+ // 512 MiB ceiling would crash the commit route with a RangeError instead of
33
+ // committing, the same silent-loss failure mode BLI-2528 was about. 500 MiB
34
+ // keeps ~12.6 MiB of real headroom under that hard ceiling for the
35
+ // Buffer.concat + string-conversion overhead, while still fitting the chunk
36
+ // protocol's 576 MiB ceiling (192 chunks * 3 MiB).
37
+ export const RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES = 500 * 1024 * 1024;
27
38
  export const RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN = 25;
28
39
  export const CODEX_SESSION_REPORT_MAX_SESSIONS = 200;
29
40
  const SafeLabelSchema = z
@@ -48,6 +48,41 @@ const SECRET_NAME_PATTERN_SOURCE = [
48
48
  "client[_-]?secret",
49
49
  "secret[_-]?access[_-]?key",
50
50
  ].join("|");
51
+ /**
52
+ * Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
53
+ * (BLI-3116).
54
+ *
55
+ * Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
56
+ * word character, so there is no word boundary between `AWS_` and `SECRET`:
57
+ * `AWS_SECRET_ACCESS_KEY=AKIA…` — the most common real spelling of the most
58
+ * common real leak — never matched and uploaded unmasked from every fleet
59
+ * machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
60
+ * `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
61
+ * `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
62
+ * fence, not the vocabulary, so the whole list is fenced differently now.
63
+ *
64
+ * `_`, `-` and `.` are identifier JOINERS here, not boundaries:
65
+ *
66
+ * - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
67
+ * at a real boundary, but a letter or digit immediately before it still
68
+ * blocks the match (`notapikey=…` stays out, as before).
69
+ * - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
70
+ * `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
71
+ *
72
+ * Prose is unaffected because the assignment requirement below is unchanged:
73
+ * "rotate your secret access key" has no `=`/`:` + opaque value and is not a
74
+ * leak. The guard's job is assignments, not vocabulary.
75
+ */
76
+ const SECRET_NAME_MATCH_SOURCE = `(?<![A-Za-z0-9])(?:${SECRET_NAME_PATTERN_SOURCE})[A-Za-z0-9_-]{0,40}`;
77
+ /**
78
+ * The assignment operator, with the closing quote of a JSON/YAML key allowed
79
+ * before it. Found while auditing the fence above: `{"aws_secret_access_key":
80
+ * "…"}` never matched either, because the name was followed by `"` and the
81
+ * pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
82
+ * shape a leaked credential most often has on the way in. The sibling redactor
83
+ * in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
84
+ */
85
+ const SECRET_ASSIGNMENT_SOURCE = `["']?\\s*[:=]\\s*["']?`;
51
86
  /**
52
87
  * Credential names that are only a leak when assigned a value (D13). The value
53
88
  * shape mirrors the long-standing generic pattern: an assignment operator
@@ -55,10 +90,18 @@ const SECRET_NAME_PATTERN_SOURCE = [
55
90
  * `GRANT ... TO service_role` (no assignment) do not match; the same name with
56
91
  * an assigned value does.
57
92
  */
58
- const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`\\b(?:${SECRET_NAME_PATTERN_SOURCE})\\b\\s*[:=]\\s*["']?[A-Za-z0-9_./+=-]{12,}`, "i");
93
+ const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE}[A-Za-z0-9_./+=-]{12,}`, "i");
59
94
  /**
60
95
  * Self-identifying secret values — blocked regardless of context because the
61
96
  * token shape itself is the credential, with no benign reading.
97
+ *
98
+ * `\b` is deliberately KEPT here, unlike the name fragments above (BLI-3116).
99
+ * These patterns are whole tokens, not fragments of a longer identifier: a
100
+ * `ghp_`/`sk-`/`AKIA…` run that continues into surrounding alphanumerics is not
101
+ * that provider's token, and unanchoring them would mask ordinary identifiers
102
+ * (`chart_m0_2024_revenue…`) for no safety gain. A real leak of any of these
103
+ * shapes is preceded by a quote, whitespace, `=`, `:` or `/` — all of which
104
+ * `\b` already admits.
62
105
  */
63
106
  const SECRET_VALUE_PATTERNS = [
64
107
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
@@ -126,7 +169,10 @@ export const RawEvidenceRedactionMetadataSchema = z
126
169
  sanitized_byte_size: z.number().int().nonnegative().optional(),
127
170
  })
128
171
  .strict();
129
- const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(\\b(?:${SECRET_NAME_PATTERN_SOURCE})\\b\\s*[:=]\\s*["']?)([A-Za-z0-9_./+=-]{12,})`, "gi");
172
+ // Same fence as the detector (BLI-3116) — the two must agree or a prefixed
173
+ // name would be detected and then left unredacted, which costs whole sessions
174
+ // (see findSecretRedactionMatches).
175
+ const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE})([A-Za-z0-9_./+=-]{12,})`, "gi");
130
176
  const SECRET_REDACTION_RULES = [
131
177
  {
132
178
  ruleId: "credential_assignment",
@@ -34,6 +34,49 @@ export declare const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
34
34
  export declare const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
35
  /** Storage refused the finished object because of its declared media type. */
36
36
  export declare const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
37
+ /**
38
+ * The commit route never survived the object (BLI-3067).
39
+ *
40
+ * Fires when the commit call answers `>= 500` with a body that is not the
41
+ * route's JSON envelope — an HTML error page from the platform. The handler
42
+ * always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
43
+ * process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
44
+ * and no catch ran, no ledger reason was written and no server log line exists.
45
+ *
46
+ * It is a distinct label because the operator-visible difference is total: a
47
+ * refusal means the server looked at these bytes and said no, this means the
48
+ * server died holding them. The collector composes the observed status onto the
49
+ * end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
50
+ * told from an out-of-memory; `classifyUploadFailure` strips that suffix.
51
+ */
52
+ export declare const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
53
+ /**
54
+ * The collector deliberately did not offer this object on this sync.
55
+ *
56
+ * Fires in the delivery-backoff partition: an object whose delivery keeps
57
+ * failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
58
+ * instead of on every sync, after one object failed 1,030 times in nine days
59
+ * (BLI-3066). The held object is reported as a failed outcome carrying this
60
+ * label rather than quietly omitted — a hold that read as success is exactly
61
+ * the green-status-hiding-missing-collection failure the fleet contract
62
+ * forbids.
63
+ *
64
+ * Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
65
+ * hold expires on a clock, so it is transient in the strict sense — the next
66
+ * eligible sync offers the same bytes again.
67
+ */
68
+ export declare const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
69
+ /**
70
+ * The listed reason a composed label is a variant of, or the label unchanged.
71
+ *
72
+ * The collector appends the observed status to some labels so an operator can
73
+ * tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
74
+ * deliberately NOT pattern matching on the label's words: the suffix is only
75
+ * removed when what remains is a reason somebody has already classified by
76
+ * hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
77
+ * still classifies as `unknown`, exactly as before.
78
+ */
79
+ export declare function baseUploadFailureReason(reason: string): string;
37
80
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
38
81
  export declare function classifyUploadFailure(reason: string | null | undefined): UploadFailureClass;
39
82
  /**
@@ -13,6 +13,12 @@
13
13
  // future reason somebody adds, which is the same class of guessing the person
14
14
  // resolver refuses to do. An unlisted reason is `unknown` and stays visible
15
15
  // until a human decides which bucket it belongs in.
16
+ //
17
+ // The one concession is a trailing `_http_<status>`, which the collector
18
+ // appends to some labels so an operator can tell a 502 from a 500. It is
19
+ // stripped only when the remaining stem is itself a listed reason — see
20
+ // `baseUploadFailureReason` — so the table still decides everything and an
21
+ // unreasoned-about stem stays `unknown`.
16
22
  /**
17
23
  * The reason written when nothing in the pipeline explained itself.
18
24
  *
@@ -34,6 +40,38 @@ export const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
34
40
  export const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
41
  /** Storage refused the finished object because of its declared media type. */
36
42
  export const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
43
+ /**
44
+ * The commit route never survived the object (BLI-3067).
45
+ *
46
+ * Fires when the commit call answers `>= 500` with a body that is not the
47
+ * route's JSON envelope — an HTML error page from the platform. The handler
48
+ * always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
49
+ * process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
50
+ * and no catch ran, no ledger reason was written and no server log line exists.
51
+ *
52
+ * It is a distinct label because the operator-visible difference is total: a
53
+ * refusal means the server looked at these bytes and said no, this means the
54
+ * server died holding them. The collector composes the observed status onto the
55
+ * end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
56
+ * told from an out-of-memory; `classifyUploadFailure` strips that suffix.
57
+ */
58
+ export const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
59
+ /**
60
+ * The collector deliberately did not offer this object on this sync.
61
+ *
62
+ * Fires in the delivery-backoff partition: an object whose delivery keeps
63
+ * failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
64
+ * instead of on every sync, after one object failed 1,030 times in nine days
65
+ * (BLI-3066). The held object is reported as a failed outcome carrying this
66
+ * label rather than quietly omitted — a hold that read as success is exactly
67
+ * the green-status-hiding-missing-collection failure the fleet contract
68
+ * forbids.
69
+ *
70
+ * Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
71
+ * hold expires on a clock, so it is transient in the strict sense — the next
72
+ * eligible sync offers the same bytes again.
73
+ */
74
+ export const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
37
75
  const UPLOAD_FAILURE_CLASSES = {
38
76
  // Budgets and locks: the file was fine and the run simply ran out of room or
39
77
  // was beaten to it. The next pass has room.
@@ -51,6 +89,16 @@ const UPLOAD_FAILURE_CLASSES = {
51
89
  // now name themselves instead of going silent. Transient because the object
52
90
  // was never offered a chance to upload — nothing about it was refused.
53
91
  begin_batch_server_error: "transient",
92
+ // The platform killed the commit process before the route could answer
93
+ // (BLI-3067). Transient for the same reason as the batch error above: nothing
94
+ // decided anything about these bytes, so the next attempt is not a repeat of
95
+ // a refusal. It is bounded rather than infinite because a repeatedly failing
96
+ // object falls into delivery backoff after the first failure, so a genuinely
97
+ // un-committable object costs ~4 attempts a day instead of 96.
98
+ [COMMIT_CRASHED_PLATFORM]: "transient",
99
+ // Held on purpose by that same backoff. The bytes are staged and eligible;
100
+ // the clock has not come round yet.
101
+ [DELIVERY_BACKOFF_HOLDING]: "transient",
54
102
  // The file itself is the problem, and it will be the same size and the same
55
103
  // shape on the next pass. Retrying is a promise nobody can keep.
56
104
  file_too_large: "deterministic",
@@ -77,11 +125,34 @@ const UPLOAD_FAILURE_CLASSES = {
77
125
  // The server-side name for the same guard, returned by the commit route.
78
126
  secret_guard_rejected: "withheld_by_policy",
79
127
  };
128
+ const HTTP_STATUS_SUFFIX = /_http_\d{3}$/u;
129
+ /**
130
+ * The listed reason a composed label is a variant of, or the label unchanged.
131
+ *
132
+ * The collector appends the observed status to some labels so an operator can
133
+ * tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
134
+ * deliberately NOT pattern matching on the label's words: the suffix is only
135
+ * removed when what remains is a reason somebody has already classified by
136
+ * hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
137
+ * still classifies as `unknown`, exactly as before.
138
+ */
139
+ export function baseUploadFailureReason(reason) {
140
+ const stem = reason.replace(HTTP_STATUS_SUFFIX, "");
141
+ if (stem === reason)
142
+ return reason;
143
+ return Object.hasOwn(UPLOAD_FAILURE_CLASSES, stem) ? stem : reason;
144
+ }
80
145
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
81
146
  export function classifyUploadFailure(reason) {
82
147
  if (!reason)
83
148
  return "unknown";
84
- return UPLOAD_FAILURE_CLASSES[reason] ?? "unknown";
149
+ const label = baseUploadFailureReason(reason);
150
+ // `hasOwn` rather than a bare index: a reason called `constructor` or
151
+ // `toString` would otherwise resolve to an inherited property and answer
152
+ // something that is not an UploadFailureClass at all.
153
+ if (!Object.hasOwn(UPLOAD_FAILURE_CLASSES, label))
154
+ return "unknown";
155
+ return UPLOAD_FAILURE_CLASSES[label] ?? "unknown";
85
156
  }
86
157
  /**
87
158
  * Whether repeating the identical attempt could plausibly succeed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.19",
3
+ "version": "0.1.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",