Skip to content

Commit 07b5d0a

Browse files
committed
feat: add discord run reporting
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent 989b2ef commit 07b5d0a

44 files changed

Lines changed: 2513 additions & 540 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ DATABASE_URL="postgresql://sentris:sentris@localhost:5433/sentris_instance_0"
55

66
# HTTP server configuration
77
PORT="3211"
8+
# Public frontend base URL used in Slack/Discord lifecycle notification links
9+
SENTRIS_FRONTEND_BASE_URL="http://localhost:5173"
810

911
# Temporal connection details
1012
TEMPORAL_ADDRESS="localhost:7233"
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
{
2+
"_metadata": {
3+
"name": "Security Scan Discord Report",
4+
"description": "Scan a container image with Trivy, build a prioritized CVE summary, post results to Discord, and save a JSON report artifact.",
5+
"category": "container-security",
6+
"tags": ["container", "cve", "trivy", "discord", "notification"],
7+
"author": "sentris-team",
8+
"version": "1.0.0"
9+
},
10+
"manifest": {
11+
"name": "Security Scan Discord Report",
12+
"description": "Trivy image scan with Discord webhook notification and artifact report.",
13+
"version": "1.0.0",
14+
"author": "sentris-team",
15+
"category": "container-security",
16+
"tags": ["container", "cve", "trivy", "discord", "notification"],
17+
"entryPoint": "trigger_1",
18+
"nodeCount": 5,
19+
"edgeCount": 7
20+
},
21+
"graph": {
22+
"name": "Security Scan Discord Report",
23+
"description": "Authorized container image scan with Discord notification for triage teams.",
24+
"nodes": [
25+
{
26+
"id": "trigger_1",
27+
"type": "core.workflow.entrypoint",
28+
"position": { "x": 100, "y": 260 },
29+
"data": {
30+
"label": "Scan Input",
31+
"config": {
32+
"params": {
33+
"runtimeInputs": [
34+
{
35+
"id": "imageRef",
36+
"label": "Container image reference",
37+
"type": "text",
38+
"required": true,
39+
"description": "Public or locally available container image reference, for example nginx:1.25 or node:18-alpine."
40+
}
41+
]
42+
},
43+
"inputOverrides": {}
44+
}
45+
}
46+
},
47+
{
48+
"id": "trivy_image_scan",
49+
"type": "sentris.trivy.run",
50+
"position": { "x": 420, "y": 260 },
51+
"data": {
52+
"label": "Scan Image CVEs",
53+
"config": {
54+
"params": {
55+
"scanType": "image",
56+
"severity": ["CRITICAL", "HIGH", "MEDIUM"],
57+
"format": "json"
58+
},
59+
"inputOverrides": {}
60+
}
61+
}
62+
},
63+
{
64+
"id": "assemble_report",
65+
"type": "core.logic.script",
66+
"position": { "x": 760, "y": 260 },
67+
"data": {
68+
"label": "Build Scan Report",
69+
"config": {
70+
"params": {
71+
"variables": [
72+
{ "name": "imageRef", "type": "string" },
73+
{ "name": "vulnerabilities", "type": "list-json" },
74+
{ "name": "vulnerabilityCount", "type": "number" }
75+
],
76+
"returns": [
77+
{ "name": "summaryText", "type": "string" },
78+
{ "name": "report", "type": "json" },
79+
{ "name": "attachmentContent", "type": "string" }
80+
],
81+
"code": "export function script(input) {\n const vulnerabilities = Array.isArray(input.vulnerabilities) ? input.vulnerabilities : [];\n const severityRank = { critical: 4, high: 3, medium: 2, low: 1, info: 0, unknown: 0 };\n const normalizeSeverity = (value) => String(value || 'unknown').toLowerCase();\n const rankFor = (value) => severityRank[normalizeSeverity(value)] ?? 0;\n const priorityFindings = vulnerabilities\n .map((finding) => ({\n vulnerabilityId: finding?.vulnerabilityId,\n pkgName: finding?.pkgName,\n severity: normalizeSeverity(finding?.severity),\n title: finding?.title,\n fixedVersion: finding?.fixedVersion || null\n }))\n .filter((finding) => finding.vulnerabilityId && rankFor(finding.severity) >= 2)\n .sort((a, b) => rankFor(b.severity) - rankFor(a.severity))\n .slice(0, 25);\n const countsBySeverity = priorityFindings.reduce((acc, finding) => {\n acc[finding.severity] = (acc[finding.severity] || 0) + 1;\n return acc;\n }, {});\n const highestSeverity = priorityFindings[0]?.severity || 'none';\n const imageRef = input.imageRef || 'unknown image';\n const summaryText = `${imageRef}: ${priorityFindings.length} actionable CVEs (highest: ${highestSeverity})`;\n const report = {\n summary: {\n imageRef,\n vulnerabilityCount: Number(input.vulnerabilityCount || vulnerabilities.length),\n actionableFindings: priorityFindings.length,\n highestSeverity,\n countsBySeverity\n },\n priorityFindings\n };\n return {\n summaryText,\n report,\n attachmentContent: JSON.stringify(report, null, 2)\n };\n}"
82+
},
83+
"inputOverrides": {}
84+
}
85+
}
86+
},
87+
{
88+
"id": "discord_notify",
89+
"type": "core.notification.discord",
90+
"position": { "x": 1100, "y": 200 },
91+
"data": {
92+
"label": "Post to Discord",
93+
"config": {
94+
"params": {
95+
"attachmentFileName": "scan-report.json",
96+
"attachmentContentFormat": "text",
97+
"attachmentMimeType": "application/json",
98+
"variables": [{ "name": "summaryText", "type": "string" }]
99+
},
100+
"inputOverrides": {
101+
"webhookUrl": "{{SECRET_PLACEHOLDER}}",
102+
"content": "**Container scan complete** — {{summaryText}}",
103+
"embeds": "[{\"title\":\"CVE Scan Summary\",\"description\":\"{{summaryText}}\",\"color\":5814783}]"
104+
}
105+
}
106+
}
107+
},
108+
{
109+
"id": "artifact_report",
110+
"type": "core.artifact.writer",
111+
"position": { "x": 1100, "y": 340 },
112+
"data": {
113+
"label": "Save Scan Report",
114+
"config": {
115+
"params": {
116+
"fileExtension": ".json",
117+
"mimeType": "application/json",
118+
"saveToRunArtifacts": true,
119+
"publishToArtifactLibrary": false
120+
},
121+
"inputOverrides": {
122+
"artifactName": "security-scan-discord-report-{{date}}"
123+
}
124+
}
125+
}
126+
}
127+
],
128+
"edges": [
129+
{
130+
"id": "trigger_1-trivy_image_scan-imageRef",
131+
"source": "trigger_1",
132+
"target": "trivy_image_scan",
133+
"sourceHandle": "imageRef",
134+
"targetHandle": "target"
135+
},
136+
{
137+
"id": "trigger_1-assemble_report-imageRef",
138+
"source": "trigger_1",
139+
"target": "assemble_report",
140+
"sourceHandle": "imageRef",
141+
"targetHandle": "imageRef"
142+
},
143+
{
144+
"id": "trivy_image_scan-assemble_report-vulnerabilities",
145+
"source": "trivy_image_scan",
146+
"target": "assemble_report",
147+
"sourceHandle": "vulnerabilities",
148+
"targetHandle": "vulnerabilities"
149+
},
150+
{
151+
"id": "trivy_image_scan-assemble_report-vulnerabilityCount",
152+
"source": "trivy_image_scan",
153+
"target": "assemble_report",
154+
"sourceHandle": "vulnerabilityCount",
155+
"targetHandle": "vulnerabilityCount"
156+
},
157+
{
158+
"id": "assemble_report-discord_notify-summaryText",
159+
"source": "assemble_report",
160+
"target": "discord_notify",
161+
"sourceHandle": "summaryText",
162+
"targetHandle": "summaryText"
163+
},
164+
{
165+
"id": "assemble_report-discord_notify-attachmentContent",
166+
"source": "assemble_report",
167+
"target": "discord_notify",
168+
"sourceHandle": "attachmentContent",
169+
"targetHandle": "attachmentContent"
170+
},
171+
{
172+
"id": "assemble_report-artifact_report-report",
173+
"source": "assemble_report",
174+
"target": "artifact_report",
175+
"sourceHandle": "report",
176+
"targetHandle": "content"
177+
}
178+
]
179+
},
180+
"requiredSecrets": [
181+
{
182+
"name": "DISCORD_WEBHOOK_URL",
183+
"type": "string",
184+
"description": "Discord Incoming Webhook URL for scan notifications"
185+
}
186+
]
187+
}

