From 6435d5f290ca83a318484f4f7a6cb25d427e366b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 22 Aug 2026 21:09:12 +0200 Subject: [PATCH] feat(notifications): Discord webhook provider + HMAC signing for generic webhooks Discord (provider 30) renders alerts as a single embed with Discord's field/length caps; actions become markdown links since webhooks cannot post buttons. Generic JSON channels gain an optional per-channel signing secret: requests carry X-AutopilotMonitor-Timestamp and X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"), computed over the exact posted JSON with same-named custom headers stripped first. Secret is redacted for readers and restored by channel id on save; validation enforces 16-128 printable ASCII characters. Co-Authored-By: Claude Fable 5 --- .../DiscordRendererTests.cs | 124 ++++++++++++++++ .../NotificationChannelsTests.cs | 55 ++++++- .../WebhookSignatureTests.cs | 39 +++++ .../Config/TestWebhookNotificationFunction.cs | 2 +- .../Helpers/TenantConfigValidation.cs | 29 ++++ .../Services/Notifications/DiscordRenderer.cs | 140 ++++++++++++++++++ .../WebhookNotificationService.cs | 32 +++- .../WebhookSignatureCalculator.cs | 31 ++++ .../Models/Config/TenantConfiguration.cs | 2 + .../Notifications/NotificationChannel.cs | 20 ++- .../Notifications/WebhookProviderType.cs | 6 + .../sections/SectionTenantConfigReport.tsx | 1 + .../components/NotificationsSection.tsx | 44 +++++- .../app/settings/types.ts | 4 +- .../utils/shared-manifests.generated.ts | 3 +- .../utils/shared-manifests.json | 3 +- 16 files changed, 519 insertions(+), 16 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DiscordRendererTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/WebhookSignatureTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookSignatureCalculator.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DiscordRendererTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DiscordRendererTests.cs new file mode 100644 index 00000000..4b6844e3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DiscordRendererTests.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits +/// (25 fields, 256/1024 name/value, 4096 description, 6000 total). +/// +public class DiscordRendererTests +{ + private static JsonElement RenderEmbed(NotificationAlert alert) + { + var json = new DiscordRenderer().RenderToJson(alert); + var doc = JsonDocument.Parse(json); + var embeds = doc.RootElement.GetProperty("embeds"); + Assert.Equal(1, embeds.GetArrayLength()); + return embeds[0]; + } + + [Fact] + public void RendersSingleEmbed_WithTitleColorFieldsAndLinks() + { + var alert = new NotificationAlert + { + Title = "Enrollment Succeeded", + Summary = "Enrollment Succeeded: DESK-001", + Severity = NotificationSeverity.Success, + ThemeColor = "00B050", + Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } }, + Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } }, + Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } }, + }; + + var embed = RenderEmbed(alert); + + Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString()); + Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32()); + + var description = embed.GetProperty("description").GetString()!; + Assert.StartsWith("Enrollment Succeeded: DESK-001", description); + Assert.Contains("**Details**\nAll apps installed.", description); + // Webhooks cannot post buttons — actions become markdown links. + Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description); + + var fields = embed.GetProperty("fields"); + Assert.Equal(1, fields.GetArrayLength()); + Assert.Equal("Device", fields[0].GetProperty("name").GetString()); + Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString()); + Assert.True(fields[0].GetProperty("inline").GetBoolean()); + } + + [Theory] + [InlineData(NotificationSeverity.Success, 0x2ECC71)] + [InlineData(NotificationSeverity.Error, 0xE74C3C)] + [InlineData(NotificationSeverity.Warning, 0xF1C40F)] + [InlineData(NotificationSeverity.Info, 0x3498DB)] + public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected) + { + foreach (var themeColor in new[] { "", "not-hex" }) + { + var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor }); + Assert.Equal(expected, embed.GetProperty("color").GetInt32()); + } + } + + [Fact] + public void Fields_CappedAt25_AndEmptyFactsSkipped() + { + var alert = new NotificationAlert { Title = "T", Summary = "S" }; + alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" }); + alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " }); + for (var i = 0; i < 30; i++) + alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" }); + + var fields = RenderEmbed(alert).GetProperty("fields"); + + Assert.Equal(25, fields.GetArrayLength()); + Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted + } + + [Fact] + public void FieldValue_TruncatedTo1024_WithEllipsis() + { + var alert = new NotificationAlert { Title = "T" }; + alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) }); + + var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!; + + Assert.Equal(1024, value.Length); + Assert.EndsWith("…", value); + } + + [Fact] + public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields() + { + var alert = new NotificationAlert { Title = "T", Summary = "S" }; + for (var i = 0; i < 25; i++) + alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) }); + + var embed = RenderEmbed(alert); + var fields = embed.GetProperty("fields"); + + var total = embed.GetProperty("title").GetString()!.Length + + embed.GetProperty("description").GetString()!.Length; + for (var i = 0; i < fields.GetArrayLength(); i++) + total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length; + + Assert.True(fields.GetArrayLength() < 25); + Assert.True(total <= 6000); + Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept + } + + [Fact] + public void EmptyDescription_Omitted_AndProviderTypeIsDiscord() + { + var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info }); + + Assert.False(embed.TryGetProperty("description", out _)); + Assert.False(embed.TryGetProperty("fields", out _)); + Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs index 093146d9..91f38491 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationChannelsTests.cs @@ -1,4 +1,5 @@ using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; @@ -64,6 +65,17 @@ public class NotificationChannelsTests Assert.Empty(slack.GetCustomHeaders()); } + [Fact] + public void GetSigningSecret_OnlyForGenericJson() + { + var secret = "0123456789abcdef"; + Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret()); + Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret()); + Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret()); + Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret()); + Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret()); + } + // ── Legacy synthesis (GetNotificationChannels) ──────────────────────── [Fact] @@ -141,7 +153,7 @@ public class NotificationChannelsTests var cfg = new TenantConfiguration { NotificationChannelsJson = "[" + - ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," + + ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," + ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]", }; @@ -151,8 +163,10 @@ public class NotificationChannelsTests Assert.Equal("ch-1", redacted[0].Id); // structure visible Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson); + Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret); Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url); Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset + Assert.Null(redacted[1].SigningSecret); } [Fact] @@ -169,7 +183,7 @@ public class NotificationChannelsTests { NotificationChannelsJson = "[" + ChannelJson("ch-1", url: "https://real.example/hook", - extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]", + extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]", }; // Round-trip the redacted view back onto a save (defense-in-depth path). @@ -179,6 +193,7 @@ public class NotificationChannelsTests var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson)); Assert.Equal("https://real.example/hook", ch.Url); Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson); + Assert.Equal("real-secret-0123", ch.SigningSecret); } [Fact] @@ -244,4 +259,40 @@ public class NotificationChannelsTests Assert.NotNull(error); Assert.Contains("headers", error); } + + // ── Signing-secret validation ───────────────────────────────────────── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("0123456789abcdef")] // 16 chars (minimum) + public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret) + { + Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret)); + // 128 chars (maximum) and a typical generated 64-char hex secret + Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128))); + Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64))); + } + + [Theory] + [InlineData("short-15-chars.")] // below minimum + [InlineData("has a space in the middle!")] // whitespace + [InlineData("line\nbreak-0123456789")] // control char + [InlineData("umlauts-äöü-0123456789")] // non-ASCII + [InlineData("***REDACTED***")] // unrestored sentinel (too short anyway) + public void ValidateWebhookSigningSecret_RejectsInvalid(string secret) + { + Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret)); + Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129))); + } + + [Fact] + public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret() + { + var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]"; + var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json); + Assert.NotNull(error); + Assert.Contains("signing secret", error); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/WebhookSignatureTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/WebhookSignatureTests.cs new file mode 100644 index 00000000..e0b39a43 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/WebhookSignatureTests.cs @@ -0,0 +1,39 @@ +using AutopilotMonitor.Functions.Services.Notifications; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the +/// scheme is a published contract receivers implement against), determinism, and +/// secret/message sensitivity. +/// +public class WebhookSignatureTests +{ + [Fact] + public void ComputeSignature_MatchesKnownVector() + { + // Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}") + var signature = WebhookSignatureCalculator.ComputeSignature( + "whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}"); + + Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature); + } + + [Fact] + public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput() + { + var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"); + + Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}")); + Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}")); + Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}")); + Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }")); + } + + [Fact] + public void HeaderNames_AreTheDocumentedContract() + { + Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader); + Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs index 2740d7e1..0e19c7c7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs @@ -82,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var testAlert = NotificationAlertBuilder.BuildTestAlert(); var result = await _webhookNotificationService.SendNotificationWithResultAsync( - channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders()); + channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret()); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs index 3948466c..a385dce2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs @@ -182,6 +182,35 @@ namespace AutopilotMonitor.Functions.Helpers var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson); if (headerError != null) return $"channel \"{label}\" headers: {headerError}"; + + var secretError = ValidateWebhookSigningSecret(channel.SigningSecret); + if (secretError != null) + return $"channel \"{label}\" signing secret: {secretError}"; + } + + return null; + } + + /// + /// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when + /// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule + /// keeps the secret copy-paste safe and (as a side effect) rejects a stray unrestored + /// redaction sentinel, which is shorter than the minimum. No provider gate here — a secret + /// on a non-generic channel is stored but inert at dispatch (GetSigningSecret), matching + /// how CustomHeadersJson behaves. + /// + internal static string? ValidateWebhookSigningSecret(string? secret) + { + if (string.IsNullOrWhiteSpace(secret)) + return null; + + if (secret.Length < 16 || secret.Length > 128) + return "must be 16-128 characters."; + + foreach (var ch in secret) + { + if (ch < 0x21 || ch > 0x7E) + return "must be printable ASCII without spaces or line breaks."; } return null; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs new file mode 100644 index 00000000..5d77a7f8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using AutopilotMonitor.Shared.Models.Notifications; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services.Notifications +{ + /// + /// Renders a NotificationAlert as a Discord webhook payload (single embed). + /// Discord webhooks cannot post buttons, so openUrl actions are appended to the + /// embed description as markdown links. + /// + public class DiscordRenderer : INotificationRenderer + { + // Discord embed limits: https://discord.com/developers/docs/resources/message#embed-object-embed-limits + private const int MaxTitleLength = 256; + private const int MaxDescriptionLength = 4096; + private const int MaxFields = 25; + private const int MaxFieldNameLength = 256; + private const int MaxFieldValueLength = 1024; + private const int MaxEmbedTotalLength = 6000; + + public WebhookProviderType ProviderType => WebhookProviderType.Discord; + + public string RenderToJson(NotificationAlert alert) + { + var title = Truncate($"{GetSeverityEmoji(alert.Severity)} {alert.Title}", MaxTitleLength); + var description = Truncate(BuildDescription(alert), MaxDescriptionLength); + + // Facts as inline fields. Discord rejects fields with an empty name or value. + var facts = alert.Facts + .Where(f => !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)) + .Take(MaxFields) + .Select(f => (Name: Truncate(f.Name, MaxFieldNameLength), Value: Truncate(f.Value, MaxFieldValueLength))) + .ToList(); + + // Enforce the 6000-char total cap across title + description + fields: + // drop trailing fields first, then shrink the description to the remaining budget. + var fixedLength = title.Length + description.Length; + while (facts.Count > 0 && fixedLength + facts.Sum(f => f.Name.Length + f.Value.Length) > MaxEmbedTotalLength) + facts.RemoveAt(facts.Count - 1); + if (fixedLength > MaxEmbedTotalLength) + description = Truncate(description, MaxEmbedTotalLength - title.Length); + + var fields = facts + .Select(f => new { name = f.Name, value = f.Value, inline = true }) + .ToArray(); + + var embed = new + { + title, + description = description.Length > 0 ? description : null, + color = GetColor(alert), + fields = fields.Length > 0 ? fields : null + }; + + var payload = new { embeds = new[] { embed } }; + + return JsonConvert.SerializeObject(payload, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver + { + NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy() + } + }); + } + + private static string BuildDescription(NotificationAlert alert) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrEmpty(alert.Summary)) + sb.Append(alert.Summary); + + foreach (var section in alert.Sections) + { + var text = ""; + if (!string.IsNullOrEmpty(section.Title)) + text += $"**{section.Title}**\n"; + if (!string.IsNullOrEmpty(section.Text)) + text += section.Text; + + if (text.Length > 0) + { + if (sb.Length > 0) + sb.Append("\n\n"); + sb.Append(text); + } + } + + foreach (var action in alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))) + { + if (sb.Length > 0) + sb.Append("\n\n"); + sb.Append($"[{action.Title}]({action.Url})"); + } + + return sb.ToString(); + } + + private static int GetColor(NotificationAlert alert) + { + if (!string.IsNullOrEmpty(alert.ThemeColor) + && int.TryParse(alert.ThemeColor.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color)) + return color; + + return alert.Severity switch + { + NotificationSeverity.Success => 0x2ECC71, + NotificationSeverity.Error => 0xE74C3C, + NotificationSeverity.Warning => 0xF1C40F, + _ => 0x3498DB + }; + } + + private static string GetSeverityEmoji(NotificationSeverity severity) + { + return severity switch + { + NotificationSeverity.Success => "\u2705", + NotificationSeverity.Error => "\u274c", + NotificationSeverity.Warning => "\u26a0\ufe0f", + NotificationSeverity.Info => "\u2139\ufe0f", + _ => "\u2139\ufe0f" + }; + } + + private static string Truncate(string value, int maxLength) + { + if (maxLength <= 0) + return ""; + if (value.Length <= maxLength) + return value; + return value.Substring(0, maxLength - 1) + "\u2026"; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs index d0589c6b..76824e00 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs @@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications [WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(), [WebhookProviderType.Slack] = new SlackRenderer(), [WebhookProviderType.GenericJson] = new GenericJsonRenderer(), + [WebhookProviderType.Discord] = new DiscordRenderer(), }; } @@ -36,9 +37,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings. /// (generic webhooks only) are attached to the request, /// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway. + /// (generic webhooks only) adds HMAC signature headers + /// (see ). /// public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, - IReadOnlyDictionary? customHeaders = null) + IReadOnlyDictionary? customHeaders = null, string? signingSecret = null) { if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None) return; @@ -54,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications await SsrfGuard.ValidateDestinationAsync(webhookUrl); var json = renderer.RenderToJson(alert); - var response = await PostAsync(webhookUrl, json, customHeaders); + var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret); if (response.IsSuccessStatusCode) _logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType); @@ -88,16 +91,18 @@ namespace AutopilotMonitor.Functions.Services.Notifications channel.Url, (WebhookProviderType)channel.ProviderType, alert, - channel.GetCustomHeaders()); + channel.GetCustomHeaders(), + channel.GetSigningSecret()); } } /// /// Sends a notification and returns the result (for test endpoint). Not fire-and-forget. - /// (generic webhooks only) are attached to the request. + /// and (generic + /// webhooks only) are applied exactly like in . /// public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert, - IReadOnlyDictionary? customHeaders = null) + IReadOnlyDictionary? customHeaders = null, string? signingSecret = null) { if (string.IsNullOrEmpty(webhookUrl)) return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." }; @@ -120,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications try { var json = renderer.RenderToJson(alert); - var response = await PostAsync(webhookUrl, json, customHeaders); + var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret); var statusCode = (int)response.StatusCode; if (response.IsSuccessStatusCode) @@ -145,8 +150,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// /// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted /// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders(). + /// When a signing secret is present, HMAC signature headers are computed over the exact + /// JSON string being posted — after custom headers, so they can never be spoofed by one. /// - private Task PostAsync(string webhookUrl, string json, IReadOnlyDictionary? customHeaders) + private Task PostAsync(string webhookUrl, string json, IReadOnlyDictionary? customHeaders, + string? signingSecret = null) { var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl) { @@ -159,6 +167,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications request.Headers.TryAddWithoutValidation(header.Key, header.Value); } + if (!string.IsNullOrEmpty(signingSecret)) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture); + request.Headers.Remove(WebhookSignatureCalculator.TimestampHeader); + request.Headers.Remove(WebhookSignatureCalculator.SignatureHeader); + request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.TimestampHeader, timestamp); + request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.SignatureHeader, + WebhookSignatureCalculator.ComputeSignature(signingSecret, timestamp, json)); + } + return _http.SendAsync(request); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookSignatureCalculator.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookSignatureCalculator.cs new file mode 100644 index 00000000..1b0bf7d8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookSignatureCalculator.cs @@ -0,0 +1,31 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace AutopilotMonitor.Functions.Services.Notifications +{ + /// + /// Computes the HMAC request signature for generic JSON webhooks (Stripe-style scheme). + /// The receiver recomputes HMACSHA256(secret, "{timestamp}.{rawBody}") and compares it + /// (constant-time) against the signature header; the timestamp binds replay protection. + /// + public static class WebhookSignatureCalculator + { + /// Header carrying the unix-seconds timestamp the signature was computed with. + public const string TimestampHeader = "X-AutopilotMonitor-Timestamp"; + + /// Header carrying the signature: "sha256=<lowercase hex HMAC>". + public const string SignatureHeader = "X-AutopilotMonitor-Signature"; + + /// + /// Computes "sha256=<hex>" over "{timestamp}.{body}" with the given secret. + /// Secret and message are UTF-8; hex is lowercase. + /// + public static string ComputeSignature(string secret, string timestamp, string body) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}")); + return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant(); + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index b2eab9c5..f9fdb6e3 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -820,6 +820,7 @@ namespace AutopilotMonitor.Shared.Models { channel.Url = RedactNullable(channel.Url); channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson); + channel.SigningSecret = RedactNullable(channel.SigningSecret); } return Notifications.NotificationChannel.SerializeList(channels); @@ -878,6 +879,7 @@ namespace AutopilotMonitor.Shared.Models continue; if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url; if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson; + if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret; } NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs index 9d526d81..f55d7e1c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs @@ -43,7 +43,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// /// Provider type (int form of ): - /// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson. + /// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord. /// public int ProviderType { get; set; } @@ -56,6 +56,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// public string? CustomHeadersJson { get; set; } + /// + /// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied + /// only when is GenericJson. Secret — redacted for readers. + /// + public string? SigningSecret { get; set; } + /// Master switch: a disabled channel never receives anything. public bool Enabled { get; set; } = true; @@ -93,6 +99,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications return WebhookHeaderParser.Parse(CustomHeadersJson); } + /// + /// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when + /// the channel is not GenericJson or no secret is configured. + /// + public string? GetSigningSecret() + { + if (ProviderType != (int)WebhookProviderType.GenericJson) + return null; + + return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret; + } + /// /// Deserializes a channel list from its JSON storage form. Fail-soft: null/blank/malformed /// input yields an empty list (dispatch then simply has no channels to send to). Entries diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs index 75dd78d8..4dcc52e3 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs @@ -23,5 +23,11 @@ namespace AutopilotMonitor.Shared.Models.Notifications /// Supports per-tenant custom request headers for API-key authentication. /// GenericJson = 20, + + /// + /// Discord channel webhook (embed format). Webhooks cannot post buttons, so + /// openUrl actions are rendered as markdown links inside the embed description. + /// + Discord = 30, } } diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 166fc200..882c25ae 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -211,6 +211,7 @@ const WEBHOOK_PROVIDERS: Record = { 2: 'Teams (Workflow Webhook)', 10: 'Slack', 20: 'Generic JSON', + 30: 'Discord', }; function formatValue(val: unknown): string { diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx index ae3269be..1d75ffe5 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx @@ -25,6 +25,7 @@ const PROVIDERS = [ { value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" }, { value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." }, { value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" }, + { value: 30, label: "Discord", placeholder: "https://discord.com/api/webhooks/..." }, ]; const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [ @@ -190,12 +191,14 @@ function ChannelEditor({ value={channel.providerType} onChange={(e) => { const next = Number(e.target.value); - // Custom headers are generic-only; clear them when leaving the generic provider so - // a later switch back can't revive stale auth headers and persist them to a new endpoint. + // Custom headers and the signing secret are generic-only; clear them when leaving the + // generic provider so a later switch back can't revive stale secrets and persist them + // to a new endpoint. onChange({ ...channel, providerType: next, customHeadersJson: next === GENERIC_PROVIDER ? channel.customHeadersJson : undefined, + signingSecret: next === GENERIC_PROVIDER ? channel.signingSecret : undefined, }); }} className="block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors" @@ -232,6 +235,8 @@ function ChannelEditor({ ? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here." : channel.providerType === GENERIC_PROVIDER ? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal." + : channel.providerType === 30 + ? "Create a webhook in your Discord channel (Channel settings → Integrations → Webhooks) and paste the URL here." : "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}

