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",