backend/src/database/schema/notification-channels.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const notificationChannelsTable = pgTable(
1616
id: uuid('id').primaryKey().defaultRandom(),
1717
organizationId: varchar('organization_id', { length: 191 }).notNull(),
1818
name: text('name').notNull(),
19-
type: text('type').notNull().$type<'slack' | 'email' | 'pagerduty'>(),
19+
type: text('type').notNull().$type<'slack' | 'discord' | 'email' | 'pagerduty'>(),
2020
config: jsonb('config').notNull().$type<Record<string, unknown>>(),
2121
status: text('status').notNull().default('active').$type<'active' | 'inactive'>(),
2222
events: jsonb('events').notNull().$type<string[]>(),
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { beforeEach, describe, expect, it, mock, afterEach } from 'bun:test';
2+
import type { NotificationChannelRecord } from '../../database/schema';
3+
import type { RunLifecycleEvent } from '@sentris/shared';
4+
5+
let mockResolve4Result: string[] = ['104.16.132.229'];
6+
let mockResolve6Result: string[] | null = null;
7+
let mockResolve4Error: Error | null = null;
8+
9+
mock.module('node:dns', () => ({
10+
promises: {
11+
resolve4: async (_hostname: string) => {
12+
if (mockResolve4Error) throw mockResolve4Error;
13+
return mockResolve4Result;
14+
},
15+
resolve6: async (_hostname: string) => {
16+
if (mockResolve6Result === null) throw new Error('no AAAA record');
17+
return mockResolve6Result;
18+
},
19+
},
20+
}));
21+
22+
import { DiscordNotificationAdapter } from '../adapters/discord.adapter';
23+
24+
function makeChannel(
25+
overrides: Partial<NotificationChannelRecord> = {},
26+
): NotificationChannelRecord {
27+
const now = new Date();
28+
return {
29+
id: overrides.id ?? 'ch-discord-1',
30+
organizationId: overrides.organizationId ?? 'org-1',
31+
name: overrides.name ?? 'Discord Alerts',
32+
type: overrides.type ?? 'discord',
33+
config: overrides.config ?? {
34+
webhookUrl: 'https://discord.com/api/webhooks/1234567890/abcdefghijklmnop',
35+
},
36+
status: overrides.status ?? 'active',
37+
events: overrides.events ?? ['run.completed'],
38+
createdBy: overrides.createdBy ?? 'user-1',
39+
createdAt: overrides.createdAt ?? now,
40+
updatedAt: overrides.updatedAt ?? now,
41+
};
42+
}
43+
44+
const testPayload: RunLifecycleEvent = {
45+
runId: 'run-abc',
46+
workflowId: 'wf-1',
47+
organizationId: 'org-1',
48+
status: 'COMPLETED',
49+
completedAt: '2026-01-15T12:00:00Z',
50+
};
51+
52+
describe('DiscordNotificationAdapter', () => {
53+
let adapter: DiscordNotificationAdapter;
54+
let originalFetch: typeof globalThis.fetch;
55+
56+
beforeEach(() => {
57+
adapter = new DiscordNotificationAdapter();
58+
originalFetch = globalThis.fetch;
59+
mockResolve4Result = ['104.16.132.229'];
60+
mockResolve6Result = null;
61+
mockResolve4Error = null;
62+
delete process.env.SENTRIS_FRONTEND_BASE_URL;
63+
});
64+
65+
afterEach(() => {
66+
globalThis.fetch = originalFetch;
67+
});
68+
69+
it('POSTs embed payload to Discord webhook URL', async () => {
70+
let capturedUrl = '';
71+
let capturedBody = '';
72+
73+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
74+
capturedUrl = String(input);
75+
capturedBody = init?.body as string;
76+
return new Response('', { status: 204 });
77+
}) as unknown as typeof fetch;
78+
79+
const result = await adapter.send(makeChannel(), testPayload);
80+
81+
expect(result.success).toBe(true);
82+
expect(capturedUrl).toBe('https://discord.com/api/webhooks/1234567890/abcdefghijklmnop');
83+
const body = JSON.parse(capturedBody);
84+
expect(body.embeds).toBeDefined();
85+
expect(body.embeds[0].title).toContain('COMPLETED');
86+
});
87+
88+
it('includes run link when SENTRIS_FRONTEND_BASE_URL is set', async () => {
89+
process.env.SENTRIS_FRONTEND_BASE_URL = 'http://localhost:5173';
90+
let capturedBody = '';
91+
92+
globalThis.fetch = (async (_: string | URL | Request, init?: RequestInit) => {
93+
capturedBody = init?.body as string;
94+
return new Response('', { status: 204 });
95+
}) as unknown as typeof fetch;
96+
97+
await adapter.send(makeChannel(), testPayload);
98+
99+
const body = JSON.parse(capturedBody);
100+
const runField = body.embeds[0].fields.find(
101+
(field: { name: string }) => field.name === 'Open in Sentris',
102+
);
103+
expect(runField.value).toBe('http://localhost:5173/workflows/wf-1/runs/run-abc');
104+
});
105+
106+
it('rejects invalid webhook URL format', async () => {
107+
const channel = makeChannel({
108+
config: { webhookUrl: 'https://example.com/hook' },
109+
});
110+
111+
const result = await adapter.send(channel, testPayload);
112+
expect(result.success).toBe(false);
113+
expect(result.error).toContain('Invalid Discord webhook URL');
114+
});
115+
});