@@ -259,6 +264,41 @@ function ChannelEditor({ /> )} + {/* Signing secret (generic provider only) */} + {channel.providerType === GENERIC_PROVIDER && ( +
+ +
+ )} + {/* Event subscriptions */}
Send to this channel diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index f72744cb..33066c43 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -9,11 +9,13 @@ export interface NotificationChannel { /** Stable id (UUID, generated on creation). Rules reference channels by this id. */ id: string; name: string; - /** 1=Teams Legacy, 2=Teams Workflow, 10=Slack, 20=Generic JSON */ + /** 1=Teams Legacy, 2=Teams Workflow, 10=Slack, 20=Generic JSON, 30=Discord */ providerType: number; url?: string; /** JSON object string { "Header-Name": "value" }; generic provider only. */ customHeadersJson?: string; + /** HMAC key for X-AutopilotMonitor-Signature request signing; generic provider only. */ + signingSecret?: string; enabled: boolean; notifyOnStart?: boolean; notifyOnSuccess?: boolean; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index f64ea50d..3d834093 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -419,7 +419,8 @@ export const SHARED_MANIFEST = { "TeamsLegacyConnector": 1, "TeamsWorkflowWebhook": 2, "Slack": 10, - "GenericJson": 20 + "GenericJson": 20, + "Discord": 30 }, "annotationLanes": [ "operator", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 2356f3f6..90dc0ce5 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -415,7 +415,8 @@ "TeamsLegacyConnector": 1, "TeamsWorkflowWebhook": 2, "Slack": 10, - "GenericJson": 20 + "GenericJson": 20, + "Discord": 30 }, "annotationLanes": [ "operator",