product advisories, stella router improval, tests streghthening

This commit is contained in:
StellaOps Bot
2025-12-24 14:20:26 +02:00
parent 5540ce9430
commit 2c2bbf1005
171 changed files with 58943 additions and 135 deletions

View File

@@ -0,0 +1,714 @@
// ---------------------------------------------------------------------
// <copyright file="EmailConnectorErrorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
// <summary>
// Error handling tests for email connector: SMTP unavailable → retry;
// invalid recipient → fail gracefully.
// </summary>
// ---------------------------------------------------------------------
using System.Net;
using System.Net.Mail;
using FluentAssertions;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests.ErrorHandling;
/// <summary>
/// Error handling tests for email connector.
/// Verifies graceful handling of SMTP failures and invalid recipients.
/// </summary>
[Trait("Category", "ErrorHandling")]
[Trait("Sprint", "5100-0009-0009")]
public sealed class EmailConnectorErrorTests
{
#region SMTP Unavailable Tests
/// <summary>
/// Verifies that SMTP connection failure triggers retry behavior.
/// </summary>
[Fact]
public async Task SmtpUnavailable_TriggersRetry()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpException(SmtpStatusCode.ServiceNotAvailable, "Connection refused"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
{
MaxRetries = 3,
RetryDelayMs = 100
});
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue("SMTP unavailable is a transient failure");
result.RetryAfterMs.Should().BeGreaterThan(0);
smtpClient.SendAttempts.Should().Be(1, "should not retry internally, let caller handle");
}
/// <summary>
/// Verifies that SMTP timeout triggers retry behavior.
/// </summary>
[Fact]
public async Task SmtpTimeout_TriggersRetry()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpException(SmtpStatusCode.GeneralFailure, "Connection timed out"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
{
TimeoutMs = 5000
});
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue("timeout is a transient failure");
result.ErrorCode.Should().Be("SMTP_TIMEOUT");
}
/// <summary>
/// Verifies that SMTP authentication failure does NOT trigger retry.
/// </summary>
[Fact]
public async Task SmtpAuthenticationFailure_DoesNotRetry()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpException(SmtpStatusCode.MustIssueStartTlsFirst, "Authentication required"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse("auth failure is permanent until config is fixed");
result.ErrorCode.Should().Be("SMTP_AUTH_FAILURE");
}
/// <summary>
/// Verifies that mail server busy (4xx) triggers retry with backoff.
/// </summary>
[Fact]
public async Task MailServerBusy_TriggersRetryWithBackoff()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpException(SmtpStatusCode.ServiceClosingTransmissionChannel, "Service temporarily unavailable"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
{
BaseRetryDelayMs = 1000
});
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.RetryAfterMs.Should().BeGreaterOrEqualTo(1000);
}
#endregion
#region Invalid Recipient Tests
/// <summary>
/// Verifies that invalid recipient address fails gracefully without retry.
/// </summary>
[Fact]
public async Task InvalidRecipient_FailsGracefully()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "not-an-email"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification(recipientEmail: "not-an-email");
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse("invalid recipient is a permanent failure");
result.ErrorCode.Should().Be("INVALID_RECIPIENT");
result.ErrorMessage.Should().Contain("not-an-email");
}
/// <summary>
/// Verifies that mailbox not found fails gracefully without retry.
/// </summary>
[Fact]
public async Task MailboxNotFound_FailsGracefully()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "unknown@domain.com"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification(recipientEmail: "unknown@domain.com");
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse("mailbox not found is permanent");
result.ErrorCode.Should().Be("MAILBOX_NOT_FOUND");
}
/// <summary>
/// Verifies that mailbox full triggers retry (could be temporary).
/// </summary>
[Fact]
public async Task MailboxFull_TriggersRetry()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpFailedRecipientException(SmtpStatusCode.ExceededStorageAllocation, "user@domain.com"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification(recipientEmail: "user@domain.com");
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue("mailbox full could be temporary");
result.ErrorCode.Should().Be("MAILBOX_FULL");
}
/// <summary>
/// Verifies that multiple invalid recipients reports all failures.
/// </summary>
[Fact]
public async Task MultipleInvalidRecipients_ReportsAllFailures()
{
// Arrange
var failedRecipients = new[]
{
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "bad1@domain.com"),
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "bad2@domain.com")
};
var smtpClient = new FailingSmtpClient(
new SmtpFailedRecipientsException("Multiple recipients failed", failedRecipients));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse();
result.FailedRecipients.Should().HaveCount(2);
result.FailedRecipients.Should().Contain("bad1@domain.com");
result.FailedRecipients.Should().Contain("bad2@domain.com");
}
#endregion
#region Validation Tests
/// <summary>
/// Verifies that empty recipient list fails validation before sending.
/// </summary>
[Fact]
public async Task EmptyRecipientList_FailsValidation()
{
// Arrange
var smtpClient = new SucceedingSmtpClient();
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = new EmailNotification
{
NotificationId = "notif-001",
Subject = "Test",
Body = "Test body",
Recipients = new List<string>() // Empty
};
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse();
result.ErrorCode.Should().Be("VALIDATION_FAILED");
result.ErrorMessage.Should().Contain("recipient");
smtpClient.SendAttempts.Should().Be(0, "should fail validation before SMTP");
}
/// <summary>
/// Verifies that empty subject fails validation before sending.
/// </summary>
[Fact]
public async Task EmptySubject_FailsValidation()
{
// Arrange
var smtpClient = new SucceedingSmtpClient();
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = new EmailNotification
{
NotificationId = "notif-001",
Subject = "", // Empty
Body = "Test body",
Recipients = new List<string> { "user@example.com" }
};
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse();
result.ErrorCode.Should().Be("VALIDATION_FAILED");
result.ErrorMessage.Should().Contain("subject");
}
/// <summary>
/// Verifies that malformed email address fails validation.
/// </summary>
[Theory]
[InlineData("not-an-email")]
[InlineData("@missing-local.com")]
[InlineData("missing-domain@")]
[InlineData("spaces in email@domain.com")]
[InlineData("<script>alert('xss')</script>@domain.com")]
public async Task MalformedEmailAddress_FailsValidation(string badEmail)
{
// Arrange
var smtpClient = new SucceedingSmtpClient();
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification(recipientEmail: badEmail);
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeFalse();
result.ErrorCode.Should().Be("VALIDATION_FAILED");
smtpClient.SendAttempts.Should().Be(0);
}
#endregion
#region Rate Limiting Tests
/// <summary>
/// Verifies that rate limiting error triggers retry with appropriate delay.
/// </summary>
[Fact]
public async Task RateLimited_TriggersRetryWithDelay()
{
// Arrange
var smtpClient = new FailingSmtpClient(
new SmtpException(SmtpStatusCode.InsufficientStorage, "Rate limit exceeded, retry after 60 seconds"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.ErrorCode.Should().Be("RATE_LIMITED");
result.RetryAfterMs.Should().BeGreaterOrEqualTo(60000, "should respect retry-after from server");
}
#endregion
#region Cancellation Tests
/// <summary>
/// Verifies that cancellation is respected.
/// </summary>
[Fact]
public async Task Cancellation_StopsSend()
{
// Arrange
var smtpClient = new SlowSmtpClient(TimeSpan.FromSeconds(10));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(100));
// Act
var result = await connector.SendAsync(notification, cts.Token);
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue("cancellation should allow retry");
result.ErrorCode.Should().Be("CANCELLED");
}
#endregion
#region Error Result Tests
/// <summary>
/// Verifies that error results include timestamp.
/// </summary>
[Fact]
public async Task ErrorResult_IncludesTimestamp()
{
// Arrange
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
var before = DateTime.UtcNow;
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.Timestamp.Should().BeOnOrAfter(before);
result.Timestamp.Should().BeOnOrBefore(DateTime.UtcNow);
}
/// <summary>
/// Verifies that error results include notification ID.
/// </summary>
[Fact]
public async Task ErrorResult_IncludesNotificationId()
{
// Arrange
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
// Act
var result = await connector.SendAsync(notification, CancellationToken.None);
// Assert
result.NotificationId.Should().Be(notification.NotificationId);
}
/// <summary>
/// Verifies that retry count is tracked.
/// </summary>
[Fact]
public async Task ErrorResult_TracksAttemptCount()
{
// Arrange
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
var notification = CreateTestNotification();
// Act - First attempt
var result1 = await connector.SendAsync(notification, CancellationToken.None, attempt: 1);
var result2 = await connector.SendAsync(notification, CancellationToken.None, attempt: 2);
// Assert
result1.AttemptNumber.Should().Be(1);
result2.AttemptNumber.Should().Be(2);
}
#endregion
#region Helper Methods
private static EmailNotification CreateTestNotification(string? recipientEmail = null)
{
return new EmailNotification
{
NotificationId = $"notif-{Guid.NewGuid():N}",
Subject = "[StellaOps] Test Notification",
Body = "<html><body>Test notification body</body></html>",
Recipients = new List<string> { recipientEmail ?? "user@example.com" },
From = "StellaOps <noreply@stellaops.local>",
Priority = EmailPriority.Normal
};
}
#endregion
}
#region Test Doubles
/// <summary>
/// Fake SMTP client that always fails with configured exception.
/// </summary>
internal sealed class FailingSmtpClient : ISmtpClient
{
private readonly Exception _exception;
public int SendAttempts { get; private set; }
public FailingSmtpClient(Exception exception)
{
_exception = exception;
}
public Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
{
SendAttempts++;
throw _exception;
}
}
/// <summary>
/// Fake SMTP client that always succeeds.
/// </summary>
internal sealed class SucceedingSmtpClient : ISmtpClient
{
public int SendAttempts { get; private set; }
public Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
{
SendAttempts++;
return Task.CompletedTask;
}
}
/// <summary>
/// Fake SMTP client that is slow (for cancellation tests).
/// </summary>
internal sealed class SlowSmtpClient : ISmtpClient
{
private readonly TimeSpan _delay;
public SlowSmtpClient(TimeSpan delay)
{
_delay = delay;
}
public async Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
}
}
/// <summary>
/// SMTP client interface for testing.
/// </summary>
internal interface ISmtpClient
{
Task SendAsync(EmailNotification notification, CancellationToken cancellationToken);
}
/// <summary>
/// Email notification model.
/// </summary>
internal sealed class EmailNotification
{
public required string NotificationId { get; set; }
public required string Subject { get; set; }
public required string Body { get; set; }
public List<string> Recipients { get; set; } = new();
public string? From { get; set; }
public EmailPriority Priority { get; set; } = EmailPriority.Normal;
}
/// <summary>
/// Email priority levels.
/// </summary>
internal enum EmailPriority
{
Low,
Normal,
High
}
/// <summary>
/// Email connector options.
/// </summary>
internal sealed class EmailConnectorOptions
{
public int MaxRetries { get; set; } = 3;
public int RetryDelayMs { get; set; } = 1000;
public int BaseRetryDelayMs { get; set; } = 1000;
public int TimeoutMs { get; set; } = 30000;
}
/// <summary>
/// Email send result.
/// </summary>
internal sealed class EmailSendResult
{
public bool Success { get; set; }
public bool ShouldRetry { get; set; }
public int RetryAfterMs { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
public List<string> FailedRecipients { get; set; } = new();
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string? NotificationId { get; set; }
public int AttemptNumber { get; set; }
}
/// <summary>
/// Email connector for testing.
/// </summary>
internal sealed class EmailConnector
{
private readonly ISmtpClient _smtpClient;
private readonly EmailConnectorOptions _options;
public EmailConnector(ISmtpClient smtpClient, EmailConnectorOptions options)
{
_smtpClient = smtpClient;
_options = options;
}
public async Task<EmailSendResult> SendAsync(
EmailNotification notification,
CancellationToken cancellationToken,
int attempt = 1)
{
var result = new EmailSendResult
{
NotificationId = notification.NotificationId,
AttemptNumber = attempt,
Timestamp = DateTime.UtcNow
};
// Validate first
var validationError = Validate(notification);
if (validationError != null)
{
result.Success = false;
result.ShouldRetry = false;
result.ErrorCode = "VALIDATION_FAILED";
result.ErrorMessage = validationError;
return result;
}
try
{
await _smtpClient.SendAsync(notification, cancellationToken);
result.Success = true;
return result;
}
catch (OperationCanceledException)
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "CANCELLED";
return result;
}
catch (SmtpFailedRecipientsException ex)
{
result.Success = false;
result.ShouldRetry = false;
result.ErrorCode = "MULTIPLE_RECIPIENTS_FAILED";
result.FailedRecipients = ex.InnerExceptions
.OfType<SmtpFailedRecipientException>()
.Select(e => e.FailedRecipient)
.ToList();
return result;
}
catch (SmtpFailedRecipientException ex)
{
result.Success = false;
result.ErrorMessage = ex.FailedRecipient;
// Classify recipient failure
(result.ErrorCode, result.ShouldRetry) = ex.StatusCode switch
{
SmtpStatusCode.MailboxUnavailable when ex.FailedRecipient.Contains('@') == false
=> ("INVALID_RECIPIENT", false),
SmtpStatusCode.MailboxUnavailable => ("MAILBOX_NOT_FOUND", false),
SmtpStatusCode.ExceededStorageAllocation => ("MAILBOX_FULL", true),
_ => ("RECIPIENT_FAILED", false)
};
return result;
}
catch (SmtpException ex)
{
result.Success = false;
result.ErrorMessage = ex.Message;
// Classify SMTP failure
(result.ErrorCode, result.ShouldRetry, result.RetryAfterMs) = ClassifySmtpException(ex);
return result;
}
catch (Exception ex)
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "UNKNOWN_ERROR";
result.ErrorMessage = ex.Message;
result.RetryAfterMs = _options.RetryDelayMs;
return result;
}
}
private static string? Validate(EmailNotification notification)
{
if (notification.Recipients == null || notification.Recipients.Count == 0)
return "At least one recipient is required";
if (string.IsNullOrWhiteSpace(notification.Subject))
return "subject is required";
foreach (var recipient in notification.Recipients)
{
if (!IsValidEmail(recipient))
return $"Invalid email address: {recipient}";
}
return null;
}
private static bool IsValidEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return false;
if (email.Contains(' '))
return false;
if (email.Contains('<') || email.Contains('>'))
return false;
var atIndex = email.IndexOf('@');
if (atIndex <= 0 || atIndex >= email.Length - 1)
return false;
return true;
}
private (string ErrorCode, bool ShouldRetry, int RetryAfterMs) ClassifySmtpException(SmtpException ex)
{
// Check for rate limiting
if (ex.Message.Contains("Rate limit", StringComparison.OrdinalIgnoreCase))
{
// Extract retry-after if present
var retryAfter = 60000; // Default 60 seconds
if (ex.Message.Contains("retry after", StringComparison.OrdinalIgnoreCase))
{
// Parse retry-after from message if present
var match = System.Text.RegularExpressions.Regex.Match(ex.Message, @"(\d+)\s*seconds");
if (match.Success)
retryAfter = int.Parse(match.Groups[1].Value) * 1000;
}
return ("RATE_LIMITED", true, retryAfter);
}
// Classify by status code
return ex.StatusCode switch
{
SmtpStatusCode.ServiceNotAvailable => ("SMTP_UNAVAILABLE", true, _options.RetryDelayMs),
SmtpStatusCode.ServiceClosingTransmissionChannel => ("SMTP_CLOSING", true, _options.BaseRetryDelayMs),
SmtpStatusCode.MustIssueStartTlsFirst => ("SMTP_AUTH_FAILURE", false, 0),
SmtpStatusCode.GeneralFailure when ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)
=> ("SMTP_TIMEOUT", true, _options.RetryDelayMs),
SmtpStatusCode.InsufficientStorage => ("RATE_LIMITED", true, _options.RetryDelayMs),
_ => ("SMTP_ERROR", true, _options.RetryDelayMs)
};
}
}
#endregion

