@obsunified/collector 2.0.1 → 2.1.1
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/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SqlDb` over Node's built-in `node:sqlite` for tests that need real SQL
|
|
3
|
+
* execution against the shipped `migrations/` (as opposed to `MemSqlDb`,
|
|
4
|
+
* which only records which statements a store chose to issue).
|
|
5
|
+
*
|
|
6
|
+
* `batch` runs the statements inside one transaction, matching what D1's
|
|
7
|
+
* `batch` and `PostgresAdapter.batch` guarantee.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { type SqlDb, type SqlStatement, sqliteDialect } from "../sql-db";
|
|
15
|
+
|
|
16
|
+
type SqliteBind = string | number | bigint | null | Uint8Array;
|
|
17
|
+
|
|
18
|
+
/** D1 accepts `undefined` and booleans in `bind`; node:sqlite does not. */
|
|
19
|
+
const toBind = (value: unknown): SqliteBind => {
|
|
20
|
+
if (value === undefined || value === null) return null;
|
|
21
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
22
|
+
if (
|
|
23
|
+
typeof value === "number" ||
|
|
24
|
+
typeof value === "bigint" ||
|
|
25
|
+
typeof value === "string" ||
|
|
26
|
+
value instanceof Uint8Array
|
|
27
|
+
) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
export const SQLITE_MIGRATIONS_DIR = path.resolve(here, "../../migrations");
|
|
35
|
+
|
|
36
|
+
/** Sorted migration file names, optionally only those up to `through`. */
|
|
37
|
+
export const listSqliteMigrations = (through?: string): string[] =>
|
|
38
|
+
readdirSync(SQLITE_MIGRATIONS_DIR)
|
|
39
|
+
.filter((f) => f.endsWith(".sql"))
|
|
40
|
+
.filter((f) => through === undefined || f <= through)
|
|
41
|
+
.sort();
|
|
42
|
+
|
|
43
|
+
export interface ApplyMigrationsOptions {
|
|
44
|
+
/** Apply files whose numeric prefix is `>= from` (inclusive, e.g. "045"). */
|
|
45
|
+
from?: string;
|
|
46
|
+
/** Apply files whose numeric prefix is `<= upTo` (inclusive, e.g. "044"). */
|
|
47
|
+
upTo?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Apply `migrations/*.sql` in sorted order; returns the file names applied. */
|
|
51
|
+
export function applySqliteMigrations(
|
|
52
|
+
db: DatabaseSync,
|
|
53
|
+
options: ApplyMigrationsOptions = {},
|
|
54
|
+
): string[] {
|
|
55
|
+
const applied: string[] = [];
|
|
56
|
+
for (const file of listSqliteMigrations()) {
|
|
57
|
+
const prefix = file.slice(0, 3);
|
|
58
|
+
if (options.from && prefix < options.from) continue;
|
|
59
|
+
if (options.upTo && prefix > options.upTo) continue;
|
|
60
|
+
db.exec(readFileSync(path.join(SQLITE_MIGRATIONS_DIR, file), "utf8"));
|
|
61
|
+
applied.push(file);
|
|
62
|
+
}
|
|
63
|
+
return applied;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fresh in-memory database with the selected D1 migrations applied. */
|
|
67
|
+
export const createMigratedSqliteDb = (
|
|
68
|
+
options: ApplyMigrationsOptions = {},
|
|
69
|
+
): NodeSqliteDb => {
|
|
70
|
+
const db = new NodeSqliteDb();
|
|
71
|
+
applySqliteMigrations(db.raw, options);
|
|
72
|
+
return db;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Column names of a table's PRIMARY KEY in key order. */
|
|
76
|
+
export const primaryKeyColumns = (db: DatabaseSync, table: string): string[] =>
|
|
77
|
+
(
|
|
78
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
|
79
|
+
name: string;
|
|
80
|
+
pk: number;
|
|
81
|
+
}>
|
|
82
|
+
)
|
|
83
|
+
.filter((c) => c.pk > 0)
|
|
84
|
+
.sort((a, b) => a.pk - b.pk)
|
|
85
|
+
.map((c) => c.name);
|
|
86
|
+
|
|
87
|
+
/** Index names declared on a table (SQLite auto-indexes excluded). */
|
|
88
|
+
export const indexNames = (db: DatabaseSync, table: string): string[] =>
|
|
89
|
+
(db.prepare(`PRAGMA index_list(${table})`).all() as Array<{ name: string }>)
|
|
90
|
+
.map((i) => i.name)
|
|
91
|
+
.filter((n) => !n.startsWith("sqlite_autoindex"))
|
|
92
|
+
.sort();
|
|
93
|
+
|
|
94
|
+
export class NodeSqliteDb implements SqlDb {
|
|
95
|
+
readonly dialect = sqliteDialect;
|
|
96
|
+
|
|
97
|
+
constructor(readonly raw: DatabaseSync = new DatabaseSync(":memory:")) {}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
this.raw.close();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Apply `migrations/*.sql` in order. `through` applies up to that file. */
|
|
104
|
+
static migrated(through?: string): NodeSqliteDb {
|
|
105
|
+
const db = new NodeSqliteDb();
|
|
106
|
+
db.applyMigrations(listSqliteMigrations(through));
|
|
107
|
+
return db;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
applyMigrations(files: readonly string[]): void {
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
this.raw.exec(
|
|
113
|
+
readFileSync(path.join(SQLITE_MIGRATIONS_DIR, file), "utf8"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
prepare(sql: string): SqlStatement {
|
|
119
|
+
return new NodeSqliteStatement(this.raw, sql, []);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Batches are serialized and run without yielding, like D1's atomic batch. */
|
|
123
|
+
private batchQueue: Promise<unknown> = Promise.resolve();
|
|
124
|
+
|
|
125
|
+
batch(
|
|
126
|
+
statements: SqlStatement[],
|
|
127
|
+
): Promise<Array<{ meta: { changes: number } }>> {
|
|
128
|
+
const run = () => {
|
|
129
|
+
const results: Array<{ meta: { changes: number } }> = [];
|
|
130
|
+
this.raw.exec("BEGIN");
|
|
131
|
+
try {
|
|
132
|
+
for (const stmt of statements) {
|
|
133
|
+
if (!(stmt instanceof NodeSqliteStatement)) {
|
|
134
|
+
throw new Error("NodeSqliteDb.batch: foreign statement");
|
|
135
|
+
}
|
|
136
|
+
results.push(stmt.runSync());
|
|
137
|
+
}
|
|
138
|
+
this.raw.exec("COMMIT");
|
|
139
|
+
} catch (err) {
|
|
140
|
+
this.raw.exec("ROLLBACK");
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
return results;
|
|
144
|
+
};
|
|
145
|
+
const next = this.batchQueue.then(run, run);
|
|
146
|
+
this.batchQueue = next.catch(() => undefined);
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Convenience for assertions: `SELECT COUNT(*)` with an optional filter. */
|
|
151
|
+
count(table: string, where = "1 = 1", ...binds: SqliteBind[]): number {
|
|
152
|
+
const row = this.raw
|
|
153
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`)
|
|
154
|
+
.get(...binds) as { n: number | bigint };
|
|
155
|
+
return Number(row.n);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class NodeSqliteStatement implements SqlStatement {
|
|
160
|
+
constructor(
|
|
161
|
+
private readonly db: DatabaseSync,
|
|
162
|
+
private readonly sql: string,
|
|
163
|
+
private readonly binds: readonly unknown[],
|
|
164
|
+
) {}
|
|
165
|
+
|
|
166
|
+
bind(...args: unknown[]): SqlStatement {
|
|
167
|
+
return new NodeSqliteStatement(this.db, this.sql, args.map(toBind));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async first<T = Record<string, unknown>>(): Promise<T | null> {
|
|
171
|
+
const row = this.db.prepare(this.sql).get(...(this.binds as SqliteBind[]));
|
|
172
|
+
return (row ?? null) as T | null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
|
|
176
|
+
const rows = this.db.prepare(this.sql).all(...(this.binds as SqliteBind[]));
|
|
177
|
+
return { results: rows as T[] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
runSync(): { meta: { changes: number } } {
|
|
181
|
+
const info = this.db.prepare(this.sql).run(...(this.binds as SqliteBind[]));
|
|
182
|
+
return { meta: { changes: Number(info.changes) } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async run(): Promise<{ meta: { changes: number } }> {
|
|
186
|
+
return this.runSync();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeWebhookUrl,
|
|
4
|
+
isPrivateWebhookHost,
|
|
5
|
+
redactWebhookUrl,
|
|
6
|
+
resolveWebhookHost,
|
|
7
|
+
WebhookUrlError,
|
|
8
|
+
} from "./webhook-url";
|
|
9
|
+
|
|
10
|
+
describe("assertSafeWebhookUrl", () => {
|
|
11
|
+
it("accepts public http(s) hooks", () => {
|
|
12
|
+
for (const url of [
|
|
13
|
+
"https://hooks.slack.com/services/T000/B000/XXXX",
|
|
14
|
+
"https://events.pagerduty.com/v2/enqueue",
|
|
15
|
+
"http://alerts.example.com:8080/hook?team=1",
|
|
16
|
+
"https://8.8.8.8/hook",
|
|
17
|
+
]) {
|
|
18
|
+
expect(() => assertSafeWebhookUrl(url)).not.toThrow();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects non-http schemes, relative URLs and embedded credentials", () => {
|
|
23
|
+
for (const url of [
|
|
24
|
+
"ftp://example.com/x",
|
|
25
|
+
"file:///etc/passwd",
|
|
26
|
+
"javascript:alert(1)",
|
|
27
|
+
"/relative/hook",
|
|
28
|
+
"https://user:pass@example.com/hook",
|
|
29
|
+
]) {
|
|
30
|
+
expect(() => assertSafeWebhookUrl(url), url).toThrow(WebhookUrlError);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects loopback, private, link-local and metadata hosts", () => {
|
|
35
|
+
for (const url of [
|
|
36
|
+
"http://localhost/hook",
|
|
37
|
+
"http://app.localhost/hook",
|
|
38
|
+
"http://127.0.0.1:9000/hook",
|
|
39
|
+
"http://0.0.0.0/hook",
|
|
40
|
+
"http://10.1.2.3/hook",
|
|
41
|
+
"http://172.16.0.1/hook",
|
|
42
|
+
"http://192.168.1.1/hook",
|
|
43
|
+
"http://100.64.0.1/hook",
|
|
44
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
45
|
+
"http://[::1]/hook",
|
|
46
|
+
"http://[fe80::1]/hook",
|
|
47
|
+
"http://[fd00::1]/hook",
|
|
48
|
+
"http://[::ffff:10.0.0.1]/hook",
|
|
49
|
+
"http://collector/hook",
|
|
50
|
+
"http://db.internal/hook",
|
|
51
|
+
]) {
|
|
52
|
+
expect(() => assertSafeWebhookUrl(url), url).toThrow(WebhookUrlError);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("allows private hosts only with the explicit opt-in", () => {
|
|
57
|
+
const url = "http://alertmanager.internal:9093/api/v2/alerts";
|
|
58
|
+
expect(() => assertSafeWebhookUrl(url)).toThrow(WebhookUrlError);
|
|
59
|
+
expect(
|
|
60
|
+
assertSafeWebhookUrl(url, { allowPrivateNetworks: true }).hostname,
|
|
61
|
+
).toBe("alertmanager.internal");
|
|
62
|
+
// The opt-in never unlocks credentials or other schemes.
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertSafeWebhookUrl("ftp://10.0.0.1/x", { allowPrivateNetworks: true }),
|
|
65
|
+
).toThrow(WebhookUrlError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("isPrivateWebhookHost treats public names as public", () => {
|
|
69
|
+
expect(isPrivateWebhookHost("hooks.slack.com")).toBe(false);
|
|
70
|
+
expect(isPrivateWebhookHost("hooks.slack.com.")).toBe(false);
|
|
71
|
+
expect(isPrivateWebhookHost("1.1.1.1")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("redactWebhookUrl", () => {
|
|
76
|
+
it("keeps only the origin: any path segment or query may be the credential", () => {
|
|
77
|
+
expect(
|
|
78
|
+
redactWebhookUrl("https://hooks.slack.com/services/T000/B000/SECRET"),
|
|
79
|
+
).toBe("https://hooks.slack.com/…");
|
|
80
|
+
// A generic receiver whose only secret is the single path segment.
|
|
81
|
+
expect(redactWebhookUrl("https://hooks.example.com/TOKEN123")).toBe(
|
|
82
|
+
"https://hooks.example.com/…",
|
|
83
|
+
);
|
|
84
|
+
expect(redactWebhookUrl("https://example.com/?token=abc")).toBe(
|
|
85
|
+
"https://example.com/…",
|
|
86
|
+
);
|
|
87
|
+
expect(redactWebhookUrl("https://user:pw@example.com:8443/")).toBe(
|
|
88
|
+
"https://example.com:8443",
|
|
89
|
+
);
|
|
90
|
+
expect(redactWebhookUrl("not a url")).toBe("<invalid url>");
|
|
91
|
+
for (const url of [
|
|
92
|
+
"https://hooks.example.com/TOKEN123",
|
|
93
|
+
"https://example.com/a/b?token=abc#frag",
|
|
94
|
+
"https://user:pw@example.com/",
|
|
95
|
+
]) {
|
|
96
|
+
expect(redactWebhookUrl(url)).not.toMatch(/TOKEN123|token=|pw|abc|frag/);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("resolveWebhookHost classifies answers and fails closed when it cannot resolve", async () => {
|
|
101
|
+
const lookup = async (host: string) =>
|
|
102
|
+
host === "evil.example"
|
|
103
|
+
? ["93.184.216.34", "10.0.0.8"]
|
|
104
|
+
: ["93.184.216.34"];
|
|
105
|
+
expect(await resolveWebhookHost("evil.example", lookup)).toBe("private");
|
|
106
|
+
expect(await resolveWebhookHost("hooks.example", lookup)).toBe("public");
|
|
107
|
+
expect(
|
|
108
|
+
await resolveWebhookHost("v6.example", async () => ["fd00::1"]),
|
|
109
|
+
).toBe("private");
|
|
110
|
+
// IP literals were judged already by the literal check.
|
|
111
|
+
expect(await resolveWebhookHost("10.0.0.8", lookup)).toBe("public");
|
|
112
|
+
// No answer is never "safe": the delivery fetch would resolve on its own.
|
|
113
|
+
expect(
|
|
114
|
+
await resolveWebhookHost("down.example", async () => {
|
|
115
|
+
throw new Error("ENOTFOUND");
|
|
116
|
+
}),
|
|
117
|
+
).toBe("unresolvable");
|
|
118
|
+
expect(await resolveWebhookHost("empty.example", async () => [])).toBe(
|
|
119
|
+
"unresolvable",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert webhook destinations are admin-supplied URLs the collector POSTs to
|
|
3
|
+
* from inside its own network. Two guards live here:
|
|
4
|
+
*
|
|
5
|
+
* - `assertSafeWebhookUrl` rejects anything that is not plain http(s) to a
|
|
6
|
+
* public host, so a rule cannot be pointed at loopback, RFC 1918 ranges,
|
|
7
|
+
* link-local (cloud metadata) or embedded credentials. Self-hosted
|
|
8
|
+
* installs that legitimately post to an internal receiver can opt in
|
|
9
|
+
* with `allowPrivateNetworks` (env `ALERT_WEBHOOK_ALLOW_PRIVATE=true`).
|
|
10
|
+
* - `redactWebhookUrl` renders a URL for logs and span attributes. Slack
|
|
11
|
+
* and PagerDuty-style hooks carry their secret in the path, so only the
|
|
12
|
+
* origin and the first path segment survive; query and userinfo never do.
|
|
13
|
+
*
|
|
14
|
+
* Hostnames are checked as written; DNS resolution happens at fetch time
|
|
15
|
+
* and a name that resolves to a private address is not caught here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface WebhookUrlOptions {
|
|
19
|
+
allowPrivateNetworks?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WebhookUrlError extends Error {}
|
|
23
|
+
|
|
24
|
+
const parseIpv4 = (host: string): number[] | null => {
|
|
25
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const octets = m.slice(1).map((o) => Number.parseInt(o, 10));
|
|
28
|
+
return octets.every((o) => o <= 255) ? octets : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isPrivateIpv4 = (o: number[]): boolean =>
|
|
32
|
+
o[0] === 0 || // "this" network / unspecified
|
|
33
|
+
o[0] === 10 ||
|
|
34
|
+
o[0] === 127 ||
|
|
35
|
+
(o[0] === 100 && o[1] >= 64 && o[1] <= 127) || // CGNAT
|
|
36
|
+
(o[0] === 169 && o[1] === 254) || // link-local incl. cloud metadata
|
|
37
|
+
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
|
|
38
|
+
(o[0] === 192 && o[1] === 168) ||
|
|
39
|
+
o[0] >= 224; // multicast, reserved, broadcast
|
|
40
|
+
|
|
41
|
+
const isPrivateIpv6 = (host: string): boolean => {
|
|
42
|
+
// URL.hostname keeps the brackets for IPv6 literals.
|
|
43
|
+
const literal = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
44
|
+
if (literal === "::" || literal === "::1") return true;
|
|
45
|
+
// IPv4-mapped (::ffff:a.b.c.d). The WHATWG URL parser serializes the
|
|
46
|
+
// embedded address as two hex groups (::ffff:a00:1), so accept both.
|
|
47
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(literal);
|
|
48
|
+
if (dotted) {
|
|
49
|
+
const v4 = parseIpv4(dotted[1]);
|
|
50
|
+
return v4 ? isPrivateIpv4(v4) : true;
|
|
51
|
+
}
|
|
52
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(literal);
|
|
53
|
+
if (hex) {
|
|
54
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
55
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
56
|
+
return isPrivateIpv4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]);
|
|
57
|
+
}
|
|
58
|
+
const first = Number.parseInt(literal.split(":")[0] || "0", 16);
|
|
59
|
+
if (Number.isNaN(first)) return true;
|
|
60
|
+
if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
61
|
+
if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
62
|
+
if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** True for hosts that name the collector itself or a non-public network. */
|
|
67
|
+
export const isPrivateWebhookHost = (hostname: string): boolean => {
|
|
68
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
69
|
+
if (!host) return true;
|
|
70
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
71
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) return true;
|
|
72
|
+
if (host.startsWith("[")) return isPrivateIpv6(host);
|
|
73
|
+
const v4 = parseIpv4(host);
|
|
74
|
+
if (v4) return isPrivateIpv4(v4);
|
|
75
|
+
// Bare hostnames without a dot are almost always intranet names.
|
|
76
|
+
return !host.includes(".");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Parse and validate a webhook destination; returns the normalized URL. */
|
|
80
|
+
export const assertSafeWebhookUrl = (
|
|
81
|
+
raw: string,
|
|
82
|
+
options: WebhookUrlOptions = {},
|
|
83
|
+
): URL => {
|
|
84
|
+
let url: URL;
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new WebhookUrlError("channel.url must be an absolute http(s) URL");
|
|
89
|
+
}
|
|
90
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
91
|
+
throw new WebhookUrlError("channel.url must be http(s)://");
|
|
92
|
+
}
|
|
93
|
+
if (url.username || url.password) {
|
|
94
|
+
throw new WebhookUrlError(
|
|
95
|
+
"channel.url must not embed credentials; use channel.headers",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!options.allowPrivateNetworks && isPrivateWebhookHost(url.hostname)) {
|
|
99
|
+
throw new WebhookUrlError(
|
|
100
|
+
`channel.url host "${url.hostname}" is not a public address (set ALERT_WEBHOOK_ALLOW_PRIVATE=true to allow internal receivers)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return url;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Resolve a hostname to its addresses (e.g. `dns.lookup(host, { all: true })`). */
|
|
107
|
+
export type HostLookup = (hostname: string) => Promise<string[]>;
|
|
108
|
+
|
|
109
|
+
export type HostResolution = "public" | "private" | "unresolvable";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Second line of defence for hosts that pass the literal check: resolve the
|
|
113
|
+
* name and classify it. Any private / loopback / link-local answer makes it
|
|
114
|
+
* `private`. A lookup that throws or returns nothing is `unresolvable` —
|
|
115
|
+
* callers must treat that as a refusal too (fail closed): the delivery
|
|
116
|
+
* fetch resolves on its own, so "we could not see where this goes" must
|
|
117
|
+
* never become "send it anyway". The fetch's own resolution can still
|
|
118
|
+
* differ from this one (rebinding); that window is not closed here.
|
|
119
|
+
*/
|
|
120
|
+
export const resolveWebhookHost = async (
|
|
121
|
+
hostname: string,
|
|
122
|
+
lookup: HostLookup,
|
|
123
|
+
): Promise<HostResolution> => {
|
|
124
|
+
// Literals were already judged by isPrivateWebhookHost.
|
|
125
|
+
if (parseIpv4(hostname) || hostname.startsWith("[")) return "public";
|
|
126
|
+
let addresses: string[];
|
|
127
|
+
try {
|
|
128
|
+
addresses = await lookup(hostname);
|
|
129
|
+
} catch {
|
|
130
|
+
return "unresolvable";
|
|
131
|
+
}
|
|
132
|
+
if (addresses.length === 0) return "unresolvable";
|
|
133
|
+
const isPrivate = addresses.some((address) => {
|
|
134
|
+
const v4 = parseIpv4(address);
|
|
135
|
+
if (v4) return isPrivateIpv4(v4);
|
|
136
|
+
return isPrivateIpv6(`[${address}]`);
|
|
137
|
+
});
|
|
138
|
+
return isPrivate ? "private" : "public";
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Origin only. Any path segment or query parameter of a webhook URL may be
|
|
143
|
+
* the credential (Slack nests it, generic receivers often use a single
|
|
144
|
+
* token segment), so nothing past the host is ever rendered; `/…` marks
|
|
145
|
+
* that a path existed. Userinfo is dropped with the rest.
|
|
146
|
+
*/
|
|
147
|
+
export const redactWebhookUrl = (raw: string): string => {
|
|
148
|
+
try {
|
|
149
|
+
const url = new URL(raw);
|
|
150
|
+
const hasPath = url.pathname !== "/" || url.search !== "";
|
|
151
|
+
return `${url.protocol}//${url.host}${hasPath ? "/…" : ""}`;
|
|
152
|
+
} catch {
|
|
153
|
+
return "<invalid url>";
|
|
154
|
+
}
|
|
155
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- Migration 042: session replay composite primary key (project_id, session_id)
|
|
2
|
+
--
|
|
3
|
+
-- Fixes cross-project session replay metadata collisions. Previously,
|
|
4
|
+
-- session_replay_metadata used session_id alone as primary key while
|
|
5
|
+
-- project_id was added in migration 010. Replay receiver upserts used
|
|
6
|
+
-- ON CONFLICT(session_id), causing identical session IDs from different
|
|
7
|
+
-- projects to overwrite or increment each other.
|
|
8
|
+
--
|
|
9
|
+
-- This forward migration recreates the table with PRIMARY KEY (project_id, session_id),
|
|
10
|
+
-- preserving all existing rows and column defaults, and recreates associated indexes.
|
|
11
|
+
|
|
12
|
+
DROP TABLE IF EXISTS session_replay_metadata_new;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE session_replay_metadata_new (
|
|
15
|
+
project_id TEXT NOT NULL DEFAULT 'default',
|
|
16
|
+
session_id TEXT NOT NULL,
|
|
17
|
+
visitor_id TEXT NOT NULL,
|
|
18
|
+
first_chunk_at TEXT NOT NULL,
|
|
19
|
+
last_chunk_at TEXT NOT NULL,
|
|
20
|
+
chunk_count INTEGER DEFAULT 0,
|
|
21
|
+
events_count INTEGER DEFAULT 0,
|
|
22
|
+
storage_bytes INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
PRIMARY KEY (project_id, session_id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
INSERT INTO session_replay_metadata_new (
|
|
27
|
+
project_id,
|
|
28
|
+
session_id,
|
|
29
|
+
visitor_id,
|
|
30
|
+
first_chunk_at,
|
|
31
|
+
last_chunk_at,
|
|
32
|
+
chunk_count,
|
|
33
|
+
events_count,
|
|
34
|
+
storage_bytes
|
|
35
|
+
)
|
|
36
|
+
SELECT
|
|
37
|
+
COALESCE(project_id, 'default'),
|
|
38
|
+
session_id,
|
|
39
|
+
visitor_id,
|
|
40
|
+
first_chunk_at,
|
|
41
|
+
last_chunk_at,
|
|
42
|
+
COALESCE(chunk_count, 0),
|
|
43
|
+
COALESCE(events_count, 0),
|
|
44
|
+
COALESCE(storage_bytes, 0)
|
|
45
|
+
FROM session_replay_metadata;
|
|
46
|
+
|
|
47
|
+
DROP TABLE session_replay_metadata;
|
|
48
|
+
|
|
49
|
+
ALTER TABLE session_replay_metadata_new RENAME TO session_replay_metadata;
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_replay_visitor ON session_replay_metadata (visitor_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_replay_project_last ON session_replay_metadata (project_id, last_chunk_at DESC);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- No-op on SQLite/D1: metric_exemplars.received_at and expires_at have been
|
|
2
|
+
-- TEXT since 035. Present only to keep migration numbering in parity with
|
|
3
|
+
-- migrations-postgres/043_metric_exemplars_text_timestamps.sql, which
|
|
4
|
+
-- converts the Postgres columns from TIMESTAMPTZ to ISO TEXT.
|
|
5
|
+
SELECT 1;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- RFC 0010 — action graph retention.
|
|
2
|
+
--
|
|
3
|
+
-- The hourly retention sweep expires action trees by `actions.started_at`
|
|
4
|
+
-- (the spine has no `expires_at` column). Existing indexes all lead with
|
|
5
|
+
-- `project_id` plus another key, so a cross-project "oldest actions first"
|
|
6
|
+
-- scan would read the whole table every hour. This index lets the sweep
|
|
7
|
+
-- pick its next batch of candidates in index order.
|
|
8
|
+
|
|
9
|
+
CREATE INDEX IF NOT EXISTS idx_actions_started_at
|
|
10
|
+
ON actions (started_at);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Migration 045: user_profiles composite primary key (project_id, user_id)
|
|
2
|
+
--
|
|
3
|
+
-- user_profiles was created in 006 with `user_id TEXT PRIMARY KEY` and only
|
|
4
|
+
-- gained project_id in 010. /v1/identify upserted on ON CONFLICT(user_id),
|
|
5
|
+
-- so an ingest key for one project could read or rewrite another project's
|
|
6
|
+
-- profile by posting the same userId (commonly an email address).
|
|
7
|
+
--
|
|
8
|
+
-- Rebuilds the table with PRIMARY KEY (project_id, user_id), preserving
|
|
9
|
+
-- every row and the two indexes. The old key was unique, so the composite
|
|
10
|
+
-- key cannot collide. Re-running the file is a no-op in effect.
|
|
11
|
+
|
|
12
|
+
DROP TABLE IF EXISTS user_profiles_new;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE user_profiles_new (
|
|
15
|
+
project_id TEXT NOT NULL DEFAULT 'default',
|
|
16
|
+
user_id TEXT NOT NULL,
|
|
17
|
+
visitor_id TEXT NOT NULL,
|
|
18
|
+
email TEXT,
|
|
19
|
+
name TEXT,
|
|
20
|
+
properties_json TEXT,
|
|
21
|
+
first_seen_at TEXT NOT NULL,
|
|
22
|
+
last_seen_at TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (project_id, user_id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
INSERT INTO user_profiles_new (
|
|
27
|
+
project_id, user_id, visitor_id, email, name, properties_json,
|
|
28
|
+
first_seen_at, last_seen_at
|
|
29
|
+
)
|
|
30
|
+
SELECT
|
|
31
|
+
COALESCE(project_id, 'default'), user_id, visitor_id, email, name,
|
|
32
|
+
properties_json, first_seen_at, last_seen_at
|
|
33
|
+
FROM user_profiles;
|
|
34
|
+
|
|
35
|
+
DROP TABLE user_profiles;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE user_profiles_new RENAME TO user_profiles;
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_user_visitor ON user_profiles (visitor_id);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_users_project_last ON user_profiles (project_id, last_seen_at DESC);
|