Skip to content

Commit 8784ca7

Browse files
DailenGclaude
andcommitted
feat: webhook relay for false positive reports
New false_positive_relay_url instance setting. When set, every stored inbound webhook event is POSTed once to that URL as JSON with the inbox row fields and the original payload forwarded verbatim as a string, so operators can trigger n8n, Power Automate, or similar automations for tickets and notifications. Delivery is best effort inside waitUntil after the durable insert: https only, one attempt, no retry, and a relay failure never fails or delays the reporting extension. Runbook documents the payload shape and the hostility assumptions receivers must keep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 788241b commit 8784ca7

8 files changed

Lines changed: 168 additions & 3 deletions

File tree

‎BACKLOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ Current work queue, in order. Update as items land.
1717
persistence. Entry deleted below per convention.
1818
- [x] Wiki regeneration for the wizard commit
1919
- [x] v0.2.0 release (setup wizard; package.json bumped to match)
20+
- [x] Webhook relay for false positives: `false_positive_relay_url`
21+
instance setting; every inbound `/hook/{guid}` event is POSTed once,
22+
best effort via waitUntil, as `{source, kind, event}` JSON with
23+
payload_json forwarded verbatim; https only; injectable-fetcher unit
24+
tests (4); documented in the runbook webhook inbox section.
2025

