@obsunified/collector 2.0.1 → 2.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.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { AlertRule } from "@obsunified/types";
2
- import { describe, expect, it } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { evaluateRuleBatch } from "./alerts-evaluator";
4
4
 
5
5
  type EvaluatorStore = Parameters<typeof evaluateRuleBatch>[0];
@@ -28,12 +28,54 @@ const makeStore = (
28
28
  listEnabledRules: async () => [],
29
29
  evaluateRule: async () => 0,
30
30
  getState: async () => null,
31
+ markNotified: async () => true,
31
32
  getAnalysisNarrative: async () => null,
32
33
  transitionState: async () => {},
33
34
  recordEvaluation: async () => {},
34
35
  ...overrides,
35
36
  });
36
37
 
38
+ type StateRow = {
39
+ current_state: "ok" | "firing";
40
+ last_state_change: string;
41
+ notified: number;
42
+ };
43
+
44
+ /**
45
+ * In-memory alert_state (with the migration-049 `notified` flag) and
46
+ * alert_evaluations, mirroring AlertsStore semantics so multi-tick scenarios
47
+ * exercise the real transition / redelivery logic.
48
+ */
49
+ const makeStatefulStore = (overrides: Partial<EvaluatorStore> = {}) => {
50
+ let state: StateRow | null = null;
51
+ const transitions: string[] = [];
52
+ const evaluations: Array<{ state: string; notified: boolean }> = [];
53
+ const store = makeStore({
54
+ getState: async () => state,
55
+ transitionState: async (_id, _project, next, now) => {
56
+ transitions.push(next);
57
+ state = { current_state: next, last_state_change: now, notified: 0 };
58
+ },
59
+ markNotified: async (_id, _project, s, lastStateChange) => {
60
+ if (
61
+ !state ||
62
+ state.current_state !== s ||
63
+ state.last_state_change !== lastStateChange ||
64
+ state.notified === 1
65
+ ) {
66
+ return false;
67
+ }
68
+ state.notified = 1;
69
+ return true;
70
+ },
71
+ recordEvaluation: async (_id, _project, _value, s, notified) => {
72
+ evaluations.push({ state: s, notified });
73
+ },
74
+ ...overrides,
75
+ });
76
+ return { store, transitions, evaluations, current: () => state };
77
+ };
78
+
37
79
  describe("evaluateRuleBatch", () => {
38
80
  it("evaluates rules with a bounded concurrency limit", async () => {
39
81
  const rules = Array.from({ length: 6 }, (_, i) => makeRule(`r${i}`));
@@ -51,7 +93,12 @@ describe("evaluateRuleBatch", () => {
51
93
 
52
94
  const result = await evaluateRuleBatch(store, rules, { concurrency: 2 });
53
95
 
54
- expect(result).toEqual({ evaluated: 6, fired: 0, resolved: 0 });
96
+ expect(result).toEqual({
97
+ evaluated: 6,
98
+ fired: 0,
99
+ resolved: 0,
100
+ redelivered: 0,
101
+ });
55
102
  expect(maxActive).toBeLessThanOrEqual(2);
56
103
  });
57
104
 
@@ -83,9 +130,423 @@ describe("evaluateRuleBatch", () => {
83
130
  },
84
131
  );
85
132
 
86
- expect(result).toEqual({ evaluated: 2, fired: 0, resolved: 0 });
133
+ expect(result).toEqual({
134
+ evaluated: 2,
135
+ fired: 0,
136
+ resolved: 0,
137
+ redelivered: 0,
138
+ });
87
139
  expect(recorded).toBe(1);
88
140
  expect(errors).toHaveLength(1);
89
141
  expect(errors[0]).toMatchObject({ rule_id: "stuck" });
90
142
  });
91
143
  });