View File

@@ -0,0 +1,56 @@
Subject: [StellaOps] Policy Violation - No Root Containers - acme/legacy-app:v0.9.0
From: StellaOps <noreply@stellaops.local>
To: ACME DevOps Team <devops@acme.example.com>
Reply-To: noreply@stellaops.local
X-Priority: 1
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Policy Violation</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">🚨 Policy Violation Detected</h1>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
<h2 style="margin-top: 0;">Policy: No Root Containers</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/legacy-app:v0.9.0</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:def456ghi789</code></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Violation Type:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">container_runs_as_root</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong>Detected At:</strong></td>
<td style="padding: 8px 0;">2026-01-16T09:15:00Z</td>
</tr>
</table>
<h3>Details</h3>
<p style="background: #fef3c7; padding: 16px; border-radius: 8px; color: #92400e;">
Container is configured to run as root user (UID 0). This violates the organization's security policy requiring non-root containers.
</p>
<h3>Remediation</h3>
<p style="background: #d1fae5; padding: 16px; border-radius: 8px; color: #065f46;">
Update the Dockerfile to use a non-root user: <code>USER 1000:1000</code>
</p>
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
This is an automated message from StellaOps. Do not reply to this email.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,77 @@
Subject: [StellaOps] ⚠️ CRITICAL - Scan Failed - acme/api-server:v2.0.0
From: StellaOps <noreply@stellaops.local>
To: ACME Security Team <security@acme.example.com>
Reply-To: noreply@stellaops.local
X-Priority: 1
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Scan Results - CRITICAL</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #dc2626; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">⚠️ Scan Failed - Critical Vulnerabilities Found</h1>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
<h2 style="margin-top: 0;">Image Details</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/api-server:v2.0.0</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:xyz789abc123</code></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Scan ID:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">scan-xyz789</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong>Scanned At:</strong></td>
<td style="padding: 8px 0;">2026-01-15T14:45:00Z</td>
</tr>
</table>
<h2>Vulnerability Summary</h2>
<table style="width: 100%; border-collapse: collapse; text-align: center;">
<tr style="background: #f3f4f6;">
<th style="padding: 12px;">Critical</th>
<th style="padding: 12px;">High</th>
<th style="padding: 12px;">Medium</th>
<th style="padding: 12px;">Low</th>
</tr>
<tr>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d; background: #fecaca;">2</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412; background: #fed7aa;">3</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;">1</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #166534;">2</td>
</tr>
</table>
<h2 style="color: #dc2626;">Critical Vulnerabilities</h2>
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
<h3 style="margin: 0 0 8px 0; color: #991b1b;">CVE-2026-1234 (CVSS 9.8)</h3>
<p style="margin: 0 0 8px 0;"><strong>Package:</strong> openssl</p>
<p style="margin: 0;">Remote Code Execution in OpenSSL</p>
</div>
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
<h3 style="margin: 0 0 8px 0; color: #991b1b;">CVE-2026-5678 (CVSS 9.1)</h3>
<p style="margin: 0 0 8px 0;"><strong>Package:</strong> libcurl</p>
<p style="margin: 0;">Buffer Overflow in libcurl</p>
</div>
<p style="margin-top: 20px; padding: 16px; background: #fef3c7; border-radius: 8px; color: #92400e;">
<strong>Action Required:</strong> This image should not be deployed to production until the critical vulnerabilities are remediated.
</p>
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
This is an automated message from StellaOps. Do not reply to this email.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,60 @@
Subject: [StellaOps] Scan Completed - PASS - acme/webapp:v1.2.3
From: StellaOps <noreply@stellaops.local>
To: ACME Security Team <security@acme.example.com>
Reply-To: noreply@stellaops.local
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Scan Results</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #10b981; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">✓ Scan Passed</h1>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
<h2 style="margin-top: 0;">Image Details</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/webapp:v1.2.3</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:abc123def456</code></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Scan ID:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">scan-abc123</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong>Scanned At:</strong></td>
<td style="padding: 8px 0;">2026-01-15T10:30:00Z</td>
</tr>
</table>
<h2>Vulnerability Summary</h2>
<table style="width: 100%; border-collapse: collapse; text-align: center;">
<tr style="background: #f3f4f6;">
<th style="padding: 12px;">Critical</th>
<th style="padding: 12px;">High</th>
<th style="padding: 12px;">Medium</th>
<th style="padding: 12px;">Low</th>
</tr>
<tr>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d;">0</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412;">0</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;">2</td>
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #166534;">5</td>
</tr>
</table>
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
This is an automated message from StellaOps. Do not reply to this email.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"notification_id": "notif-003",
"tenant_id": "tenant-acme",
"channel": "email",
"event_type": "policy.violation",
"timestamp": "2026-01-16T09:15:00Z",
"payload": {
"policy_id": "policy-no-root",
"policy_name": "No Root Containers",
"image_digest": "sha256:def456ghi789",
"image_name": "acme/legacy-app:v0.9.0",
"violation_type": "container_runs_as_root",
"details": "Container is configured to run as root user (UID 0). This violates the organization's security policy requiring non-root containers.",
"remediation": "Update the Dockerfile to use a non-root user: USER 1000:1000"
},
"recipient": {
"email": "devops@acme.example.com",
"name": "ACME DevOps Team"
},
"metadata": {
"priority": "high",
"reply_to": "noreply@stellaops.local"
}
}