2126
Queue complete. Next scoped item: GPO deployment artifacts (item 1 below).
2227

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Multi tenant configuration service for the [Check by CyberDrain](https://docs.ch
88

99
- **Rules host.** Mirrors the upstream CyberDrain detection rules daily, layers a small per tenant delta on top (extra exclusions, trusted patterns, custom indicators, suppressions), validates everything, and serves each client an immutable published ruleset at an unguessable URL: `/rules/{guid}.json`.
1010
- **Policy generator.** Renders ready-to-deploy managed policy artifacts per tenant: Chrome and Edge managed storage JSON, Firefox `policies.json` (fragment and full file), `.reg` files for GPO, the variable block for Check's Intune setup script, and the field values for CIPP's Check deployment standard.
11-
- **Operations dashboard.** Draft and publish with validation gates, one-click rollback, GUID rotation and revocation with hit counters, tenant branding with logo hosting, a webhook inbox for false positive reports, upstream diff history, and an indefinite audit log. Dark mode by default.
11+
- **Operations dashboard.** Draft and publish with validation gates, one-click rollback, GUID rotation and revocation with hit counters, tenant branding with logo hosting, a webhook inbox for false positive reports with an optional relay that forwards each report to n8n, Power Automate, or any webhook receiver, upstream diff history, and an indefinite audit log. Dark mode by default.
1212

1313
Two delivery paths stay separate by design: detection rules are URL fetched by the extension on its own schedule, while branding and enforcement settings are pushed to browsers via managed storage policy.
1414

‎docs/runbook.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ The cron runs daily (06:17 UTC). Each sync fetches the CyberDrain rules file; on
143143

144144
False positive reports and other extension events POST to `/hook/{guid}` and land in the Inbox. Payloads are stored verbatim, treated as hostile, and always rendered escaped. Disposition events as reviewed or dismissed; dispositioned events are purged by the daily cleanup, undispositioned ones after the retention window.
145145

146+
**Relay to automations.** Set the **False positive relay URL** instance setting to forward every inbound event to an automation platform (n8n, Power Automate, Zapier, or any webhook receiver) for tickets or notifications. Each event is POSTed once as JSON: `{source, kind, event}` where `event` carries the inbox row (id, tenant id and name, GUID, received time, event type) and the original `payload_json` as a verbatim string. Notes:
147+
148+
- The URL must be `https://`. Platforms like n8n and Power Automate embed a capability token in the URL itself; treat the configured URL as sensitive.
149+
- Delivery is best effort: one attempt, no retry queue. The Inbox row is the durable record; the relay is a convenience copy, and a failed relay never fails or delays the reporting extension.
150+
- The relayed `payload_json` is untrusted extension input forwarded verbatim. Automations consuming it must apply the same hostility assumptions this service does: parse defensively, never render unescaped, never execute.
151+
146152
### Decommissioning a tenant
147153

148154
Revoke all GUIDs, wait for revoked-hit counters to drain (confirming no clients still point at it), then Tenant > Delete. Deletion removes the tenant's rows and R2 objects; audit entries are retained.

‎src/lib/db.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export const DEFAULT_INSTANCE_SETTINGS: Record<string, string> = {
7575
"https://raw.githubusercontent.com/CyberDrain/Check/main/rules/detection-rules.json",
7676
upstream_keep_snapshots: "10",
7777
version_suffix_label: "cdm",
78+
// When set, every inbound webhook event is POSTed to this URL (n8n,
79+
// Power Automate, and similar). Empty disables the relay.
80+
false_positive_relay_url: "",
7881
// ISO timestamp once the setup wizard is finished or skipped; empty means
7982
// the wizard is still offered. Not listed in the Settings page UI.
8083
onboarding_completed_at: "",

‎src/lib/relay.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Outbound relay for inbound webhook reports (false positives and similar).
2+
// When the false_positive_relay_url instance setting is set, every stored
3+
// webhook event is POSTed to it as JSON, so operators can trigger n8n,
4+
// Power Automate, or comparable automations (tickets, notifications).
5+
//
6+
// Delivery is best effort: one attempt inside waitUntil, no queue and no
7+
// retry, matching the zero-infrastructure design. The extension retries
8+
// nothing either; the inbox row is the durable record and the relay is a
9+
// convenience copy. payload_json is forwarded verbatim as a string, never
10+
// parsed or interpreted here; receivers must treat it as hostile input.
11+
import type { Env } from "../types";
12+
import { getInstanceSettings } from "./db";
13+
14+
export interface RelayEvent {
15+
id: string;
16+
tenant_id: string;
17+
tenant_name: string;
18+
guid: string;
19+
received_at: string;
20+
event_type: string;
21+
payload_json: string;
22+
}
23+
24+
export type RelayOutcome =
25+
| { status: "skipped" }
26+
| { status: "sent"; httpStatus: number }
27+
| { status: "failed"; error: string };
28+
29+
export async function relayWebhookEvent(
30+
env: Env,
31+
event: RelayEvent,
32+
fetcher: typeof fetch = fetch,
33+
): Promise<RelayOutcome> {
34+
const settings = await getInstanceSettings(env.DB);
35+
const url = (settings.false_positive_relay_url ?? "").trim();
36+
if (url === "") return { status: "skipped" };
37+
if (!/^https:\/\//i.test(url)) {
38+
return { status: "failed", error: "relay URL must start with https://" };
39+
}
40+
try {
41+
const response = await fetcher(url, {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
body: JSON.stringify({
45+
source: "checkdeploymanager",
46+
kind: "webhook_event",
47+
event,
48+
}),
49+
});
50+
if (!response.ok) {
51+
return { status: "failed", error: `HTTP ${response.status}` };
52+
}
53+
return { status: "sent", httpStatus: response.status };
54+
} catch (error) {
55+
return { status: "failed", error: String(error) };
56+
}
57+
}

‎src/routes/hook.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// as hostile: never interpreted, always HTML-escaped when rendered.
33
import { Hono } from "hono";
44
import type { Env } from "../types";
5-
import { getActiveGuid, newId, nowIso } from "../lib/db";
5+
import { getActiveGuid, getTenant, newId, nowIso } from "../lib/db";
6+
import { relayWebhookEvent } from "../lib/relay";
67

78
export const MAX_HOOK_BYTES = 256 * 1024;
89

@@ -41,12 +42,34 @@ hookRoutes.post("/hook/:guid", async (c) => {
4142
return c.json({ error: "body is not valid JSON" }, 400);
4243
}
4344

45+
const eventId = newId();
46+
const receivedAt = nowIso();
4447
await c.env.DB.prepare(
4548
"INSERT INTO webhook_events (id, tenant_id, guid, received_at, event_type, payload_json) " +
4649
"VALUES (?, ?, ?, ?, ?, ?)",
4750
)
48-
.bind(newId(), guidRow.tenant_id, guidRow.guid, nowIso(), eventType, body)
51+
.bind(eventId, guidRow.tenant_id, guidRow.guid, receivedAt, eventType, body)
4952
.run();
5053

54+
// Best-effort relay after the durable insert; never delays or fails the
55+
// extension's request.
56+
c.executionCtx.waitUntil(
57+
(async () => {
58+
const tenant = await getTenant(c.env.DB, guidRow.tenant_id);
59+
const outcome = await relayWebhookEvent(c.env, {
60+
id: eventId,
61+
tenant_id: guidRow.tenant_id,
62+
tenant_name: tenant?.name ?? "",
63+
guid: guidRow.guid,
64+
received_at: receivedAt,
65+
event_type: eventType,
66+
payload_json: body,
67+
});
68+
if (outcome.status === "failed") {
69+
console.log(`webhook relay failed: ${outcome.error}`);
70+
}
71+
})(),
72+
);
73+
5174
return c.json({ received: true });
5275
});

‎src/ui/manage/app.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,7 @@ async function renderUpstream() {
857857
const SETTING_LABELS = [
858858
["public_base_url", "Public base URL (e.g. https://check.example.com; used in every generated artifact)"],
859859
["default_cipp_server_url", "Default CIPP server URL (blank disables CIPP unless a tenant overrides)"],
860+
["false_positive_relay_url", "False positive relay URL (every inbound webhook report is POSTed here as JSON; for n8n, Power Automate, and similar; blank disables)"],
860861
["upstream_source_url", "Upstream rules source URL"],
861862
["version_suffix_label", "Version suffix label (published versions read upstream+label.n)"],
862863
["metrics_retention_days", "Fetch metrics retention (days)"],

‎test/relay.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { env } from "cloudflare:test";
2+
import { describe, expect, it } from "vitest";
3+
import { relayWebhookEvent, type RelayEvent } from "../src/lib/relay";
4+
import { putInstanceSetting } from "../src/lib/db";
5+
import { fetcherFailing, fetcherReturning, SAMPLE_GUID, SAMPLE_TENANT_NAME } from "./helpers";
6+
7+
function sampleEvent(): RelayEvent {
8+
return {
9+
id: "00000000-0000-4000-8000-000000000000",
10+
tenant_id: "00000000-0000-4000-8000-000000000000",
11+
tenant_name: SAMPLE_TENANT_NAME,
12+
guid: SAMPLE_GUID,
13+
received_at: "2026-07-03T00:00:00.000Z",
14+
event_type: "false_positive_report",
15+
payload_json: '{"reportType":"false_positive_report","url":"https://login.example.com"}',
16+
};
17+
}
18+
19+
function fetcherCapturing(captured: { url?: string; body?: string }): typeof fetch {
20+
return (async (url: any, init: any) => {
21+
captured.url = String(url);
22+
captured.body = String(init?.body ?? "");
23+
return new Response("ok", { status: 200 });
24+
}) as typeof fetch;
25+
}
26+
27+
function fetcherRefusing(): typeof fetch {
28+
return (async () => {
29+
throw new Error("relay fetch must not be called when disabled");
30+
}) as typeof fetch;
31+
}
32+
33+
describe("webhook relay", () => {
34+
it("skips without calling out when no relay URL is configured", async () => {
35+
const outcome = await relayWebhookEvent(env, sampleEvent(), fetcherRefusing());
36+
expect(outcome).toEqual({ status: "skipped" });
37+
});
38+
39+
it("POSTs the event as JSON to the configured URL", async () => {
40+
await putInstanceSetting(env.DB, "false_positive_relay_url", "https://hooks.example.test/relay");
41+
const captured: { url?: string; body?: string } = {};
42+
const outcome = await relayWebhookEvent(env, sampleEvent(), fetcherCapturing(captured));
43+
expect(outcome).toEqual({ status: "sent", httpStatus: 200 });
44+
expect(captured.url).toBe("https://hooks.example.test/relay");
45+
const sent = JSON.parse(captured.body ?? "{}");
46+
expect(sent.source).toBe("checkdeploymanager");
47+
expect(sent.kind).toBe("webhook_event");
48+
expect(sent.event.tenant_name).toBe(SAMPLE_TENANT_NAME);
49+
expect(sent.event.event_type).toBe("false_positive_report");
50+
// The payload travels verbatim as a string, never parsed by the relay.
51+
expect(typeof sent.event.payload_json).toBe("string");
52+
});
53+
54+
it("rejects non-https relay URLs", async () => {
55+
await putInstanceSetting(env.DB, "false_positive_relay_url", "http://insecure.example.test/hook");
56+
const outcome = await relayWebhookEvent(env, sampleEvent(), fetcherRefusing());
57+
expect(outcome).toEqual({
58+
status: "failed",
59+
error: "relay URL must start with https://",
60+
});
61+
});
62+
63+
it("reports failure on network errors and non-2xx responses", async () => {
64+
await putInstanceSetting(env.DB, "false_positive_relay_url", "https://hooks.example.test/relay");
65+
const network = await relayWebhookEvent(env, sampleEvent(), fetcherFailing());
66+
expect(network.status).toBe("failed");
67+
const http = await relayWebhookEvent(env, sampleEvent(), fetcherReturning("nope", 500));
68+
expect(http).toEqual({ status: "failed", error: "HTTP 500" });
69+
});
70+
});

0 commit comments

Comments
 (0)