mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
|
||||
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -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.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
|
||||
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
|
||||
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
|
||||
/// (see <see cref="WebhookSignatureCalculator"/>).
|
||||
/// </summary>
|
||||
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
IReadOnlyDictionary<string, string>? 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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
|
||||
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
|
||||
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
IReadOnlyDictionary<string, string>? 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
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
|
||||
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class WebhookSignatureCalculator
|
||||
{
|
||||
/// <summary>Header carrying the unix-seconds timestamp the signature was computed with.</summary>
|
||||
public const string TimestampHeader = "X-AutopilotMonitor-Timestamp";
|
||||
|
||||
/// <summary>Header carrying the signature: "sha256=<lowercase hex HMAC>".</summary>
|
||||
public const string SignatureHeader = "X-AutopilotMonitor-Signature";
|
||||
|
||||
/// <summary>
|
||||
/// Computes "sha256=<hex>" over "{timestamp}.{body}" with the given secret.
|
||||
/// Secret and message are UTF-8; hex is lowercase.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
|
||||
/// <summary>
|
||||
/// Provider type (int form of <see cref="WebhookProviderType"/>):
|
||||
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson.
|
||||
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord.
|
||||
/// </summary>
|
||||
public int ProviderType { get; set; }
|
||||
|
||||
@@ -56,6 +56,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// </summary>
|
||||
public string? CustomHeadersJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied
|
||||
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
|
||||
/// </summary>
|
||||
public string? SigningSecret { get; set; }
|
||||
|
||||
/// <summary>Master switch: a disabled channel never receives anything.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
@@ -93,6 +99,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
return WebhookHeaderParser.Parse(CustomHeadersJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when
|
||||
/// the channel is not GenericJson or no secret is configured.
|
||||
/// </summary>
|
||||
public string? GetSigningSecret()
|
||||
{
|
||||
if (ProviderType != (int)WebhookProviderType.GenericJson)
|
||||
return null;
|
||||
|
||||
return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -23,5 +23,11 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// Supports per-tenant custom request headers for API-key authentication.
|
||||
/// </summary>
|
||||
GenericJson = 20,
|
||||
|
||||
/// <summary>
|
||||
/// Discord channel webhook (embed format). Webhooks cannot post buttons, so
|
||||
/// openUrl actions are rendered as markdown links inside the embed description.
|
||||
/// </summary>
|
||||
Discord = 30,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
2: 'Teams (Workflow Webhook)',
|
||||
10: 'Slack',
|
||||
20: 'Generic JSON',
|
||||
30: 'Discord',
|
||||
};
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
|
||||
@@ -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."}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -259,6 +264,41 @@ function ChannelEditor({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Signing secret (generic provider only) */}
|
||||
{channel.providerType === GENERIC_PROVIDER && (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium text-sm">Signing Secret (optional)</span>
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
When set, every request carries <code className="font-mono">X-AutopilotMonitor-Signature</code> (HMAC-SHA256)
|
||||
so your endpoint can verify it came from Autopilot Monitor — see the docs for the verification scheme.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={channel.signingSecret ?? ""}
|
||||
onChange={(e) => onChange({ ...channel, signingSecret: e.target.value || undefined })}
|
||||
placeholder="16-128 characters"
|
||||
autoComplete="off"
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
onChange({ ...channel, signingSecret: secret });
|
||||
}}
|
||||
className="mt-1 flex-shrink-0 px-3 py-2 text-sm font-medium text-sky-600 hover:text-sky-700 border border-sky-200 hover:border-sky-300 rounded-lg transition-colors"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event subscriptions */}
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium text-sm">Send to this channel</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -419,7 +419,8 @@ export const SHARED_MANIFEST = {
|
||||
"TeamsLegacyConnector": 1,
|
||||
"TeamsWorkflowWebhook": 2,
|
||||
"Slack": 10,
|
||||
"GenericJson": 20
|
||||
"GenericJson": 20,
|
||||
"Discord": 30
|
||||
},
|
||||
"annotationLanes": [
|
||||
"operator",
|
||||
|
||||
@@ -415,7 +415,8 @@
|
||||
"TeamsLegacyConnector": 1,
|
||||
"TeamsWorkflowWebhook": 2,
|
||||
"Slack": 10,
|
||||
"GenericJson": 20
|
||||
"GenericJson": 20,
|
||||
"Discord": 30
|
||||
},
|
||||
"annotationLanes": [
|
||||
"operator",
|
||||
|
||||
Reference in New Issue
Block a user