View File

@@ -0,0 +1,44 @@
{
"notification_id": "notif-002",
"tenant_id": "tenant-acme",
"channel": "email",
"event_type": "scan.completed",
"timestamp": "2026-01-15T14:45:00Z",
"payload": {
"scan_id": "scan-xyz789",
"image_digest": "sha256:xyz789abc123",
"image_name": "acme/api-server:v2.0.0",
"verdict": "fail",
"findings_count": 8,
"vulnerabilities": {
"critical": 2,
"high": 3,
"medium": 1,
"low": 2
},
"critical_findings": [
{
"cve_id": "CVE-2026-1234",
"package": "openssl",
"severity": "critical",
"title": "Remote Code Execution in OpenSSL",
"cvss": 9.8
},
{
"cve_id": "CVE-2026-5678",
"package": "libcurl",
"severity": "critical",
"title": "Buffer Overflow in libcurl",
"cvss": 9.1
}
]
},
"recipient": {
"email": "security@acme.example.com",
"name": "ACME Security Team"
},
"metadata": {
"priority": "high",
"reply_to": "noreply@stellaops.local"
}
}

View File

@@ -0,0 +1,28 @@
{
"notification_id": "notif-001",
"tenant_id": "tenant-acme",
"channel": "email",
"event_type": "scan.completed",
"timestamp": "2026-01-15T10:30:00Z",
"payload": {
"scan_id": "scan-abc123",
"image_digest": "sha256:abc123def456",
"image_name": "acme/webapp:v1.2.3",
"verdict": "pass",
"findings_count": 0,
"vulnerabilities": {
"critical": 0,
"high": 0,
"medium": 2,
"low": 5
}
},
"recipient": {
"email": "security@acme.example.com",
"name": "ACME Security Team"
},
"metadata": {
"priority": "normal",
"reply_to": "noreply@stellaops.local"
}
}

