warnings fixes, tests fixes, sprints completions
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SlackSecretAlertFormatter.cs
|
||||
// Sprint: SPRINT_20260104_007_BE_secret_detection_alerts
|
||||
// Task: SDA-004 - Implement Slack/Teams formatters for secret alerts
|
||||
// Description: Slack Block Kit formatter for secret detection alert events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Engine.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats secret detection alert events into Slack Block Kit payloads.
|
||||
/// Supports both individual findings and scan summaries.
|
||||
/// </summary>
|
||||
public sealed class SlackSecretAlertFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Formats an individual secret finding alert for Slack.
|
||||
/// </summary>
|
||||
/// <param name="alert">The secret finding alert event.</param>
|
||||
/// <param name="includeMaskedValue">Whether to include the masked secret value.</param>
|
||||
/// <param name="includeFilePath">Whether to include the file path.</param>
|
||||
/// <param name="findingUrl">URL to view the finding in StellaOps.</param>
|
||||
/// <param name="exceptionUrl">URL to add an exception for this finding.</param>
|
||||
/// <returns>Slack Block Kit JSON payload.</returns>
|
||||
public static string FormatFinding(
|
||||
SecretAlertPayload alert,
|
||||
bool includeMaskedValue = true,
|
||||
bool includeFilePath = true,
|
||||
string? findingUrl = null,
|
||||
string? exceptionUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alert);
|
||||
|
||||
var blocks = new List<object>
|
||||
{
|
||||
// Header
|
||||
new
|
||||
{
|
||||
type = "header",
|
||||
text = new
|
||||
{
|
||||
type = "plain_text",
|
||||
text = ":rotating_light: Secret Detected in Container Scan",
|
||||
emoji = true
|
||||
}
|
||||
},
|
||||
// Severity and Rule
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*Severity:*\n{GetSeverityEmoji(alert.Severity)} {alert.Severity}" },
|
||||
new { type = "mrkdwn", text = $"*Rule:*\n{alert.RuleName}" }
|
||||
}
|
||||
},
|
||||
// Image and Category
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*Image:*\n`{alert.ImageRef}`" },
|
||||
new { type = "mrkdwn", text = $"*Category:*\n{alert.RuleCategory ?? "Uncategorized"}" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// File location (optional)
|
||||
if (includeFilePath)
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
fields = new[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*File:*\n`{alert.FilePath}`" },
|
||||
new { type = "mrkdwn", text = $"*Line:*\n{alert.LineNumber}" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Masked value (optional)
|
||||
if (includeMaskedValue && !string.IsNullOrEmpty(alert.MaskedValue))
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Detected Value (masked):*\n```{alert.MaskedValue}```"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Context
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Scan ID: {0} | Detected: {1:O} | Confidence: {2}",
|
||||
alert.ScanId,
|
||||
alert.DetectedAt,
|
||||
alert.Confidence)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Actions
|
||||
if (!string.IsNullOrEmpty(findingUrl) || !string.IsNullOrEmpty(exceptionUrl))
|
||||
{
|
||||
var actionElements = new List<object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(findingUrl))
|
||||
{
|
||||
actionElements.Add(new
|
||||
{
|
||||
type = "button",
|
||||
text = new { type = "plain_text", text = "View in StellaOps" },
|
||||
url = findingUrl,
|
||||
style = "primary"
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exceptionUrl))
|
||||
{
|
||||
actionElements.Add(new
|
||||
{
|
||||
type = "button",
|
||||
text = new { type = "plain_text", text = "Add Exception" },
|
||||
url = exceptionUrl
|
||||
});
|
||||
}
|
||||
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "actions",
|
||||
elements = actionElements
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new { blocks };
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a secret scan summary for Slack.
|
||||
/// </summary>
|
||||
/// <param name="summary">The scan summary.</param>
|
||||
/// <param name="reportUrl">URL to view the full report.</param>
|
||||
/// <returns>Slack Block Kit JSON payload.</returns>
|
||||
public static string FormatSummary(
|
||||
SecretSummaryPayload summary,
|
||||
string? reportUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var blocks = new List<object>
|
||||
{
|
||||
// Header
|
||||
new
|
||||
{
|
||||
type = "header",
|
||||
text = new
|
||||
{
|
||||
type = "plain_text",
|
||||
text = ":mag: Secret Scan Summary",
|
||||
emoji = true
|
||||
}
|
||||
},
|
||||
// Image
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Image:* `{summary.ImageRef}`"
|
||||
}
|
||||
},
|
||||
// Total and Files
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*Total Findings:*\n{summary.TotalFindings}" },
|
||||
new { type = "mrkdwn", text = $"*Files Scanned:*\n{summary.FilesScanned}" }
|
||||
}
|
||||
},
|
||||
// Severity breakdown
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
fields = new[]
|
||||
{
|
||||
new { type = "mrkdwn", text = $"*:fire: Critical:*\n{summary.CriticalCount}" },
|
||||
new { type = "mrkdwn", text = $"*:warning: High:*\n{summary.HighCount}" },
|
||||
new { type = "mrkdwn", text = $"*:large_blue_circle: Medium:*\n{summary.MediumCount}" },
|
||||
new { type = "mrkdwn", text = $"*:white_circle: Low:*\n{summary.LowCount}" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Top categories (if available)
|
||||
if (summary.TopCategories?.Count > 0)
|
||||
{
|
||||
var categoryText = string.Join("\n",
|
||||
summary.TopCategories.Take(5).Select(c => $"- {c.Category}: {c.Count}"));
|
||||
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Top Categories:*\n{categoryText}"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Context
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Scan ID: {0} | Duration: {1}ms | Completed: {2:O}",
|
||||
summary.ScanId,
|
||||
summary.DurationMs,
|
||||
summary.CompletedAt)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Actions
|
||||
if (!string.IsNullOrEmpty(reportUrl))
|
||||
{
|
||||
blocks.Add(new
|
||||
{
|
||||
type = "actions",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "button",
|
||||
text = new { type = "plain_text", text = "View Full Report" },
|
||||
url = reportUrl,
|
||||
style = "primary"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new { blocks };
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
private static string GetSeverityEmoji(string severity) => severity?.ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => ":fire:",
|
||||
"HIGH" => ":warning:",
|
||||
"MEDIUM" => ":large_blue_circle:",
|
||||
"LOW" => ":white_circle:",
|
||||
_ => ":grey_question:"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload structure for secret finding alerts.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertPayload
|
||||
{
|
||||
public required Guid EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid ScanId { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string RuleName { get; init; }
|
||||
public string? RuleCategory { get; init; }
|
||||
public required string FilePath { get; init; }
|
||||
public required int LineNumber { get; init; }
|
||||
public required string MaskedValue { get; init; }
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
public required string Confidence { get; init; }
|
||||
public string? ScanTriggeredBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload structure for secret scan summaries.
|
||||
/// </summary>
|
||||
public sealed record SecretSummaryPayload
|
||||
{
|
||||
public required Guid ScanId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required int TotalFindings { get; init; }
|
||||
public required int FilesScanned { get; init; }
|
||||
public required int CriticalCount { get; init; }
|
||||
public required int HighCount { get; init; }
|
||||
public required int MediumCount { get; init; }
|
||||
public required int LowCount { get; init; }
|
||||
public required long DurationMs { get; init; }
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
public IReadOnlyList<CategoryCount>? TopCategories { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category count for summary reports.
|
||||
/// </summary>
|
||||
public sealed record CategoryCount
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public required int Count { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TeamsSecretAlertFormatter.cs
|
||||
// Sprint: SPRINT_20260104_007_BE_secret_detection_alerts
|
||||
// Task: SDA-004 - Implement Slack/Teams formatters for secret alerts
|
||||
// Description: Microsoft Teams MessageCard formatter for secret detection alerts
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Engine.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats secret detection alert events into Microsoft Teams MessageCard payloads.
|
||||
/// Supports both individual findings and scan summaries.
|
||||
/// </summary>
|
||||
public sealed class TeamsSecretAlertFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Formats an individual secret finding alert for Teams.
|
||||
/// </summary>
|
||||
/// <param name="alert">The secret finding alert event.</param>
|
||||
/// <param name="includeMaskedValue">Whether to include the masked secret value.</param>
|
||||
/// <param name="includeFilePath">Whether to include the file path.</param>
|
||||
/// <param name="findingUrl">URL to view the finding in StellaOps.</param>
|
||||
/// <param name="exceptionUrl">URL to add an exception for this finding.</param>
|
||||
/// <returns>Teams MessageCard JSON payload.</returns>
|
||||
public static string FormatFinding(
|
||||
SecretAlertPayload alert,
|
||||
bool includeMaskedValue = true,
|
||||
bool includeFilePath = true,
|
||||
string? findingUrl = null,
|
||||
string? exceptionUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alert);
|
||||
|
||||
var facts = new List<object>
|
||||
{
|
||||
new { name = "Severity", value = alert.Severity },
|
||||
new { name = "Rule", value = alert.RuleName },
|
||||
new { name = "Category", value = alert.RuleCategory ?? "Uncategorized" },
|
||||
new { name = "Image", value = alert.ImageRef }
|
||||
};
|
||||
|
||||
if (includeFilePath)
|
||||
{
|
||||
facts.Add(new { name = "File", value = alert.FilePath });
|
||||
facts.Add(new { name = "Line", value = alert.LineNumber.ToString(CultureInfo.InvariantCulture) });
|
||||
}
|
||||
|
||||
facts.Add(new { name = "Confidence", value = alert.Confidence });
|
||||
facts.Add(new { name = "Scan ID", value = alert.ScanId.ToString() });
|
||||
|
||||
var sections = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
activityTitle = "Secret Detected in Container Scan",
|
||||
activitySubtitle = alert.ImageRef,
|
||||
facts,
|
||||
markdown = true
|
||||
}
|
||||
};
|
||||
|
||||
// Add masked value section
|
||||
if (includeMaskedValue && !string.IsNullOrEmpty(alert.MaskedValue))
|
||||
{
|
||||
sections.Add(new
|
||||
{
|
||||
text = $"**Detected Value (masked):**\n\n```\n{alert.MaskedValue}\n```"
|
||||
});
|
||||
}
|
||||
|
||||
var potentialActions = new List<object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(findingUrl))
|
||||
{
|
||||
potentialActions.Add(new
|
||||
{
|
||||
type = "OpenUri",
|
||||
name = "View in StellaOps",
|
||||
targets = new object[] { new { os = "default", uri = findingUrl } }
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exceptionUrl))
|
||||
{
|
||||
potentialActions.Add(new
|
||||
{
|
||||
type = "OpenUri",
|
||||
name = "Add Exception",
|
||||
targets = new object[] { new { os = "default", uri = exceptionUrl } }
|
||||
});
|
||||
}
|
||||
|
||||
var messageCard = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor = GetSeverityColor(alert.Severity),
|
||||
summary = $"Secret Detected - {alert.RuleName} in {alert.ImageRef}",
|
||||
sections,
|
||||
potentialAction = potentialActions.Count > 0 ? potentialActions : null
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(messageCard, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a secret scan summary for Teams.
|
||||
/// </summary>
|
||||
/// <param name="summary">The scan summary.</param>
|
||||
/// <param name="reportUrl">URL to view the full report.</param>
|
||||
/// <returns>Teams MessageCard JSON payload.</returns>
|
||||
public static string FormatSummary(
|
||||
SecretSummaryPayload summary,
|
||||
string? reportUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var facts = new List<object>
|
||||
{
|
||||
new { name = "Total Findings", value = summary.TotalFindings.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "Files Scanned", value = summary.FilesScanned.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "Critical", value = summary.CriticalCount.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "High", value = summary.HighCount.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "Medium", value = summary.MediumCount.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "Low", value = summary.LowCount.ToString(CultureInfo.InvariantCulture) },
|
||||
new { name = "Duration", value = $"{summary.DurationMs}ms" }
|
||||
};
|
||||
|
||||
var sections = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
activityTitle = "Secret Scan Summary",
|
||||
activitySubtitle = summary.ImageRef,
|
||||
facts,
|
||||
markdown = true
|
||||
}
|
||||
};
|
||||
|
||||
// Add top categories if available
|
||||
if (summary.TopCategories?.Count > 0)
|
||||
{
|
||||
var categoryText = string.Join("\n",
|
||||
summary.TopCategories.Take(5).Select(c => $"- {c.Category}: {c.Count}"));
|
||||
|
||||
sections.Add(new
|
||||
{
|
||||
text = $"**Top Categories:**\n\n{categoryText}"
|
||||
});
|
||||
}
|
||||
|
||||
var potentialActions = new List<object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(reportUrl))
|
||||
{
|
||||
potentialActions.Add(new
|
||||
{
|
||||
type = "OpenUri",
|
||||
name = "View Full Report",
|
||||
targets = new object[] { new { os = "default", uri = reportUrl } }
|
||||
});
|
||||
}
|
||||
|
||||
// Determine theme color based on severity counts
|
||||
var themeColor = summary.CriticalCount > 0 ? "FF0000"
|
||||
: summary.HighCount > 0 ? "FFA500"
|
||||
: summary.MediumCount > 0 ? "0078D7"
|
||||
: summary.TotalFindings > 0 ? "808080"
|
||||
: "28A745";
|
||||
|
||||
var messageCard = new
|
||||
{
|
||||
type = "MessageCard",
|
||||
context = "http://schema.org/extensions",
|
||||
themeColor,
|
||||
summary = $"Secret Scan Summary - {summary.ImageRef}",
|
||||
sections,
|
||||
potentialAction = potentialActions.Count > 0 ? potentialActions : null
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(messageCard, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a summary for Adaptive Card (newer Teams format).
|
||||
/// </summary>
|
||||
/// <param name="summary">The scan summary.</param>
|
||||
/// <param name="reportUrl">URL to view the full report.</param>
|
||||
/// <returns>Teams Adaptive Card JSON payload.</returns>
|
||||
public static string FormatSummaryAdaptiveCard(
|
||||
SecretSummaryPayload summary,
|
||||
string? reportUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var bodyElements = new List<object>
|
||||
{
|
||||
// Title
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
size = "Large",
|
||||
weight = "Bolder",
|
||||
text = "Secret Scan Summary"
|
||||
},
|
||||
// Image reference
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = summary.ImageRef,
|
||||
wrap = true,
|
||||
isSubtle = true
|
||||
},
|
||||
// Statistics container
|
||||
new
|
||||
{
|
||||
type = "ColumnSet",
|
||||
columns = new object[]
|
||||
{
|
||||
CreateStatColumn("Total", summary.TotalFindings, "Accent"),
|
||||
CreateStatColumn("Critical", summary.CriticalCount, "Attention"),
|
||||
CreateStatColumn("High", summary.HighCount, "Warning"),
|
||||
CreateStatColumn("Medium", summary.MediumCount, "Default")
|
||||
}
|
||||
},
|
||||
// Additional info
|
||||
new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = new object[]
|
||||
{
|
||||
new { title = "Files Scanned", value = summary.FilesScanned.ToString(CultureInfo.InvariantCulture) },
|
||||
new { title = "Duration", value = $"{summary.DurationMs}ms" },
|
||||
new { title = "Completed", value = summary.CompletedAt.ToString("O", CultureInfo.InvariantCulture) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var actions = new List<object>();
|
||||
if (!string.IsNullOrEmpty(reportUrl))
|
||||
{
|
||||
actions.Add(new
|
||||
{
|
||||
type = "Action.OpenUrl",
|
||||
title = "View Full Report",
|
||||
url = reportUrl
|
||||
});
|
||||
}
|
||||
|
||||
var adaptiveCard = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = bodyElements,
|
||||
actions = actions.Count > 0 ? actions : null
|
||||
};
|
||||
|
||||
// Wrap in Teams message format
|
||||
var message = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = adaptiveCard
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(message, JsonOptions);
|
||||
}
|
||||
|
||||
private static object CreateStatColumn(string title, int value, string color) => new
|
||||
{
|
||||
type = "Column",
|
||||
width = "auto",
|
||||
items = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = title,
|
||||
size = "Small",
|
||||
isSubtle = true,
|
||||
horizontalAlignment = "Center"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = value.ToString(CultureInfo.InvariantCulture),
|
||||
size = "ExtraLarge",
|
||||
weight = "Bolder",
|
||||
horizontalAlignment = "Center",
|
||||
color
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static string GetSeverityColor(string severity) => severity?.ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => "FF0000",
|
||||
"HIGH" => "FFA500",
|
||||
"MEDIUM" => "0078D7",
|
||||
"LOW" => "808080",
|
||||
_ => "6B7280"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretFindingAlertTemplates.cs
|
||||
// Sprint: SPRINT_20260104_007_BE_secret_detection_alerts
|
||||
// Task: SDA-003 - Add secret-finding alert templates
|
||||
// Description: Default templates for secret detection alerts across all channels
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Engine.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Provides default templates for secret detection alert notifications.
|
||||
/// Templates support secret.finding and secret.summary event kinds.
|
||||
/// </summary>
|
||||
public static class SecretFindingAlertTemplates
|
||||
{
|
||||
/// <summary>
|
||||
/// Template key for individual secret finding notifications.
|
||||
/// </summary>
|
||||
public const string SecretFindingKey = "notification.scanner.secret.finding";
|
||||
|
||||
/// <summary>
|
||||
/// Template key for secret scan summary notifications.
|
||||
/// </summary>
|
||||
public const string SecretSummaryKey = "notification.scanner.secret.summary";
|
||||
|
||||
/// <summary>
|
||||
/// Get all default secret alert templates for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="locale">Locale code (default: en-us).</param>
|
||||
/// <returns>Collection of default templates.</returns>
|
||||
public static IReadOnlyList<NotifyTemplate> GetDefaultTemplates(
|
||||
string tenantId,
|
||||
string locale = "en-us")
|
||||
{
|
||||
var templates = new List<NotifyTemplate>();
|
||||
|
||||
// Add individual finding templates
|
||||
templates.Add(CreateSlackFindingTemplate(tenantId, locale));
|
||||
templates.Add(CreateTeamsFindingTemplate(tenantId, locale));
|
||||
templates.Add(CreateEmailFindingTemplate(tenantId, locale));
|
||||
templates.Add(CreateWebhookFindingTemplate(tenantId, locale));
|
||||
templates.Add(CreatePagerDutyFindingTemplate(tenantId, locale));
|
||||
|
||||
// Add summary templates
|
||||
templates.Add(CreateSlackSummaryTemplate(tenantId, locale));
|
||||
templates.Add(CreateTeamsSummaryTemplate(tenantId, locale));
|
||||
templates.Add(CreateEmailSummaryTemplate(tenantId, locale));
|
||||
templates.Add(CreateWebhookSummaryTemplate(tenantId, locale));
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
#region Individual Finding Templates
|
||||
|
||||
private static NotifyTemplate CreateSlackFindingTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-finding-slack-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: SecretFindingKey,
|
||||
locale: locale,
|
||||
body: SlackFindingBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Slack,
|
||||
description: "Slack notification for detected secret in container scan",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateTeamsFindingTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-finding-teams-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Teams,
|
||||
key: SecretFindingKey,
|
||||
locale: locale,
|
||||
body: TeamsFindingBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Teams,
|
||||
description: "Teams notification for detected secret in container scan",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateEmailFindingTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-finding-email-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Email,
|
||||
key: SecretFindingKey,
|
||||
locale: locale,
|
||||
body: EmailFindingBody,
|
||||
renderMode: NotifyTemplateRenderMode.Html,
|
||||
format: NotifyDeliveryFormat.Html,
|
||||
description: "Email notification for detected secret in container scan",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateWebhookFindingTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-finding-webhook-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: SecretFindingKey,
|
||||
locale: locale,
|
||||
body: WebhookFindingBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Json,
|
||||
description: "Webhook notification for detected secret in container scan",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreatePagerDutyFindingTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-finding-pagerduty-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.PagerDuty,
|
||||
key: SecretFindingKey,
|
||||
locale: locale,
|
||||
body: PagerDutyFindingBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Json,
|
||||
description: "PagerDuty notification for critical secrets detected in container scan",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary Templates
|
||||
|
||||
private static NotifyTemplate CreateSlackSummaryTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-summary-slack-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: SecretSummaryKey,
|
||||
locale: locale,
|
||||
body: SlackSummaryBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Slack,
|
||||
description: "Slack summary notification for secret scan results",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateTeamsSummaryTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-summary-teams-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Teams,
|
||||
key: SecretSummaryKey,
|
||||
locale: locale,
|
||||
body: TeamsSummaryBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Teams,
|
||||
description: "Teams summary notification for secret scan results",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateEmailSummaryTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-summary-email-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Email,
|
||||
key: SecretSummaryKey,
|
||||
locale: locale,
|
||||
body: EmailSummaryBody,
|
||||
renderMode: NotifyTemplateRenderMode.Html,
|
||||
format: NotifyDeliveryFormat.Html,
|
||||
description: "Email summary notification for secret scan results",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
private static NotifyTemplate CreateWebhookSummaryTemplate(string tenantId, string locale) =>
|
||||
NotifyTemplate.Create(
|
||||
templateId: $"tmpl-secret-summary-webhook-{tenantId}",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Webhook,
|
||||
key: SecretSummaryKey,
|
||||
locale: locale,
|
||||
body: WebhookSummaryBody,
|
||||
renderMode: NotifyTemplateRenderMode.None,
|
||||
format: NotifyDeliveryFormat.Json,
|
||||
description: "Webhook summary notification for secret scan results",
|
||||
metadata: CreateMetadata("1.0.0"),
|
||||
createdBy: "system:secret-templates");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Template Bodies
|
||||
|
||||
private const string SlackFindingBody = """
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":rotating_light: Secret Detected in Container Scan",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Severity:*\n{{#if (eq payload.severity 'Critical')}}:fire: Critical{{else if (eq payload.severity 'High')}}:warning: High{{else if (eq payload.severity 'Medium')}}:large_blue_circle: Medium{{else}}:white_circle: Low{{/if}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Rule:*\n{{payload.ruleName}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image:*\n`{{payload.imageRef}}`"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Category:*\n{{payload.ruleCategory}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*File:*\n`{{payload.filePath}}`"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Line:*\n{{payload.lineNumber}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{{#if payload.includeMaskedValue}}
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Detected Value (masked):*\n```{{payload.maskedValue}}```"
|
||||
}
|
||||
},
|
||||
{{/if}}
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "Scan ID: {{payload.scanId}} | Detected: {{payload.detectedAt}} | Confidence: {{payload.confidence}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View in StellaOps"
|
||||
},
|
||||
"url": "{{payload.findingUrl}}",
|
||||
"style": "primary"
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "Add Exception"
|
||||
},
|
||||
"url": "{{payload.exceptionUrl}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string TeamsFindingBody = """
|
||||
{
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "{{#if (eq payload.severity 'Critical')}}FF0000{{else if (eq payload.severity 'High')}}FFA500{{else if (eq payload.severity 'Medium')}}0078D7{{else}}808080{{/if}}",
|
||||
"summary": "Secret Detected - {{payload.ruleName}} in {{payload.imageRef}}",
|
||||
"sections": [
|
||||
{
|
||||
"activityTitle": "🚨 Secret Detected in Container Scan",
|
||||
"activitySubtitle": "{{payload.imageRef}}",
|
||||
"facts": [
|
||||
{ "name": "Severity", "value": "{{payload.severity}}" },
|
||||
{ "name": "Rule", "value": "{{payload.ruleName}}" },
|
||||
{ "name": "Category", "value": "{{payload.ruleCategory}}" },
|
||||
{ "name": "File", "value": "{{payload.filePath}}" },
|
||||
{ "name": "Line", "value": "{{payload.lineNumber}}" },
|
||||
{ "name": "Confidence", "value": "{{payload.confidence}}" },
|
||||
{ "name": "Scan ID", "value": "{{payload.scanId}}" }
|
||||
],
|
||||
"markdown": true
|
||||
}
|
||||
{{#if payload.includeMaskedValue}},
|
||||
{
|
||||
"text": "**Detected Value (masked):**\n\n```\n{{payload.maskedValue}}\n```"
|
||||
}
|
||||
{{/if}}
|
||||
],
|
||||
"potentialAction": [
|
||||
{
|
||||
"@type": "OpenUri",
|
||||
"name": "View in StellaOps",
|
||||
"targets": [{ "os": "default", "uri": "{{payload.findingUrl}}" }]
|
||||
},
|
||||
{
|
||||
"@type": "OpenUri",
|
||||
"name": "Add Exception",
|
||||
"targets": [{ "os": "default", "uri": "{{payload.exceptionUrl}}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string EmailFindingBody = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: {{#if (eq payload.severity 'Critical')}}#dc3545{{else if (eq payload.severity 'High')}}#fd7e14{{else if (eq payload.severity 'Medium')}}#0d6efd{{else}}#6c757d{{/if}}; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||
.header h1 { margin: 0; font-size: 20px; }
|
||||
.content { background: #f8f9fa; padding: 20px; border: 1px solid #dee2e6; border-top: none; border-radius: 0 0 8px 8px; }
|
||||
.detail-row { display: flex; margin-bottom: 12px; }
|
||||
.detail-label { font-weight: 600; width: 100px; color: #495057; }
|
||||
.detail-value { flex: 1; }
|
||||
.masked-value { background: #e9ecef; padding: 12px; border-radius: 4px; font-family: monospace; font-size: 13px; overflow-x: auto; }
|
||||
.actions { margin-top: 20px; }
|
||||
.btn { display: inline-block; padding: 10px 20px; border-radius: 4px; text-decoration: none; font-weight: 500; margin-right: 10px; }
|
||||
.btn-primary { background: #0d6efd; color: white; }
|
||||
.btn-secondary { background: #6c757d; color: white; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚨 Secret Detected in Container Scan</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Severity:</span>
|
||||
<span class="detail-value">{{payload.severity}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Rule:</span>
|
||||
<span class="detail-value">{{payload.ruleName}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Category:</span>
|
||||
<span class="detail-value">{{payload.ruleCategory}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Image:</span>
|
||||
<span class="detail-value">{{payload.imageRef}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">File:</span>
|
||||
<span class="detail-value">{{payload.filePath}}:{{payload.lineNumber}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Confidence:</span>
|
||||
<span class="detail-value">{{payload.confidence}}</span>
|
||||
</div>
|
||||
{{#if payload.includeMaskedValue}}
|
||||
<h3>Detected Value (masked):</h3>
|
||||
<div class="masked-value">{{payload.maskedValue}}</div>
|
||||
{{/if}}
|
||||
<div class="actions">
|
||||
<a href="{{payload.findingUrl}}" class="btn btn-primary">View in StellaOps</a>
|
||||
<a href="{{payload.exceptionUrl}}" class="btn btn-secondary">Add Exception</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Scan ID: {{payload.scanId}} | Detected: {{payload.detectedAt}}</p>
|
||||
<p>Triggered by: {{payload.scanTriggeredBy}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
private const string WebhookFindingBody = """
|
||||
{
|
||||
"event": "secret.finding",
|
||||
"version": "1.0",
|
||||
"timestamp": "{{payload.detectedAt}}",
|
||||
"tenant": "{{payload.tenantId}}",
|
||||
"data": {
|
||||
"eventId": "{{payload.eventId}}",
|
||||
"scanId": "{{payload.scanId}}",
|
||||
"imageRef": "{{payload.imageRef}}",
|
||||
"artifactDigest": "{{payload.artifactDigest}}",
|
||||
"severity": "{{payload.severity}}",
|
||||
"ruleId": "{{payload.ruleId}}",
|
||||
"ruleName": "{{payload.ruleName}}",
|
||||
"ruleCategory": "{{payload.ruleCategory}}",
|
||||
"filePath": "{{payload.filePath}}",
|
||||
"lineNumber": {{payload.lineNumber}},
|
||||
"maskedValue": "{{payload.maskedValue}}",
|
||||
"confidence": "{{payload.confidence}}",
|
||||
"bundleId": "{{payload.bundleId}}",
|
||||
"bundleVersion": "{{payload.bundleVersion}}",
|
||||
"scanTriggeredBy": "{{payload.scanTriggeredBy}}"
|
||||
},
|
||||
"links": {
|
||||
"finding": "{{payload.findingUrl}}",
|
||||
"exception": "{{payload.exceptionUrl}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private const string PagerDutyFindingBody = """
|
||||
{
|
||||
"routing_key": "{{payload.routingKey}}",
|
||||
"event_action": "trigger",
|
||||
"dedup_key": "{{payload.deduplicationKey}}",
|
||||
"payload": {
|
||||
"summary": "[{{payload.severity}}] Secret detected in {{payload.imageRef}} - {{payload.ruleName}}",
|
||||
"source": "stellaops-scanner",
|
||||
"severity": "{{#if (eq payload.severity 'Critical')}}critical{{else if (eq payload.severity 'High')}}error{{else if (eq payload.severity 'Medium')}}warning{{else}}info{{/if}}",
|
||||
"timestamp": "{{payload.detectedAt}}",
|
||||
"class": "secret-detection",
|
||||
"component": "scanner",
|
||||
"group": "{{payload.ruleCategory}}",
|
||||
"custom_details": {
|
||||
"image_ref": "{{payload.imageRef}}",
|
||||
"artifact_digest": "{{payload.artifactDigest}}",
|
||||
"rule_id": "{{payload.ruleId}}",
|
||||
"rule_name": "{{payload.ruleName}}",
|
||||
"file_path": "{{payload.filePath}}",
|
||||
"line_number": "{{payload.lineNumber}}",
|
||||
"confidence": "{{payload.confidence}}",
|
||||
"scan_id": "{{payload.scanId}}",
|
||||
"tenant_id": "{{payload.tenantId}}"
|
||||
}
|
||||
},
|
||||
"links": [
|
||||
{ "href": "{{payload.findingUrl}}", "text": "View in StellaOps" },
|
||||
{ "href": "{{payload.exceptionUrl}}", "text": "Add Exception" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SlackSummaryBody = """
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":mag: Secret Scan Summary",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image:* `{{payload.imageRef}}`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Total Findings:*\n{{payload.totalFindings}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Files Scanned:*\n{{payload.filesScanned}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*:fire: Critical:*\n{{payload.criticalCount}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*:warning: High:*\n{{payload.highCount}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*:large_blue_circle: Medium:*\n{{payload.mediumCount}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*:white_circle: Low:*\n{{payload.lowCount}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{{#if payload.topCategories}}
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Top Categories:*\n{{#each payload.topCategories}}• {{this.category}}: {{this.count}}\n{{/each}}"
|
||||
}
|
||||
},
|
||||
{{/if}}
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "Scan ID: {{payload.scanId}} | Duration: {{payload.duration}}ms | Completed: {{payload.completedAt}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Full Report"
|
||||
},
|
||||
"url": "{{payload.reportUrl}}",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string TeamsSummaryBody = """
|
||||
{
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "{{#if payload.criticalCount}}FF0000{{else if payload.highCount}}FFA500{{else if payload.mediumCount}}0078D7{{else}}28A745{{/if}}",
|
||||
"summary": "Secret Scan Summary - {{payload.imageRef}}",
|
||||
"sections": [
|
||||
{
|
||||
"activityTitle": "🔍 Secret Scan Summary",
|
||||
"activitySubtitle": "{{payload.imageRef}}",
|
||||
"facts": [
|
||||
{ "name": "Total Findings", "value": "{{payload.totalFindings}}" },
|
||||
{ "name": "Files Scanned", "value": "{{payload.filesScanned}}" },
|
||||
{ "name": "Critical", "value": "{{payload.criticalCount}}" },
|
||||
{ "name": "High", "value": "{{payload.highCount}}" },
|
||||
{ "name": "Medium", "value": "{{payload.mediumCount}}" },
|
||||
{ "name": "Low", "value": "{{payload.lowCount}}" },
|
||||
{ "name": "Duration", "value": "{{payload.duration}}ms" }
|
||||
],
|
||||
"markdown": true
|
||||
}
|
||||
],
|
||||
"potentialAction": [
|
||||
{
|
||||
"@type": "OpenUri",
|
||||
"name": "View Full Report",
|
||||
"targets": [{ "os": "default", "uri": "{{payload.reportUrl}}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string EmailSummaryBody = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #0d6efd; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||
.header h1 { margin: 0; font-size: 20px; }
|
||||
.content { background: #f8f9fa; padding: 20px; border: 1px solid #dee2e6; border-top: none; border-radius: 0 0 8px 8px; }
|
||||
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 20px 0; }
|
||||
.stat-card { background: white; padding: 16px; border-radius: 8px; border: 1px solid #dee2e6; min-width: 100px; text-align: center; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; }
|
||||
.stat-label { font-size: 12px; color: #6c757d; text-transform: uppercase; }
|
||||
.critical { color: #dc3545; }
|
||||
.high { color: #fd7e14; }
|
||||
.medium { color: #0d6efd; }
|
||||
.low { color: #6c757d; }
|
||||
.actions { margin-top: 20px; }
|
||||
.btn { display: inline-block; padding: 12px 24px; background: #0d6efd; color: white; border-radius: 4px; text-decoration: none; font-weight: 500; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔍 Secret Scan Summary</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p><strong>Image:</strong> {{payload.imageRef}}</p>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{payload.totalFindings}}</div>
|
||||
<div class="stat-label">Total Findings</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value critical">{{payload.criticalCount}}</div>
|
||||
<div class="stat-label">Critical</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value high">{{payload.highCount}}</div>
|
||||
<div class="stat-label">High</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value medium">{{payload.mediumCount}}</div>
|
||||
<div class="stat-label">Medium</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value low">{{payload.lowCount}}</div>
|
||||
<div class="stat-label">Low</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Files Scanned:</strong> {{payload.filesScanned}}</p>
|
||||
<p><strong>Scan Duration:</strong> {{payload.duration}}ms</p>
|
||||
<div class="actions">
|
||||
<a href="{{payload.reportUrl}}" class="btn">View Full Report</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Scan ID: {{payload.scanId}} | Completed: {{payload.completedAt}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
private const string WebhookSummaryBody = """
|
||||
{
|
||||
"event": "secret.summary",
|
||||
"version": "1.0",
|
||||
"timestamp": "{{payload.completedAt}}",
|
||||
"tenant": "{{payload.tenantId}}",
|
||||
"data": {
|
||||
"scanId": "{{payload.scanId}}",
|
||||
"imageRef": "{{payload.imageRef}}",
|
||||
"artifactDigest": "{{payload.artifactDigest}}",
|
||||
"totalFindings": {{payload.totalFindings}},
|
||||
"filesScanned": {{payload.filesScanned}},
|
||||
"severityCounts": {
|
||||
"critical": {{payload.criticalCount}},
|
||||
"high": {{payload.highCount}},
|
||||
"medium": {{payload.mediumCount}},
|
||||
"low": {{payload.lowCount}}
|
||||
},
|
||||
"duration": {{payload.duration}},
|
||||
"scanTriggeredBy": "{{payload.scanTriggeredBy}}"
|
||||
},
|
||||
"links": {
|
||||
"report": "{{payload.reportUrl}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ImmutableDictionary<string, string> CreateMetadata(string version) =>
|
||||
ImmutableDictionary<string, string>.Empty
|
||||
.Add("version", version)
|
||||
.Add("category", "secret-detection")
|
||||
.Add("source", "stellaops-scanner");
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user