backend/src/notifications/__tests__/notification-dispatcher-response.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { NotificationChannelRecord, NotificationDeliveryRecord } from '../.
44
import type { NotificationChannelRepository } from '../repository/notification-channel.repository';
55
import type { NotificationDeliveryRepository } from '../repository/notification-delivery.repository';
66
import type { SlackNotificationAdapter } from '../adapters/slack.adapter';
7+
import type { DiscordNotificationAdapter } from '../adapters/discord.adapter';
78
import { NotificationDispatcherService } from '../notification-dispatcher.service';
89

910
// ---------------------------------------------------------------------------
@@ -83,7 +84,17 @@ function createMocks() {
8384
),
8485
} as unknown as SlackNotificationAdapter;
8586

86-
return { channelRepo, deliveryRepo, slackAdapter, deliveryUpdates };
87+
const discordAdapter = {
88+
send: mock(() =>
89+
Promise.resolve({
90+
success: true,
91+
responseStatus: 204,
92+
responseBody: '',
93+
}),
94+
),
95+
} as unknown as DiscordNotificationAdapter;
96+
97+
return { channelRepo, deliveryRepo, slackAdapter, discordAdapter, deliveryUpdates };
8798
}
8899

89100
// ---------------------------------------------------------------------------
@@ -112,6 +123,7 @@ describe('NotificationDispatcherService — response capture', () => {
112123
mocks.channelRepo,
113124
mocks.deliveryRepo,
114125
mocks.slackAdapter,
126+
mocks.discordAdapter,
115127
);
116128
});
117129

0 commit comments

Comments
 (0)