144
+
145
+ describe("evaluateRuleBatch — webhook delivery failures", () => {
146
+ afterEach(() => {
147
+ vi.unstubAllGlobals();
148
+ });
149
+
150
+ const webhookRule = (): AlertRule => ({
151
+ ...makeRule("hook"),
152
+ channels: [{ type: "webhook", url: "https://hooks.example.test/alert" }],
153
+ });
154
+
155
+ const logger = { debug() {}, info() {}, warn() {}, error() {} };
156
+
157
+ it("records firing immediately when the webhook fails, then redelivers without a second transition", async () => {
158
+ const statuses = [503, 200];
159
+ const fetchMock = vi.fn(async () => {
160
+ const status = statuses.shift() ?? 200;
161
+ return new Response(null, { status });
162
+ });
163
+ vi.stubGlobal("fetch", fetchMock);
164
+ const { store, transitions, evaluations, current } = makeStatefulStore({
165
+ evaluateRule: async () => 42,
166
+ });
167
+
168
+ // Tick 1: breach, webhook returns 503. The dashboard must still see
169
+ // `firing`; only the notification is outstanding.
170
+ const first = await evaluateRuleBatch(store, [webhookRule()], { logger });
171
+ expect(first).toEqual({
172
+ evaluated: 1,
173
+ fired: 1,
174
+ resolved: 0,
175
+ redelivered: 0,
176
+ });
177
+ expect(transitions).toEqual(["firing"]);
178
+ expect(current()).toMatchObject({ current_state: "firing", notified: 0 });
179
+ expect(evaluations.map((e) => [e.state, e.notified])).toEqual([
180
+ ["firing", false],
181
+ ]);
182
+
183
+ // Tick 2: still breaching, webhook now accepts → the owed notification
184
+ // goes out; the state does not churn.
185
+ const second = await evaluateRuleBatch(store, [webhookRule()], { logger });
186
+ expect(second).toEqual({
187
+ evaluated: 1,
188
+ fired: 0,
189
+ resolved: 0,
190
+ redelivered: 1,
191
+ });
192
+ expect(fetchMock).toHaveBeenCalledTimes(2);
193
+ expect(transitions).toEqual(["firing"]);
194
+ expect(current()).toMatchObject({ current_state: "firing", notified: 1 });
195
+ expect(evaluations.at(-1)).toMatchObject({
196
+ state: "firing",
197
+ notified: true,
198
+ });
199
+
200
+ // Tick 3: delivered already → plain evaluation, no webhook.
201
+ const third = await evaluateRuleBatch(store, [webhookRule()], { logger });
202
+ expect(third).toEqual({
203
+ evaluated: 1,
204
+ fired: 0,
205
+ resolved: 0,
206
+ redelivered: 0,
207
+ });
208
+ expect(fetchMock).toHaveBeenCalledTimes(2);
209
+ expect(evaluations.at(-1)).toMatchObject({
210
+ state: "firing",
211
+ notified: false,
212
+ });
213
+ });
214
+
215
+ it("records recovery immediately and retries the recovery webhook until it is accepted", async () => {
216
+ const fetchMock = vi.fn(async () => new Response(null, { status: 500 }));
217
+ vi.stubGlobal("fetch", fetchMock);
218
+ const { store, transitions, current } = makeStatefulStore({
219
+ evaluateRule: async () => 0,
220
+ });
221
+ await store.transitionState(
222
+ "hook",
223
+ "default",
224
+ "firing",
225
+ "2026-05-31T00:00:00.000Z",
226
+ );
227
+ transitions.length = 0;
228
+
229
+ const result = await evaluateRuleBatch(store, [webhookRule()], { logger });
230
+ expect(result).toEqual({
231
+ evaluated: 1,
232
+ fired: 0,
233
+ resolved: 1,
234
+ redelivered: 0,
235
+ });
236
+ expect(fetchMock).toHaveBeenCalledTimes(1);
237
+ expect(transitions).toEqual(["ok"]);
238
+ expect(current()?.current_state).toBe("ok");
239
+
240
+ // Next tick: still ok, recovery notice still owed → retried, no churn.
241
+ const retry = await evaluateRuleBatch(store, [webhookRule()], { logger });
242
+ expect(retry).toEqual({
243
+ evaluated: 1,
244
+ fired: 0,
245
+ resolved: 0,
246
+ redelivered: 0,
247
+ });
248
+ expect(fetchMock).toHaveBeenCalledTimes(2);
249
+ expect(transitions).toEqual(["ok"]);
250
+ });
251
+
252
+ it("notifies a delivered state exactly once however many ticks follow", async () => {
253
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
254
+ vi.stubGlobal("fetch", fetchMock);
255
+ const { store } = makeStatefulStore({ evaluateRule: async () => 42 });
256
+
257
+ await evaluateRuleBatch(store, [webhookRule()], { logger });
258
+ await evaluateRuleBatch(store, [webhookRule()], { logger });
259
+ await evaluateRuleBatch(store, [webhookRule()], { logger });
260
+ expect(fetchMock).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("acknowledges delivery before writing history, so a failed history write cannot cause a resend", async () => {
264
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
265
+ vi.stubGlobal("fetch", fetchMock);
266
+ const { logger: capturing, entries } = (() => {
267
+ const entries: string[] = [];
268
+ return {
269
+ entries,
270
+ logger: {
271
+ debug() {},
272
+ info() {},
273
+ warn() {},
274
+ error(message: string) {
275
+ entries.push(message);
276
+ },
277
+ },
278
+ };
279
+ })();
280
+ const { store, current } = makeStatefulStore({
281
+ evaluateRule: async () => 42,
282
+ });
283
+ const recordEvaluation = store.recordEvaluation;
284
+ let failHistoryOnce = true;
285
+ store.recordEvaluation = async (...args) => {
286
+ if (failHistoryOnce) {
287
+ failHistoryOnce = false;
288
+ throw new Error("alert_evaluations insert failed");
289
+ }
290
+ return recordEvaluation(...args);
291
+ };
292
+
293
+ await evaluateRuleBatch(store, [webhookRule()], { logger: capturing });
294
+ expect(fetchMock).toHaveBeenCalledTimes(1);
295
+ expect(entries).toContain("[alerts-evaluator] rule evaluation failed");
296
+ // The webhook was accepted, so the transition is already acknowledged.
297
+ expect(current()).toMatchObject({ current_state: "firing", notified: 1 });
298
+
299
+ await evaluateRuleBatch(store, [webhookRule()], { logger: capturing });
300
+ expect(fetchMock).toHaveBeenCalledTimes(1);
301
+ });
302
+
303
+ it("treats a state row without the notified column (schema before 049) as delivered", async () => {
304
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
305
+ vi.stubGlobal("fetch", fetchMock);
306
+ const store = makeStore({
307
+ evaluateRule: async () => 42,
308
+ getState: async () => ({
309
+ current_state: "firing",
310
+ last_state_change: "2026-05-31T00:00:00.000Z",
311
+ }),
312
+ });
313
+
314
+ const result = await evaluateRuleBatch(store, [webhookRule()], { logger });
315
+ expect(result).toEqual({
316
+ evaluated: 1,
317
+ fired: 0,
318
+ resolved: 0,
319
+ redelivered: 0,
320
+ });
321
+ expect(fetchMock).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it("marks only the transition it delivered, so a state change during delivery keeps its own flag", async () => {
325
+ // The webhook for `firing` is slow; by the time it is accepted the rule
326
+ // has already flipped to `ok` on another tick.
327
+ const { store, current, transitions } = makeStatefulStore({
328
+ evaluateRule: async () => 42,
329
+ });
330
+ let releaseFiring: () => void = () => {};
331
+ const slowFetch = vi.fn(
332
+ () =>
333
+ new Promise<Response>((resolve) => {
334
+ releaseFiring = () => resolve(new Response(null, { status: 200 }));
335
+ }),
336
+ );
337
+ vi.stubGlobal("fetch", slowFetch);
338
+ const firingTick = evaluateRuleBatch(store, [webhookRule()], { logger });
339
+ await vi.waitFor(() => expect(slowFetch).toHaveBeenCalledTimes(1));
340
+
341
+ // A concurrent tick observes recovery and records `ok`, delivered at once.
342
+ vi.stubGlobal(
343
+ "fetch",
344
+ vi.fn(async () => new Response(null, { status: 200 })),
345
+ );
346
+ await evaluateRuleBatch(
347
+ { ...store, evaluateRule: async () => 0 },
348
+ [webhookRule()],
349
+ { logger },
350
+ );
351
+ expect(current()).toMatchObject({ current_state: "ok", notified: 1 });
352
+
353
+ releaseFiring();
354
+ await firingTick;
355
+ // The late `firing` acknowledgement must not touch the `ok` row.
356
+ expect(transitions).toEqual(["firing", "ok"]);
357
+ expect(current()).toMatchObject({ current_state: "ok", notified: 1 });
358
+ });
359
+ });
360
+
361
+ describe("evaluateRuleBatch — webhook target safety", () => {
362
+ afterEach(() => {
363
+ vi.unstubAllGlobals();
364
+ });
365
+
366
+ const SECRET_HOOK = "https://hooks.slack.com/services/T000/B000/SUPERSECRET";
367
+
368
+ const ruleFor = (url: string): AlertRule => ({
369
+ ...makeRule("safety"),
370
+ channels: [{ type: "webhook", url }],
371
+ });
372
+
373
+ const capturingLogger = () => {
374
+ const entries: Array<{ message: string; attrs?: Record<string, unknown> }> =
375
+ [];
376
+ return {
377
+ entries,
378
+ logger: {
379
+ debug() {},
380
+ info() {},
381
+ warn(message: string, attrs?: Record<string, unknown>) {
382
+ entries.push({ message, attrs });
383
+ },
384
+ error(message: string, attrs?: Record<string, unknown>) {
385
+ entries.push({ message, attrs });
386
+ },
387
+ },
388
+ };
389
+ };
390
+
391
+ it("does not follow redirects and never logs the secret-bearing URL", async () => {
392
+ const fetchMock = vi.fn(
393
+ async (_url: string, _init?: RequestInit) =>
394
+ new Response(null, {
395
+ status: 302,
396
+ headers: { Location: "https://evil.example/collect" },
397
+ }),
398
+ );
399
+ vi.stubGlobal("fetch", fetchMock);
400
+ const { logger, entries } = capturingLogger();
401
+ const spanAttrs: Record<string, unknown>[] = [];
402
+ const tracer = async <T>(
403
+ _name: string,
404
+ fn: (span: { setAttribute(k: string, v: unknown): void }) => Promise<T>,
405
+ attrs?: Record<string, unknown>,
406
+ ) => {
407
+ if (attrs) spanAttrs.push(attrs);
408
+ return fn({ setAttribute() {} });
409
+ };
410
+ const { store, transitions, evaluations } = makeStatefulStore({
411
+ evaluateRule: async () => 42,
412
+ });
413
+
414
+ const result = await evaluateRuleBatch(store, [ruleFor(SECRET_HOOK)], {
415
+ logger,
416
+ tracer: tracer as never,
417
+ });
418
+
419
+ // The redirect counts as a failed delivery: the state is recorded but
420
+ // the evaluation is marked not notified, so the next tick retries.
421
+ expect(result).toEqual({
422
+ evaluated: 1,
423
+ fired: 1,
424
+ resolved: 0,
425
+ redelivered: 0,
426
+ });
427
+ expect(fetchMock).toHaveBeenCalledTimes(1);
428
+ expect((fetchMock.mock.calls[0]?.[1] as RequestInit).redirect).toBe(
429
+ "manual",
430
+ );
431
+ expect(transitions).toEqual(["firing"]);
432
+ expect(evaluations.at(-1)).toMatchObject({
433
+ state: "firing",
434
+ notified: false,
435
+ });
436
+ const serialized = JSON.stringify({ entries, spanAttrs });
437
+ expect(serialized).not.toContain("SUPERSECRET");
438
+ expect(serialized).toContain("https://hooks.slack.com/…");
439
+ });
440
+
441
+ it("refuses private network targets at fire time unless explicitly allowed", async () => {
442
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
443
+ vi.stubGlobal("fetch", fetchMock);
444
+ const { logger, entries } = capturingLogger();
445
+ const rule = ruleFor("http://169.254.169.254/latest/meta-data/");
446
+ const { store, current, evaluations } = makeStatefulStore({
447
+ evaluateRule: async () => 42,
448
+ });
449
+
450
+ // The breach is recorded as `firing` for the dashboard even though the
451
+ // target is refused; nothing is sent.
452
+ const blocked = await evaluateRuleBatch(store, [rule], { logger });
453
+ expect(blocked).toEqual({
454
+ evaluated: 1,
455
+ fired: 1,
456
+ resolved: 0,
457
+ redelivered: 0,
458
+ });
459
+ expect(current()?.current_state).toBe("firing");
460
+ expect(evaluations).toEqual([
461
+ expect.objectContaining({ state: "firing", notified: false }),
462
+ ]);
463
+ expect(fetchMock).not.toHaveBeenCalled();
464
+ expect(entries.map((e) => e.message)).toContain(
465
+ "[alerts-evaluator] webhook target refused",
466
+ );
467
+
468
+ const allowed = await evaluateRuleBatch(store, [rule], {
469
+ logger,
470
+ allowPrivateWebhookTargets: true,
471
+ });
472
+ // Once allowed, the outstanding notification is delivered without
473
+ // another transition.
474
+ expect(allowed).toEqual({
475
+ evaluated: 1,
476
+ fired: 0,
477
+ resolved: 0,
478
+ redelivered: 1,
479
+ });
480
+ expect(fetchMock).toHaveBeenCalledTimes(1);
481
+ });
482
+
483
+ it("refuses a public name whose DNS answer is private when a resolver is provided", async () => {
484
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
485
+ vi.stubGlobal("fetch", fetchMock);
486
+ const { logger, entries } = capturingLogger();
487
+ const { store } = makeStatefulStore({ evaluateRule: async () => 42 });
488
+ const rule = ruleFor("https://hooks.example.com/alert/abc");
489
+
490
+ const refused = await evaluateRuleBatch(store, [rule], {
491
+ logger,
492
+ hostLookup: async () => ["192.168.1.20"],
493
+ });
494
+ expect(refused).toEqual({
495
+ evaluated: 1,
496
+ fired: 1,
497
+ resolved: 0,
498
+ redelivered: 0,
499
+ });
500
+ expect(fetchMock).not.toHaveBeenCalled();
501
+ expect(JSON.stringify(entries)).toContain("resolves to a private address");
502
+
503
+ const delivered = await evaluateRuleBatch(store, [rule], {
504
+ logger,
505
+ hostLookup: async () => ["93.184.216.34"],
506
+ });
507
+ expect(delivered).toEqual({
508
+ evaluated: 1,
509
+ fired: 0,
510
+ resolved: 0,
511
+ redelivered: 1,
512
+ });
513
+ expect(fetchMock).toHaveBeenCalledTimes(1);
514
+ });
515
+
516
+ it("refuses delivery when the configured resolver cannot answer, unless private targets are allowed", async () => {
517
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
518
+ vi.stubGlobal("fetch", fetchMock);
519
+ const { logger, entries } = capturingLogger();
520
+ const { store } = makeStatefulStore({ evaluateRule: async () => 42 });
521
+ const rule = ruleFor("https://hooks.example.com/alert/abc");
522
+ const failingLookup = async () => {
523
+ throw new Error("EAI_AGAIN");
524
+ };
525
+
526
+ const refused = await evaluateRuleBatch(store, [rule], {
527
+ logger,
528
+ hostLookup: failingLookup,
529
+ });
530
+ expect(refused).toEqual({
531
+ evaluated: 1,
532
+ fired: 1,
533
+ resolved: 0,
534
+ redelivered: 0,
535
+ });
536
+ expect(fetchMock).not.toHaveBeenCalled();
537
+ expect(JSON.stringify(entries)).toContain("could not be resolved");
538
+
539
+ // The private-network opt-in skips the resolver entirely.
540
+ const allowed = await evaluateRuleBatch(store, [rule], {
541
+ logger,
542
+ hostLookup: failingLookup,
543
+ allowPrivateWebhookTargets: true,
544
+ });
545
+ expect(allowed).toEqual({
546
+ evaluated: 1,
547
+ fired: 0,
548
+ resolved: 0,
549
+ redelivered: 1,
550
+ });
551
+ });
552
+ });