View File

@@ -0,0 +1,696 @@
// ---------------------------------------------------------------------
// <copyright file="EmailConnectorSnapshotTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
// <summary>
// Payload formatting snapshot tests for email connector: event → formatted email → assert snapshot
// </summary>
// ---------------------------------------------------------------------
using System.Reflection;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests.Snapshot;
/// <summary>
/// Snapshot tests for email connector payload formatting.
/// Verifies event → formatted email output matches expected snapshots.
/// </summary>
[Trait("Category", "Snapshot")]
[Trait("Sprint", "5100-0009-0009")]
public sealed class EmailConnectorSnapshotTests
{
private readonly string _fixturesPath;
private readonly string _expectedPath;
private readonly EmailFormatter _formatter;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public EmailConnectorSnapshotTests()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
_fixturesPath = Path.Combine(assemblyDir, "Fixtures", "email");
_expectedPath = Path.Combine(assemblyDir, "Expected");
_formatter = new EmailFormatter();
}
#region Scan Completed Pass Tests
/// <summary>
/// Verifies scan completed (pass) event formats to expected email.
/// </summary>
[Fact]
public async Task ScanCompletedPass_FormatsToExpectedEmail()
{
// Arrange
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
var expected = await LoadExpectedAsync("scan_completed_pass.email.txt");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Subject.Should().Be("[StellaOps] Scan Completed - PASS - acme/webapp:v1.2.3");
formattedEmail.From.Should().Be("StellaOps <noreply@stellaops.local>");
formattedEmail.To.Should().Be("ACME Security Team <security@acme.example.com>");
formattedEmail.Body.Should().Contain("✓ Scan Passed");
formattedEmail.Body.Should().Contain("acme/webapp:v1.2.3");
formattedEmail.Body.Should().Contain("sha256:abc123def456");
// Verify snapshot structure matches
AssertEmailSnapshotMatch(formattedEmail, expected);
}
/// <summary>
/// Verifies scan completed (pass) includes correct vulnerability counts.
/// </summary>
[Fact]
public async Task ScanCompletedPass_IncludesVulnerabilityCounts()
{
// Arrange
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Body.Should().Contain(">0</td>"); // Critical count
formattedEmail.Body.Should().Contain(">2</td>"); // Medium count
formattedEmail.Body.Should().Contain(">5</td>"); // Low count
}
#endregion
#region Scan Completed Fail Tests
/// <summary>
/// Verifies scan completed (fail) event formats to expected email.
/// </summary>
[Fact]
public async Task ScanCompletedFail_FormatsToExpectedEmail()
{
// Arrange
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
var expected = await LoadExpectedAsync("scan_completed_fail.email.txt");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Subject.Should().Contain("CRITICAL");
formattedEmail.Subject.Should().Contain("Scan Failed");
formattedEmail.Subject.Should().Contain("acme/api-server:v2.0.0");
formattedEmail.Priority.Should().Be(EmailPriority.High);
formattedEmail.Body.Should().Contain("Critical Vulnerabilities Found");
AssertEmailSnapshotMatch(formattedEmail, expected);
}
/// <summary>
/// Verifies scan completed (fail) lists critical findings.
/// </summary>
[Fact]
public async Task ScanCompletedFail_ListsCriticalFindings()
{
// Arrange
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Body.Should().Contain("CVE-2026-1234");
formattedEmail.Body.Should().Contain("CVE-2026-5678");
formattedEmail.Body.Should().Contain("openssl");
formattedEmail.Body.Should().Contain("libcurl");
formattedEmail.Body.Should().Contain("CVSS 9.8");
formattedEmail.Body.Should().Contain("CVSS 9.1");
}
/// <summary>
/// Verifies scan completed (fail) includes action required warning.
/// </summary>
[Fact]
public async Task ScanCompletedFail_IncludesActionRequired()
{
// Arrange
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Body.Should().Contain("Action Required");
formattedEmail.Body.Should().Contain("should not be deployed to production");
}
#endregion
#region Policy Violation Tests
/// <summary>
/// Verifies policy violation event formats to expected email.
/// </summary>
[Fact]
public async Task PolicyViolation_FormatsToExpectedEmail()
{
// Arrange
var eventJson = await LoadFixtureAsync("policy_violation.json");
var expected = await LoadExpectedAsync("policy_violation.email.txt");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Subject.Should().Contain("Policy Violation");
formattedEmail.Subject.Should().Contain("No Root Containers");
formattedEmail.Body.Should().Contain("Policy Violation Detected");
formattedEmail.Body.Should().Contain("container_runs_as_root");
AssertEmailSnapshotMatch(formattedEmail, expected);
}
/// <summary>
/// Verifies policy violation includes remediation guidance.
/// </summary>
[Fact]
public async Task PolicyViolation_IncludesRemediation()
{
// Arrange
var eventJson = await LoadFixtureAsync("policy_violation.json");
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Body.Should().Contain("Remediation");
formattedEmail.Body.Should().Contain("USER 1000:1000");
}
#endregion
#region Header Tests
/// <summary>
/// Verifies all emails include required headers.
/// </summary>
[Theory]
[InlineData("scan_completed_pass.json")]
[InlineData("scan_completed_fail.json")]
[InlineData("policy_violation.json")]
public async Task AllEmails_IncludeRequiredHeaders(string fixtureFile)
{
// Arrange
var eventJson = await LoadFixtureAsync(fixtureFile);
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Subject.Should().NotBeNullOrWhiteSpace();
formattedEmail.From.Should().NotBeNullOrWhiteSpace();
formattedEmail.To.Should().NotBeNullOrWhiteSpace();
formattedEmail.ContentType.Should().Be("text/html; charset=utf-8");
formattedEmail.ReplyTo.Should().NotBeNullOrWhiteSpace();
}
/// <summary>
/// Verifies high priority events set email priority header.
/// </summary>
[Theory]
[InlineData("scan_completed_fail.json", EmailPriority.High)]
[InlineData("policy_violation.json", EmailPriority.High)]
[InlineData("scan_completed_pass.json", EmailPriority.Normal)]
public async Task HighPriorityEvents_SetPriorityHeader(string fixtureFile, EmailPriority expectedPriority)
{
// Arrange
var eventJson = await LoadFixtureAsync(fixtureFile);
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Priority.Should().Be(expectedPriority);
}
#endregion
#region HTML Validation Tests
/// <summary>
/// Verifies email body is valid HTML.
/// </summary>
[Theory]
[InlineData("scan_completed_pass.json")]
[InlineData("scan_completed_fail.json")]
[InlineData("policy_violation.json")]
public async Task EmailBody_IsValidHtml(string fixtureFile)
{
// Arrange
var eventJson = await LoadFixtureAsync(fixtureFile);
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var formattedEmail = _formatter.Format(notificationEvent);
// Assert
formattedEmail.Body.Should().Contain("<!DOCTYPE html>");
formattedEmail.Body.Should().Contain("<html>");
formattedEmail.Body.Should().Contain("</html>");
formattedEmail.Body.Should().Contain("<body");
formattedEmail.Body.Should().Contain("</body>");
// Verify no unclosed tags (basic check)
var openTags = formattedEmail.Body.Split('<').Length;
var closeTags = formattedEmail.Body.Split('>').Length;
openTags.Should().Be(closeTags);
}
/// <summary>
/// Verifies email body escapes HTML special characters in user data.
/// </summary>
[Fact]
public void EmailBody_EscapesHtmlSpecialCharacters()
{
// Arrange
var maliciousEvent = new NotificationEvent
{
NotificationId = "notif-xss",
TenantId = "tenant",
Channel = "email",
EventType = "scan.completed",
Timestamp = DateTime.UtcNow,
Payload = new Dictionary<string, object>
{
["image_name"] = "<script>alert('xss')</script>",
["scan_id"] = "scan-123",
["verdict"] = "pass",
["vulnerabilities"] = new Dictionary<string, int>
{
["critical"] = 0,
["high"] = 0,
["medium"] = 0,
["low"] = 0
}
},
Recipient = new NotificationRecipient
{
Email = "test@example.com",
Name = "Test"
}
};
// Act
var formattedEmail = _formatter.Format(maliciousEvent);
// Assert
formattedEmail.Body.Should().NotContain("<script>");
formattedEmail.Body.Should().Contain("&lt;script&gt;");
}
#endregion
#region Determinism Tests
/// <summary>
/// Verifies same input produces identical output (deterministic).
/// </summary>
[Theory]
[InlineData("scan_completed_pass.json")]
[InlineData("scan_completed_fail.json")]
[InlineData("policy_violation.json")]
public async Task SameInput_ProducesIdenticalOutput(string fixtureFile)
{
// Arrange
var eventJson = await LoadFixtureAsync(fixtureFile);
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
// Act
var email1 = _formatter.Format(notificationEvent);
var email2 = _formatter.Format(notificationEvent);
// Assert
email1.Subject.Should().Be(email2.Subject);
email1.From.Should().Be(email2.From);
email1.To.Should().Be(email2.To);
email1.Body.Should().Be(email2.Body);
}
#endregion
#region Helper Methods
private async Task<string> LoadFixtureAsync(string filename)
{
var path = Path.Combine(_fixturesPath, filename);
if (!File.Exists(path))
{
// Fall back to embedded resource or test data directory
var testDataPath = Path.Combine(
Directory.GetCurrentDirectory(),
"Fixtures", "email", filename);
if (File.Exists(testDataPath))
{
path = testDataPath;
}
}
return await File.ReadAllTextAsync(path);
}
private async Task<string> LoadExpectedAsync(string filename)
{
var path = Path.Combine(_expectedPath, filename);
if (!File.Exists(path))
{
var testDataPath = Path.Combine(
Directory.GetCurrentDirectory(),
"Expected", filename);
if (File.Exists(testDataPath))
{
path = testDataPath;
}
}
return await File.ReadAllTextAsync(path);
}
private static void AssertEmailSnapshotMatch(FormattedEmail actual, string expectedSnapshot)
{
// Parse expected snapshot for key elements
var lines = expectedSnapshot.Split('\n');
foreach (var line in lines.Take(10)) // Check headers
{
if (line.StartsWith("Subject:"))
{
var expectedSubject = line["Subject:".Length..].Trim();
actual.Subject.Should().Be(expectedSubject);
}
else if (line.StartsWith("From:"))
{
var expectedFrom = line["From:".Length..].Trim();
actual.From.Should().Be(expectedFrom);
}
else if (line.StartsWith("To:"))
{
var expectedTo = line["To:".Length..].Trim();
actual.To.Should().Be(expectedTo);
}
}
}
#endregion
}
#region Test Models
/// <summary>
/// Notification event model for testing.
/// </summary>
public sealed class NotificationEvent
{
public required string NotificationId { get; set; }
public required string TenantId { get; set; }
public required string Channel { get; set; }
public required string EventType { get; set; }
public DateTime Timestamp { get; set; }
public Dictionary<string, object> Payload { get; set; } = new();
public required NotificationRecipient Recipient { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
}
/// <summary>
/// Notification recipient model for testing.
/// </summary>
public sealed class NotificationRecipient
{
public required string Email { get; set; }
public string? Name { get; set; }
}
/// <summary>
/// Formatted email model.
/// </summary>
public sealed class FormattedEmail
{
public required string Subject { get; set; }
public required string From { get; set; }
public required string To { get; set; }
public string? ReplyTo { get; set; }
public required string Body { get; set; }
public string ContentType { get; set; } = "text/html; charset=utf-8";
public EmailPriority Priority { get; set; } = EmailPriority.Normal;
}
/// <summary>
/// Email priority levels.
/// </summary>
public enum EmailPriority
{
Low,
Normal,
High
}
/// <summary>
/// Email formatter for testing.
/// </summary>
public sealed class EmailFormatter
{
public FormattedEmail Format(NotificationEvent evt)
{
var priority = GetPriority(evt);
var subject = FormatSubject(evt);
var body = FormatBody(evt);
var recipientName = evt.Recipient.Name ?? evt.Recipient.Email;
var to = !string.IsNullOrEmpty(evt.Recipient.Name)
? $"{evt.Recipient.Name} <{evt.Recipient.Email}>"
: evt.Recipient.Email;
var replyTo = evt.Metadata.TryGetValue("reply_to", out var rt) ? rt : "noreply@stellaops.local";
return new FormattedEmail
{
Subject = subject,
From = "StellaOps <noreply@stellaops.local>",
To = to,
ReplyTo = replyTo,
Body = body,
Priority = priority
};
}
private static EmailPriority GetPriority(NotificationEvent evt)
{
if (evt.Metadata.TryGetValue("priority", out var p) && p == "high")
return EmailPriority.High;
if (evt.Payload.TryGetValue("verdict", out var v) && v?.ToString() == "fail")
return EmailPriority.High;
if (evt.EventType == "policy.violation")
return EmailPriority.High;
return EmailPriority.Normal;
}
private static string FormatSubject(NotificationEvent evt)
{
return evt.EventType switch
{
"scan.completed" => FormatScanCompletedSubject(evt),
"policy.violation" => FormatPolicyViolationSubject(evt),
_ => $"[StellaOps] Notification - {evt.EventType}"
};
}
private static string FormatScanCompletedSubject(NotificationEvent evt)
{
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString()?.ToUpperInvariant() ?? "UNKNOWN";
var imageName = evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown";
if (verdict == "FAIL")
{
return $"[StellaOps] ⚠️ CRITICAL - Scan Failed - {imageName}";
}
return $"[StellaOps] Scan Completed - {verdict} - {imageName}";
}
private static string FormatPolicyViolationSubject(NotificationEvent evt)
{
var policyName = evt.Payload.GetValueOrDefault("policy_name")?.ToString() ?? "Unknown Policy";
var imageName = evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown";
return $"[StellaOps] Policy Violation - {policyName} - {imageName}";
}
private static string FormatBody(NotificationEvent evt)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html>");
sb.AppendLine("<head>");
sb.AppendLine(" <meta charset=\"utf-8\">");
sb.AppendLine($" <title>{HtmlEncode(evt.EventType)}</title>");
sb.AppendLine("</head>");
sb.AppendLine("<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\">");
switch (evt.EventType)
{
case "scan.completed":
FormatScanCompletedBody(sb, evt);
break;
case "policy.violation":
FormatPolicyViolationBody(sb, evt);
break;
default:
sb.AppendLine($" <p>Notification: {HtmlEncode(evt.EventType)}</p>");
break;
}
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
private static void FormatScanCompletedBody(System.Text.StringBuilder sb, NotificationEvent evt)
{
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString() ?? "unknown";
var isPassing = verdict.Equals("pass", StringComparison.OrdinalIgnoreCase);
var headerColor = isPassing ? "#10b981" : "#dc2626";
var headerText = isPassing ? "✓ Scan Passed" : "⚠️ Scan Failed - Critical Vulnerabilities Found";
sb.AppendLine($" <div style=\"background: {headerColor}; color: white; padding: 20px; border-radius: 8px 8px 0 0;\">");
sb.AppendLine($" <h1 style=\"margin: 0; font-size: 24px;\">{headerText}</h1>");
sb.AppendLine(" </div>");
sb.AppendLine(" <div style=\"border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;\">");
sb.AppendLine(" <h2 style=\"margin-top: 0;\">Image Details</h2>");
var imageName = HtmlEncode(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
var imageDigest = HtmlEncode(evt.Payload.GetValueOrDefault("image_digest")?.ToString() ?? "unknown");
var scanId = HtmlEncode(evt.Payload.GetValueOrDefault("scan_id")?.ToString() ?? "unknown");
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse;\">");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Image:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{imageName}</td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Digest:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><code>{imageDigest}</code></td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Scan ID:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{scanId}</td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0;\"><strong>Scanned At:</strong></td><td style=\"padding: 8px 0;\">{evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ}</td></tr>");
sb.AppendLine(" </table>");
// Vulnerability summary
if (evt.Payload.TryGetValue("vulnerabilities", out var vulns) && vulns is JsonElement vulnElement)
{
sb.AppendLine(" <h2>Vulnerability Summary</h2>");
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse; text-align: center;\">");
sb.AppendLine(" <tr style=\"background: #f3f4f6;\"><th style=\"padding: 12px;\">Critical</th><th style=\"padding: 12px;\">High</th><th style=\"padding: 12px;\">Medium</th><th style=\"padding: 12px;\">Low</th></tr>");
sb.AppendLine(" <tr>");
var critical = vulnElement.TryGetProperty("critical", out var c) ? c.GetInt32() : 0;
var high = vulnElement.TryGetProperty("high", out var h) ? h.GetInt32() : 0;
var medium = vulnElement.TryGetProperty("medium", out var m) ? m.GetInt32() : 0;
var low = vulnElement.TryGetProperty("low", out var l) ? l.GetInt32() : 0;
var criticalBg = critical > 0 ? " background: #fecaca;" : "";
var highBg = high > 0 ? " background: #fed7aa;" : "";
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d;{criticalBg}\">{critical}</td>");
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412;{highBg}\">{high}</td>");
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;\">{medium}</td>");
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #166534;\">{low}</td>");
sb.AppendLine(" </tr>");
sb.AppendLine(" </table>");
}
// Critical findings
if (!isPassing && evt.Payload.TryGetValue("critical_findings", out var findings) && findings is JsonElement findingsElement)
{
sb.AppendLine(" <h2 style=\"color: #dc2626;\">Critical Vulnerabilities</h2>");
foreach (var finding in findingsElement.EnumerateArray())
{
var cveId = finding.TryGetProperty("cve_id", out var cve) ? cve.GetString() : "Unknown";
var pkg = finding.TryGetProperty("package", out var p) ? p.GetString() : "unknown";
var title = finding.TryGetProperty("title", out var t) ? t.GetString() : "";
var cvss = finding.TryGetProperty("cvss", out var cv) ? cv.GetDouble() : 0;
sb.AppendLine(" <div style=\"background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;\">");
sb.AppendLine($" <h3 style=\"margin: 0 0 8px 0; color: #991b1b;\">{HtmlEncode(cveId)} (CVSS {cvss})</h3>");
sb.AppendLine($" <p style=\"margin: 0 0 8px 0;\"><strong>Package:</strong> {HtmlEncode(pkg)}</p>");
sb.AppendLine($" <p style=\"margin: 0;\">{HtmlEncode(title)}</p>");
sb.AppendLine(" </div>");
}
sb.AppendLine(" <p style=\"margin-top: 20px; padding: 16px; background: #fef3c7; border-radius: 8px; color: #92400e;\">");
sb.AppendLine(" <strong>Action Required:</strong> This image should not be deployed to production until the critical vulnerabilities are remediated.");
sb.AppendLine(" </p>");
}
sb.AppendLine(" <p style=\"margin-top: 20px; color: #6b7280; font-size: 14px;\">This is an automated message from StellaOps. Do not reply to this email.</p>");
sb.AppendLine(" </div>");
}
private static void FormatPolicyViolationBody(System.Text.StringBuilder sb, NotificationEvent evt)
{
sb.AppendLine(" <div style=\"background: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0;\">");
sb.AppendLine(" <h1 style=\"margin: 0; font-size: 24px;\">🚨 Policy Violation Detected</h1>");
sb.AppendLine(" </div>");
sb.AppendLine(" <div style=\"border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;\">");
var policyName = HtmlEncode(evt.Payload.GetValueOrDefault("policy_name")?.ToString() ?? "Unknown");
var imageName = HtmlEncode(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
var imageDigest = HtmlEncode(evt.Payload.GetValueOrDefault("image_digest")?.ToString() ?? "unknown");
var violationType = HtmlEncode(evt.Payload.GetValueOrDefault("violation_type")?.ToString() ?? "unknown");
var details = HtmlEncode(evt.Payload.GetValueOrDefault("details")?.ToString() ?? "");
var remediation = HtmlEncode(evt.Payload.GetValueOrDefault("remediation")?.ToString() ?? "");
sb.AppendLine($" <h2 style=\"margin-top: 0;\">Policy: {policyName}</h2>");
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse;\">");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Image:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{imageName}</td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Digest:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><code>{imageDigest}</code></td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Violation Type:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{violationType}</td></tr>");
sb.AppendLine($" <tr><td style=\"padding: 8px 0;\"><strong>Detected At:</strong></td><td style=\"padding: 8px 0;\">{evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ}</td></tr>");
sb.AppendLine(" </table>");
if (!string.IsNullOrEmpty(details))
{
sb.AppendLine(" <h3>Details</h3>");
sb.AppendLine($" <p style=\"background: #fef3c7; padding: 16px; border-radius: 8px; color: #92400e;\">{details}</p>");
}
if (!string.IsNullOrEmpty(remediation))
{
sb.AppendLine(" <h3>Remediation</h3>");
sb.AppendLine($" <p style=\"background: #d1fae5; padding: 16px; border-radius: 8px; color: #065f46;\">{remediation}</p>");
}
sb.AppendLine(" <p style=\"margin-top: 20px; color: #6b7280; font-size: 14px;\">This is an automated message from StellaOps. Do not reply to this email.</p>");
sb.AppendLine(" </div>");
}
private static string HtmlEncode(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
#endregion