/**
* @module utils/notifications
* @description Failure notification dispatcher (FEA-001).
*
* Dispatches notifications to configured channels when a test run completes
* with failures. Supports three channels:
*
* 1. **Microsoft Teams** — Adaptive Card via incoming webhook.
* 2. **Email** — HTML summary via the existing `emailSender.js` transport.
* 3. **Generic webhook** — POST JSON payload to a user-configured URL.
*
* All dispatches are best-effort: errors are logged but never propagate
* to the caller, so a failing notification never affects the run outcome.
*
* ### Usage
* ```js
* import { fireNotifications } from "../utils/notifications.js";
* await fireNotifications(run, project);
* ```
*/
import crypto from "node:crypto";
import * as notificationSettingsRepo from "../database/repositories/notificationSettingsRepo.js";
import * as workspaceSiemConfigRepo from "../database/repositories/workspaceSiemConfigRepo.js";
import * as auditDlqRepo from "../database/repositories/auditDlqRepo.js";
import { sendEmail, escapeHtml } from "./emailSender.js";
import { formatLogLine } from "./logFormatter.js";
import { safeFetch } from "./ssrfGuard.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Build the base URL for deep links into the Sentri UI.
*
* @returns {string}
*/
function getAppUrl() {
if (process.env.APP_URL) return process.env.APP_URL;
const corsOrigin = process.env.CORS_ORIGIN || "";
return corsOrigin.split(",")[0].trim() || "http://localhost:3000";
}
/**
* Build a deep link URL to a specific run detail page.
*
* @param {string} runId
* @returns {string}
*/
function runDetailUrl(runId) {
const base = getAppUrl().replace(/\/$/, "");
const basePath = (process.env.APP_BASE_PATH || "/").replace(/\/$/, "");
return `${base}${basePath}/runs/${runId}`;
}
/**
* Extract failing test names from run results.
*
* @param {Object} run
* @returns {string[]}
*/
function getFailingTestNames(run) {
if (!Array.isArray(run.results)) return [];
return run.results
.filter(r => r.status === "failed")
.map(r => r.testName || r.testId || "Unknown test")
.slice(0, 10); // cap at 10 to avoid huge payloads
}
/**
* Compute human-readable run duration.
*
* @param {Object} run
* @returns {string}
*/
function formatDuration(run) {
if (!run.duration) return "—";
const secs = Math.round(run.duration / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
}
// ─── Channel dispatchers ──────────────────────────────────────────────────────
/**
* Send a Microsoft Teams Adaptive Card via incoming webhook.
*
* @param {string} webhookUrl - Teams incoming webhook URL.
* @param {Object} run - Completed run object.
* @param {Object} project - Project object.
* @returns {Promise<void>}
*/
async function sendTeamsNotification(webhookUrl, run, project) {
const failingTests = getFailingTestNames(run);
const deepLink = runDetailUrl(run.id);
const card = {
type: "message",
attachments: [{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: `🔴 Test Run Failed — ${project.name}`,
weight: "Bolder",
size: "Medium",
wrap: true,
},
{
type: "FactSet",
facts: [
{ title: "Run", value: run.id },
{ title: "Passed", value: String(run.passed || 0) },
{ title: "Failed", value: String(run.failed || 0) },
{ title: "Total", value: String(run.total || 0) },
{ title: "Duration", value: formatDuration(run) },
],
},
...(failingTests.length > 0 ? [{
type: "TextBlock",
text: `**Failing tests:**\n${failingTests.map(t => `- ${t}`).join("\n")}${failingTests.length >= 10 ? "\n- _(and more…)_" : ""}`,
wrap: true,
size: "Small",
}] : []),
],
actions: [
{
type: "Action.OpenUrl",
title: "View Run Details",
url: deepLink,
},
],
},
}],
};
const res = await safeFetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Teams webhook returned ${res.status}: ${body.slice(0, 200)}`);
}
}
/**
* Send a failure notification email to all configured recipients.
*
* @param {string} recipients - Comma-separated email addresses.
* @param {Object} run - Completed run object.
* @param {Object} project - Project object.
* @returns {Promise<void>}
*/
async function sendEmailNotification(recipients, run, project) {
const failingTests = getFailingTestNames(run);
const deepLink = runDetailUrl(run.id);
const duration = formatDuration(run);
const subject = `[Sentri] ❌ ${run.failed} test${run.failed !== 1 ? "s" : ""} failed — ${project.name}`;
const failList = failingTests.length > 0
? failingTests.map(t => `<li style="color:#dc2626;">${escapeHtml(t)}</li>`).join("")
: "<li>No details available</li>";
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 32px 24px;">
<h2 style="margin: 0 0 16px; font-size: 20px; color: #0f172a;">Test Run Failed — ${escapeHtml(project.name)}</h2>
<table style="width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 14px; color: #475569;">
<tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Run</td><td>${escapeHtml(run.id)}</td></tr>
<tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Passed</td><td style="color: #16a34a;">${run.passed || 0}</td></tr>
<tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Failed</td><td style="color: #dc2626;">${run.failed || 0}</td></tr>
<tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Total</td><td>${run.total || 0}</td></tr>
<tr><td style="padding: 4px 8px 4px 0; font-weight: 600;">Duration</td><td>${escapeHtml(duration)}</td></tr>
</table>
<p style="margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #0f172a;">Failing tests:</p>
<ul style="margin: 0 0 20px; padding-left: 20px; font-size: 13px; line-height: 1.6;">${failList}</ul>
<a href="${escapeHtml(deepLink)}" style="display: inline-block; padding: 10px 24px; background: #6366f1; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px;">
View Run Details
</a>
</div>
`;
const text = [
`Test Run Failed — ${project.name}`,
`Run: ${run.id} | Passed: ${run.passed || 0} | Failed: ${run.failed || 0} | Total: ${run.total || 0} | Duration: ${duration}`,
`Failing tests: ${failingTests.join(", ")}`,
`Details: ${deepLink}`,
].join("\n\n");
const emails = recipients.split(",").map(e => e.trim()).filter(Boolean);
for (const to of emails) {
await sendEmail({ to, subject, html, text });
}
}
/**
* Send a generic webhook notification (POST JSON).
*
* @param {string} url - Webhook URL.
* @param {Object} run - Completed run object.
* @param {Object} project - Project object.
* @returns {Promise<void>}
*/
async function sendWebhookNotification(url, run, project) {
const payload = {
event: "run.failed",
runId: run.id,
projectId: project.id,
projectName: project.name,
status: run.status,
passed: run.passed || 0,
failed: run.failed || 0,
total: run.total || 0,
duration: run.duration || null,
failingTests: getFailingTestNames(run),
detailUrl: runDetailUrl(run.id),
timestamp: new Date().toISOString(),
};
const res = await safeFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Webhook returned ${res.status}: ${body.slice(0, 200)}`);
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Fire all configured notification channels for a completed run.
*
* Only dispatches when:
* 1. The run has failures (`run.failed > 0`).
* 2. The project has notification settings configured and enabled.
*
* All dispatches are best-effort — errors are logged but never thrown.
*
* @param {Object} run - The completed run object.
* @param {Object} project - The project `{ id, name, url }`.
* @returns {Promise<void>}
*/
export async function fireNotifications(run, project) {
// Only notify on failures
if (!run.failed || run.failed <= 0) return;
let settings;
try {
settings = notificationSettingsRepo.getByProjectId(project.id);
} catch (err) {
console.warn(formatLogLine("warn", null,
`[notifications] Failed to read settings for project ${project.id}: ${err.message}`));
return;
}
if (!settings || !settings.enabled) return;
const dispatches = [];
// Microsoft Teams
if (settings.teamsWebhookUrl) {
dispatches.push(
sendTeamsNotification(settings.teamsWebhookUrl, run, project)
.then(() => console.log(formatLogLine("info", null,
`[notifications] Teams notification sent for ${run.id}`)))
.catch(err => console.warn(formatLogLine("warn", null,
`[notifications] Teams notification failed for ${run.id}: ${err.message}`)))
);
}
// Email
if (settings.emailRecipients) {
dispatches.push(
sendEmailNotification(settings.emailRecipients, run, project)
.then(() => console.log(formatLogLine("info", null,
`[notifications] Email notification sent for ${run.id}`)))
.catch(err => console.warn(formatLogLine("warn", null,
`[notifications] Email notification failed for ${run.id}: ${err.message}`)))
);
}
// Generic webhook
if (settings.webhookUrl) {
dispatches.push(
sendWebhookNotification(settings.webhookUrl, run, project)
.then(() => console.log(formatLogLine("info", null,
`[notifications] Webhook notification sent for ${run.id}`)))
.catch(err => console.warn(formatLogLine("warn", null,
`[notifications] Webhook notification failed for ${run.id}: ${err.message}`)))
);
}
await Promise.allSettled(dispatches);
}
// ─── SEC-007 Part C: SIEM audit-log forwarder ─────────────────────────────────
/**
* Sleep for `ms` milliseconds. Used by the SIEM retry loop's exponential
* backoff schedule.
*
* @param {number} ms
* @returns {Promise<void>}
* @private
*/
function _sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Compute the HMAC-SHA256 signature of an NDJSON body.
*
* SIEM operators verify this header on their ingest endpoint to confirm
* the event came from the configured workspace's Sentri instance.
*
* X-Sentri-Audit-Signature: sha256=<hex(hmac_sha256(secret, body))>
*
* @param {string} secret - Per-workspace HMAC secret (plaintext).
* @param {string} body - The NDJSON body string.
* @returns {string} sha256=<hex digest>
* @private
*/
function _hmacSignature(secret, body) {
const hex = crypto.createHmac("sha256", secret).update(body).digest("hex");
return `sha256=${hex}`;
}
/**
* Determine whether an HTTP response status should trigger a retry.
*
* - 5xx server errors → retry (transient)
* - 408 Request Timeout, 429 Too Many Requests → retry (back-pressure)
* - 4xx (other) → DO NOT retry; the SIEM target rejected our payload
* shape or our auth, so retrying with the same bytes won't help.
* The DLQ inspector + admin replay path is the recovery surface for
* config-issue failures.
*
* @param {number} status
* @returns {boolean}
* @private
*/
function _isRetryableStatus(status) {
if (status >= 500) return true;
if (status === 408 || status === 429) return true;
return false;
}
/**
* SEC-007 Part C — forward a single audit row to the workspace's configured
* SIEM target.
*
* Lookup chain:
* 1. Read per-workspace config via `workspaceSiemConfigRepo.getDecrypted`.
* If no row, or row has `enabled = false`, return immediately (no-op).
* 2. POST the row as one NDJSON line with:
* Content-Type: application/x-ndjson
* X-Sentri-Audit-Signature: sha256=<hex(hmac(secret, body))>
* ... + any configured custom headers (e.g. Splunk HEC token).
* 3. Retry on 5xx / 408 / 429 with backoff (immediate, then 1s, then 2s).
* 4. After 3 attempts, enqueue the row in `audit_dlq` so an admin can
* replay it via the AuditLog DLQ inspector.
*
* Fire-and-forget contract: this function NEVER throws to the caller.
* It's invoked from `logActivity` after every audit INSERT, and a SIEM
* outage MUST NOT block the originating request. Failures land in the
* DLQ; persistent outages surface as a non-empty DLQ count in the UI.
*
* @param {string} workspaceId - The workspace whose SIEM config to load.
* @param {Object} row - The activity row that was just persisted.
* @param {Object} [opts]
* @param {boolean} [opts.skipDlqOnFailure=false] - When true, a failed
* dispatch will NOT enqueue a fresh DLQ row. Used by the admin DLQ
* replay path so that retrying an existing DLQ row doesn't create a
* duplicate row each time it fails — the route handler manages the
* original row's `attempts` counter via `auditDlqRepo.incrementAttempts`
* instead. Default `false` preserves the original fire-and-forget
* contract for the `logActivity` dispatch path.
* @returns {Promise<Object>} `{ ok: boolean, attempts?: number, lastError?: string }`
*/
export async function dispatchSiemEvent(workspaceId, row, opts = {}) {
if (!workspaceId || !row) return { ok: false, lastError: "missing workspaceId or row" };
let cfg;
try {
cfg = workspaceSiemConfigRepo.getDecrypted(workspaceId);
} catch (err) {
// Reading the config shouldn't fail under normal conditions, but if
// the DB is locked / decryption errors / etc., treat as not-configured.
// Logging at warn (not error) because this is best-effort and the row
// is already safely persisted in `activities`.
console.warn(formatLogLine("warn", null,
`[siem] Failed to load config for ${workspaceId}: ${err.message}`));
return { ok: false, lastError: err.message };
}
if (!cfg || !cfg.enabled || !cfg.targetUrl) {
// Not configured or disabled — silent no-op (the audit row is still
// safely in the DB; SIEM forwarding is an optional extension).
return { ok: false, lastError: "siem-not-configured" };
}
// NDJSON one-line body. The verifying side feeds the exact bytes
// received into HMAC-SHA256 — any whitespace change here would break
// verification, so we serialise the row exactly once.
const body = JSON.stringify(row) + "\n";
const signature = _hmacSignature(cfg.hmacSecret, body);
// SEC-007: spread custom headers FIRST so the system-controlled integrity
// headers (`Content-Type`, `X-Sentri-Audit-Signature`) can never be
// overridden by an admin's `cfg.headers`. A malicious or careless admin
// setting `headers: { "X-Sentri-Audit-Signature": "sha256=0…0" }` would
// otherwise silently strip HMAC verification at the SIEM target. The PUT
// route validator also rejects reserved header names defensively.
const headers = {
...(cfg.headers || {}),
"Content-Type": "application/x-ndjson",
"X-Sentri-Audit-Signature": signature,
};
// 3 attempts at 0s, 1s, 2s (cumulative 3s). `safeFetch` enforces SSRF
// protection on the target URL (same guard used by notification webhooks)
// so an attacker configuring a malicious SIEM URL (169.254.169.254, etc.)
// can't pivot through Sentri to reach cloud metadata endpoints.
//
// First attempt fires immediately (no backoff); subsequent attempts back
// off exponentially. Aligning the loop and array indices avoids an
// off-by-one that would otherwise add a spurious 1-second delay before
// every initial dispatch.
const backoffMs = [0, 1000, 2000];
let lastError = "unknown";
let attempts = 0;
for (let i = 0; i < 3; i++) {
attempts = i + 1;
if (backoffMs[i] > 0) await _sleep(backoffMs[i]);
try {
const res = await safeFetch(cfg.targetUrl, {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
return { ok: true, attempts };
}
// Non-2xx — decide whether to keep trying or bail to DLQ.
const bodyText = await res.text().catch(() => "");
lastError = `HTTP ${res.status}: ${bodyText.slice(0, 200)}`;
if (!_isRetryableStatus(res.status)) {
// 4xx config issue — don't waste budget retrying. Go straight to DLQ.
break;
}
} catch (err) {
// Network / DNS / TLS / SSRF rejection / timeout. All retryable.
lastError = err.message || String(err);
}
}
// All retries exhausted (or 4xx short-circuit). Enqueue for admin replay
// — unless the caller is itself the admin replay path (`skipDlqOnFailure`),
// in which case re-enqueuing here would create a duplicate of the very
// DLQ row the caller is retrying. The replay route handles bookkeeping
// by calling `auditDlqRepo.incrementAttempts` on the original row.
if (!opts.skipDlqOnFailure) {
try {
auditDlqRepo.enqueue({
workspaceId,
rowSnapshot: row,
lastError,
});
console.warn(formatLogLine("warn", null,
`[siem] Dispatch failed after ${attempts} attempt(s) for ws=${workspaceId} row=${row.id || "?"} — enqueued to DLQ: ${lastError}`));
} catch (dlqErr) {
// DLQ failure is a P1 — the audit row is safely persisted but its
// dispatch trace is now lost. Log loudly so operators can investigate.
console.error(formatLogLine("error", null,
`[siem] DLQ enqueue failed for ws=${workspaceId} row=${row.id || "?"}: ${dlqErr.message} (original dispatch error: ${lastError})`));
}
}
return { ok: false, attempts, lastError };
}