tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -95,6 +95,14 @@ public sealed record ReleaseEvidencePackManifest
|
||||
[JsonPropertyName("manifestHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the verification replay log for deterministic offline replay.
|
||||
/// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayLogPath")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReplayLogPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
// Advisory: Sealed Audit-Pack replay_log.json format per EU CRA/NIS2 compliance
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Verification replay log for deterministic offline proof replay.
|
||||
/// Captures step-by-step verification operations that auditors can replay
|
||||
/// to recompute canonical_digest → DSSE subject digest → signature verify → Rekor inclusion verify.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This format satisfies the EU CRA (Regulation 2024/2847) and NIS2 (Directive 2022/2555)
|
||||
/// requirements for verifiable supply-chain evidence in procurement scenarios.
|
||||
/// </remarks>
|
||||
public sealed record VerificationReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the replay log format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_id")]
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the artifact being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact_ref")]
|
||||
public required string ArtifactRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when verification was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier tool used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verifier_version")]
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verification result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of verification steps for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public required ImmutableArray<VerificationReplayStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public keys used for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_keys")]
|
||||
public required ImmutableArray<VerificationKeyRef> VerificationKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor")]
|
||||
public RekorVerificationInfo? Rekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification step in the replay log.
|
||||
/// </summary>
|
||||
public sealed record VerificationReplayStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step number (1-indexed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("step")]
|
||||
public required int Step { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action performed (e.g., "compute_canonical_sbom_digest", "verify_dsse_signature").
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the action for human readers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input file or value for this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("input")]
|
||||
public string? Input { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output/computed value from this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("output")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value (for comparison steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expected")]
|
||||
public string? Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual computed value (for comparison steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("actual")]
|
||||
public string? Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of this step: "pass", "fail", or "skip".
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of this step in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public double? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the step failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used if this was a signature verification step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("key_id")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used (e.g., "sha256", "ecdsa-p256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a verification key.
|
||||
/// </summary>
|
||||
public sealed record VerificationKeyRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("key_id")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key type (e.g., "cosign", "rekor", "fulcio").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the public key file in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the public key.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log verification information.
|
||||
/// </summary>
|
||||
public sealed record RekorVerificationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_id")]
|
||||
public required string LogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index of the entry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_index")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tree_size")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the inclusion proof file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusion_proof_path")]
|
||||
public string? InclusionProofPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the signed checkpoint file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint_path")]
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Integrated time (Unix timestamp).
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrated_time")]
|
||||
public long? IntegratedTime { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
using StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack;
|
||||
|
||||
@@ -104,16 +105,11 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
verifyMd,
|
||||
cancellationToken);
|
||||
|
||||
// Write verify.sh
|
||||
var verifyShContent = await LoadTemplateAsync("verify.sh.template");
|
||||
// Write verify.sh (template renamed to avoid MSBuild treating .sh as culture code)
|
||||
var verifyShContent = await LoadTemplateAsync("verify-unix.template");
|
||||
var verifyShPath = Path.Combine(bundleDir, "verify.sh");
|
||||
await File.WriteAllTextAsync(verifyShPath, verifyShContent, cancellationToken);
|
||||
#if !WINDOWS
|
||||
// Make executable on Unix
|
||||
File.SetUnixFileMode(verifyShPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
#endif
|
||||
SetUnixExecutableIfSupported(verifyShPath);
|
||||
|
||||
// Write verify.ps1
|
||||
var verifyPs1Content = await LoadTemplateAsync("verify.ps1.template");
|
||||
@@ -125,6 +121,40 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
_logger.LogInformation("Evidence pack written to: {Path}", bundleDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure with optional replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToDirectoryAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string outputPath,
|
||||
string artifactsSourcePath,
|
||||
string publicKeyPath,
|
||||
string? rekorPublicKeyPath,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Update manifest with replay log path if provided
|
||||
var manifestWithReplayLog = replayLog is not null
|
||||
? manifest with { ReplayLogPath = "replay_log.json" }
|
||||
: manifest;
|
||||
|
||||
await SerializeToDirectoryAsync(
|
||||
manifestWithReplayLog,
|
||||
outputPath,
|
||||
artifactsSourcePath,
|
||||
publicKeyPath,
|
||||
rekorPublicKeyPath,
|
||||
cancellationToken);
|
||||
|
||||
// Write replay_log.json if provided
|
||||
if (replayLog is not null)
|
||||
{
|
||||
var bundleDir = Path.Combine(outputPath, $"stella-release-{manifest.ReleaseVersion}-evidence-pack");
|
||||
await WriteReplayLogAsync(bundleDir, replayLog, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure without copying artifacts.
|
||||
/// This overload is useful for testing and scenarios where artifacts are referenced but not bundled.
|
||||
@@ -172,16 +202,11 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
verifyMd,
|
||||
cancellationToken);
|
||||
|
||||
// Write verify.sh
|
||||
var verifyShContent = await LoadTemplateAsync("verify.sh.template");
|
||||
// Write verify.sh (template renamed to avoid MSBuild treating .sh as culture code)
|
||||
var verifyShContent = await LoadTemplateAsync("verify-unix.template");
|
||||
var verifyShPath = Path.Combine(outputPath, "verify.sh");
|
||||
await File.WriteAllTextAsync(verifyShPath, verifyShContent, cancellationToken);
|
||||
#if !WINDOWS
|
||||
// Make executable on Unix
|
||||
File.SetUnixFileMode(verifyShPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
#endif
|
||||
SetUnixExecutableIfSupported(verifyShPath);
|
||||
|
||||
// Write verify.ps1
|
||||
var verifyPs1Content = await LoadTemplateAsync("verify.ps1.template");
|
||||
@@ -193,6 +218,30 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
_logger.LogInformation("Evidence pack written to: {Path}", outputPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure without copying artifacts, with optional replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToDirectoryAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string outputPath,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Update manifest with replay log path if provided
|
||||
var manifestWithReplayLog = replayLog is not null
|
||||
? manifest with { ReplayLogPath = "replay_log.json" }
|
||||
: manifest;
|
||||
|
||||
await SerializeToDirectoryAsync(manifestWithReplayLog, outputPath, cancellationToken);
|
||||
|
||||
// Write replay_log.json if provided
|
||||
if (replayLog is not null)
|
||||
{
|
||||
await WriteReplayLogAsync(outputPath, replayLog, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .tar.gz archive.
|
||||
/// </summary>
|
||||
@@ -337,6 +386,100 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .tar.gz archive with replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToTarGzAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
Stream outputStream,
|
||||
string bundleName,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create temp directory, serialize, then create tar.gz
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"evidence-pack-{Guid.NewGuid():N}");
|
||||
var bundleDir = Path.Combine(tempDir, bundleName);
|
||||
try
|
||||
{
|
||||
await SerializeToDirectoryAsync(manifest, bundleDir, replayLog, cancellationToken);
|
||||
|
||||
// Create tar.gz using GZipStream
|
||||
await using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal, leaveOpen: true);
|
||||
await CreateTarFromDirectoryAsync(bundleDir, gzipStream, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Evidence pack archived as tar.gz with replay_log.json");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .zip archive with replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToZipAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
Stream outputStream,
|
||||
string bundleName,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create temp directory, serialize, then create zip
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"evidence-pack-{Guid.NewGuid():N}");
|
||||
var bundleDir = Path.Combine(tempDir, bundleName);
|
||||
try
|
||||
{
|
||||
await SerializeToDirectoryAsync(manifest, bundleDir, replayLog, cancellationToken);
|
||||
|
||||
using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
await AddDirectoryToZipAsync(archive, bundleDir, bundleName, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Evidence pack archived as zip with replay_log.json");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the replay_log.json file to the bundle directory.
|
||||
/// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json
|
||||
/// </summary>
|
||||
private async Task WriteReplayLogAsync(
|
||||
string bundleDir,
|
||||
VerificationReplayLog replayLog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var replayLogJson = JsonSerializer.Serialize(replayLog, ReplayLogSerializerContext.Default.VerificationReplayLog);
|
||||
var replayLogPath = Path.Combine(bundleDir, "replay_log.json");
|
||||
await File.WriteAllTextAsync(replayLogPath, replayLogJson, cancellationToken);
|
||||
_logger.LogDebug("Wrote replay_log.json for deterministic verification replay");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets Unix executable permissions on a file if running on a Unix-like OS.
|
||||
/// </summary>
|
||||
private static void SetUnixExecutableIfSupported(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(filePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateChecksumsFilesAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string bundleDir,
|
||||
@@ -447,6 +590,31 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Deterministic Replay Verification (CRA/NIS2 compliance)
|
||||
if (manifest.ReplayLogPath is not null)
|
||||
{
|
||||
sb.AppendLine("## Deterministic Replay Verification (EU CRA/NIS2)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("This bundle includes `replay_log.json` for offline deterministic verification.");
|
||||
sb.AppendLine("The replay log documents each verification step for auditor replay:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```bash");
|
||||
sb.AppendLine("# View verification steps");
|
||||
sb.AppendLine("cat replay_log.json | jq '.steps[]'");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("# Verify all steps passed");
|
||||
sb.AppendLine("cat replay_log.json | jq '.result'");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Steps typically include:");
|
||||
sb.AppendLine("1. `compute_canonical_sbom_digest` - RFC 8785 JCS canonicalization");
|
||||
sb.AppendLine("2. `verify_dsse_subject_match` - SBOM digest matches DSSE subject");
|
||||
sb.AppendLine("3. `verify_dsse_signature` - DSSE envelope signature validation");
|
||||
sb.AppendLine("4. `verify_rekor_inclusion` - Merkle proof against transparency log");
|
||||
sb.AppendLine("5. `verify_rekor_checkpoint` - Signed checkpoint validation (optional)");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("## Bundle Contents");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| File | SHA-256 | Description |");
|
||||
@@ -488,7 +656,7 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
|
||||
private static async Task<string> LoadTemplateAsync(string templateName)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var assembly = typeof(ReleaseEvidencePackSerializer).Assembly;
|
||||
var resourceName = $"StellaOps.Attestor.EvidencePack.Templates.{templateName}";
|
||||
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for verification replay logs.
|
||||
/// </summary>
|
||||
[JsonSerializable(typeof(VerificationReplayLog))]
|
||||
[JsonSerializable(typeof(VerificationReplayStep))]
|
||||
[JsonSerializable(typeof(VerificationKeyRef))]
|
||||
[JsonSerializable(typeof(RekorVerificationInfo))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class ReplayLogSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
// Advisory: Sealed Audit-Pack replay_log.json generation per EU CRA/NIS2 compliance
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds verification replay logs for deterministic offline proof replay.
|
||||
/// </summary>
|
||||
public sealed class VerificationReplayLogBuilder : IVerificationReplayLogBuilder
|
||||
{
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
private const string VerifierVersion = "stellaops-attestor/1.0.0";
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VerificationReplayLogBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a verification replay log from SBOM verification results.
|
||||
/// </summary>
|
||||
public VerificationReplayLog Build(VerificationReplayLogRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var replayId = GenerateReplayId(request.ArtifactRef, now);
|
||||
|
||||
var steps = new List<VerificationReplayStep>();
|
||||
var stepNumber = 1;
|
||||
|
||||
// Step 1: Compute canonical SBOM digest
|
||||
if (request.SbomPath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "compute_canonical_sbom_digest",
|
||||
Description = "Compute SHA-256 hash of the canonicalized SBOM (RFC 8785 JCS)",
|
||||
Input = request.SbomPath,
|
||||
Output = request.CanonicalSbomDigest,
|
||||
Result = "pass",
|
||||
Algorithm = "sha256"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Verify DSSE subject match
|
||||
if (request.DsseSubjectDigest is not null)
|
||||
{
|
||||
var subjectMatch = string.Equals(
|
||||
request.CanonicalSbomDigest,
|
||||
request.DsseSubjectDigest,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_dsse_subject_match",
|
||||
Description = "Verify SBOM digest matches DSSE envelope subject[].digest",
|
||||
Expected = request.DsseSubjectDigest,
|
||||
Actual = request.CanonicalSbomDigest,
|
||||
Result = subjectMatch ? "pass" : "fail",
|
||||
Error = subjectMatch ? null : "SBOM digest does not match DSSE subject digest"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Verify DSSE signature
|
||||
if (request.DsseEnvelopePath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_dsse_signature",
|
||||
Description = "Verify DSSE envelope signature using supplier public key",
|
||||
Input = request.DsseEnvelopePath,
|
||||
KeyId = request.SigningKeyId,
|
||||
Result = request.DsseSignatureValid ? "pass" : "fail",
|
||||
Error = request.DsseSignatureValid ? null : request.DsseSignatureError,
|
||||
Algorithm = request.SignatureAlgorithm ?? "ecdsa-p256"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Verify Rekor inclusion
|
||||
if (request.RekorLogIndex is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_rekor_inclusion",
|
||||
Description = "Verify Merkle inclusion proof against Rekor transparency log",
|
||||
Input = request.InclusionProofPath,
|
||||
Output = $"log_index={request.RekorLogIndex}",
|
||||
Result = request.RekorInclusionValid ? "pass" : "fail",
|
||||
Error = request.RekorInclusionValid ? null : request.RekorInclusionError
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Verify Rekor checkpoint signature (if provided)
|
||||
if (request.CheckpointPath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_rekor_checkpoint",
|
||||
Description = "Verify signed Rekor checkpoint (tile head)",
|
||||
Input = request.CheckpointPath,
|
||||
KeyId = request.RekorPublicKeyId,
|
||||
Result = request.CheckpointValid ? "pass" : "fail",
|
||||
Error = request.CheckpointValid ? null : "Checkpoint signature verification failed"
|
||||
});
|
||||
}
|
||||
|
||||
// Build verification keys list
|
||||
var keys = new List<VerificationKeyRef>();
|
||||
if (request.CosignPublicKeyPath is not null)
|
||||
{
|
||||
keys.Add(new VerificationKeyRef
|
||||
{
|
||||
KeyId = request.SigningKeyId ?? "cosign-key",
|
||||
Type = "cosign",
|
||||
Path = request.CosignPublicKeyPath,
|
||||
Fingerprint = request.SigningKeyFingerprint
|
||||
});
|
||||
}
|
||||
if (request.RekorPublicKeyPath is not null)
|
||||
{
|
||||
keys.Add(new VerificationKeyRef
|
||||
{
|
||||
KeyId = request.RekorPublicKeyId ?? "rekor-key",
|
||||
Type = "rekor",
|
||||
Path = request.RekorPublicKeyPath
|
||||
});
|
||||
}
|
||||
|
||||
// Build Rekor info
|
||||
RekorVerificationInfo? rekorInfo = null;
|
||||
if (request.RekorLogIndex is not null && request.RekorLogId is not null)
|
||||
{
|
||||
rekorInfo = new RekorVerificationInfo
|
||||
{
|
||||
LogId = request.RekorLogId,
|
||||
LogIndex = request.RekorLogIndex.Value,
|
||||
TreeSize = request.RekorTreeSize ?? 0,
|
||||
RootHash = request.RekorRootHash ?? string.Empty,
|
||||
InclusionProofPath = request.InclusionProofPath,
|
||||
CheckpointPath = request.CheckpointPath,
|
||||
IntegratedTime = request.RekorIntegratedTime
|
||||
};
|
||||
}
|
||||
|
||||
// Determine overall result
|
||||
var overallResult = steps.All(s => s.Result == "pass" || s.Result == "skip") ? "pass" : "fail";
|
||||
|
||||
return new VerificationReplayLog
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
ReplayId = replayId,
|
||||
ArtifactRef = request.ArtifactRef,
|
||||
VerifiedAt = now,
|
||||
VerifierVersion = VerifierVersion,
|
||||
Result = overallResult,
|
||||
Steps = steps.ToImmutableArray(),
|
||||
VerificationKeys = keys.ToImmutableArray(),
|
||||
Rekor = rekorInfo,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the replay log to JSON.
|
||||
/// </summary>
|
||||
public string Serialize(VerificationReplayLog log)
|
||||
{
|
||||
return JsonSerializer.Serialize(log, ReplayLogSerializerContext.Default.VerificationReplayLog);
|
||||
}
|
||||
|
||||
private static string GenerateReplayId(string artifactRef, DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{artifactRef}:{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"replay_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for building a verification replay log.
|
||||
/// </summary>
|
||||
public sealed record VerificationReplayLogRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference to the artifact being verified (e.g., OCI reference, file path).
|
||||
/// </summary>
|
||||
public required string ArtifactRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the SBOM file in the bundle.
|
||||
/// </summary>
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the canonicalized SBOM.
|
||||
/// </summary>
|
||||
public string? CanonicalSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the DSSE envelope file in the bundle.
|
||||
/// </summary>
|
||||
public string? DsseEnvelopePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest from DSSE envelope subject[].digest.
|
||||
/// </summary>
|
||||
public string? DsseSubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether DSSE signature verification passed.
|
||||
/// </summary>
|
||||
public bool DsseSignatureValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if DSSE signature verification failed.
|
||||
/// </summary>
|
||||
public string? DsseSignatureError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the signing public key.
|
||||
/// </summary>
|
||||
public string? SigningKeyFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the cosign public key in the bundle.
|
||||
/// </summary>
|
||||
public string? CosignPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor tree size at time of inclusion.
|
||||
/// </summary>
|
||||
public long? RekorTreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor root hash.
|
||||
/// </summary>
|
||||
public string? RekorRootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor integrated time (Unix timestamp).
|
||||
/// </summary>
|
||||
public long? RekorIntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the inclusion proof file.
|
||||
/// </summary>
|
||||
public string? InclusionProofPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor inclusion proof verification passed.
|
||||
/// </summary>
|
||||
public bool RekorInclusionValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if Rekor inclusion verification failed.
|
||||
/// </summary>
|
||||
public string? RekorInclusionError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the signed checkpoint file.
|
||||
/// </summary>
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether checkpoint signature verification passed.
|
||||
/// </summary>
|
||||
public bool CheckpointValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the Rekor public key in the bundle.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor public key ID.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for building verification replay logs.
|
||||
/// </summary>
|
||||
public interface IVerificationReplayLogBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a verification replay log from the request.
|
||||
/// </summary>
|
||||
VerificationReplayLog Build(VerificationReplayLogRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the replay log to JSON.
|
||||
/// </summary>
|
||||
string Serialize(VerificationReplayLog log);
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.IO.Compression" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,7 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Templates\VERIFY.md.template" />
|
||||
<EmbeddedResource Include="Templates\verify.sh.template" />
|
||||
<EmbeddedResource Include="Templates\verify-unix.template" />
|
||||
<EmbeddedResource Include="Templates\verify.ps1.template" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryMicroWitnessPredicate.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-001 - Define binary-micro-witness predicate schema
|
||||
// Description: Compact DSSE predicate for auditor-friendly binary patch witnesses.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// Compact DSSE predicate for binary-level patch verification witnesses.
|
||||
/// Designed for auditor portability (<1KB target size).
|
||||
/// predicateType: https://stellaops.dev/predicates/binary-micro-witness@v1
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a compact formalization of DeltaSig verification results,
|
||||
/// optimized for third-party audit and offline verification.
|
||||
/// </remarks>
|
||||
public sealed record BinaryMicroWitnessPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for binary micro-witness attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stellaops.dev/predicates/binary-micro-witness@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Short name for display purposes.
|
||||
/// </summary>
|
||||
public const string PredicateTypeName = "stellaops/binary-micro-witness/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version (semver).
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Binary artifact being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("binary")]
|
||||
public required MicroWitnessBinaryRef Binary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or advisory being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required MicroWitnessCveRef Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification verdict: "patched", "vulnerable", "inconclusive".
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compact function match evidence (top matches only).
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required IReadOnlyList<MicroWitnessFunctionEvidence> Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of full DeltaSig predicate for detailed analysis.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("deltaSigDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? DeltaSigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component reference (purl or bomRef).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomRef")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public MicroWitnessSbomRef? SbomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tooling metadata for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tooling")]
|
||||
public required MicroWitnessTooling Tooling { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verification was computed (RFC 3339).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compact binary reference for micro-witness.
|
||||
/// </summary>
|
||||
public sealed record MicroWitnessBinaryRef
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the binary.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (purl) if known.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture (e.g., "linux-amd64").
|
||||
/// </summary>
|
||||
[JsonPropertyName("arch")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Arch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filename or path (for display).
|
||||
/// </summary>
|
||||
[JsonPropertyName("filename")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Filename { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE/advisory reference for micro-witness.
|
||||
/// </summary>
|
||||
public sealed record MicroWitnessCveRef
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2024-1234").
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional advisory URL or upstream reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("advisory")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Advisory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Upstream commit hash if known.
|
||||
/// </summary>
|
||||
[JsonPropertyName("patchCommit")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? PatchCommit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compact function match evidence for micro-witness.
|
||||
/// </summary>
|
||||
public sealed record MicroWitnessFunctionEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Function/symbol name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("function")]
|
||||
public required string Function { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Match state: "patched", "vulnerable", "modified", "unchanged".
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Match confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Match method used: "semantic_ksg", "byte_exact", "cfg_structural".
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function hash in analyzed binary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Hash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component reference for micro-witness.
|
||||
/// </summary>
|
||||
public sealed record MicroWitnessSbomRef
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM document digest.
|
||||
/// Format: sha256:<64-hex-chars>
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component bomRef within the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bomRef")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? BomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component purl within the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tooling metadata for micro-witness reproducibility.
|
||||
/// </summary>
|
||||
public sealed record MicroWitnessTooling
|
||||
{
|
||||
/// <summary>
|
||||
/// BinaryIndex version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("binaryIndexVersion")]
|
||||
public required string BinaryIndexVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifter used: "b2r2", "ghidra".
|
||||
/// </summary>
|
||||
[JsonPropertyName("lifter")]
|
||||
public required string Lifter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Match algorithm: "semantic_ksg", "byte_exact".
|
||||
/// </summary>
|
||||
[JsonPropertyName("matchAlgorithm")]
|
||||
public required string MatchAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalization recipe ID (for reproducibility).
|
||||
/// </summary>
|
||||
[JsonPropertyName("normalizationRecipe")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? NormalizationRecipe { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for micro-witness verdict values.
|
||||
/// </summary>
|
||||
public static class MicroWitnessVerdicts
|
||||
{
|
||||
public const string Patched = "patched";
|
||||
public const string Vulnerable = "vulnerable";
|
||||
public const string Inconclusive = "inconclusive";
|
||||
public const string Partial = "partial";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryMicroWitnessStatement.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-001 - Define binary-micro-witness predicate schema
|
||||
// Description: In-toto statement wrapper for binary micro-witness predicates.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for binary micro-witness attestations.
|
||||
/// Predicate type: https://stellaops.dev/predicates/binary-micro-witness@v1
|
||||
/// </summary>
|
||||
public sealed record BinaryMicroWitnessStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => BinaryMicroWitnessPredicate.PredicateType;
|
||||
|
||||
/// <summary>
|
||||
/// The binary micro-witness predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required BinaryMicroWitnessPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityAlertEvent.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-002
|
||||
// Description: Event contract for identity alerts emitted by the watchlist monitor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a watched identity is detected in a transparency log entry.
|
||||
/// This event is routed through the notification system to configured channels.
|
||||
/// </summary>
|
||||
public sealed record IdentityAlertEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this event instance.
|
||||
/// </summary>
|
||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Event kind. One of the IdentityAlertEventKinds constants.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventKind")]
|
||||
public required string EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant that owns the watchlist entry that triggered this alert.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the watchlist entry that matched.
|
||||
/// </summary>
|
||||
public required Guid WatchlistEntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the watchlist entry for notification rendering.
|
||||
/// </summary>
|
||||
public required string WatchlistEntryName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity values that triggered the match.
|
||||
/// </summary>
|
||||
public required IdentityAlertMatchedIdentity MatchedIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the Rekor entry that contained the matching identity.
|
||||
/// </summary>
|
||||
public required IdentityAlertRekorEntry RekorEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level of this alert.
|
||||
/// </summary>
|
||||
public required IdentityAlertSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this alert was generated.
|
||||
/// </summary>
|
||||
public DateTimeOffset OccurredAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Number of duplicate alerts that were suppressed within the dedup window.
|
||||
/// Only relevant when this is the first alert after suppression.
|
||||
/// </summary>
|
||||
public int SuppressedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel overrides from the watchlist entry.
|
||||
/// When null, uses tenant's default attestation channels.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ChannelOverrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Serializes this event to canonical JSON for deterministic hashing.
|
||||
/// Keys are sorted lexicographically, no whitespace.
|
||||
/// </summary>
|
||||
public string ToCanonicalJson()
|
||||
{
|
||||
// Build a sorted dictionary representation for canonical output
|
||||
var sorted = new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["channelOverrides"] = ChannelOverrides,
|
||||
["eventId"] = EventId.ToString(),
|
||||
["eventKind"] = EventKind,
|
||||
["matchedIdentity"] = MatchedIdentity != null ? new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["issuer"] = MatchedIdentity.Issuer,
|
||||
["keyId"] = MatchedIdentity.KeyId,
|
||||
["subjectAlternativeName"] = MatchedIdentity.SubjectAlternativeName
|
||||
}.Where(kv => kv.Value != null).ToDictionary(kv => kv.Key, kv => kv.Value) : null,
|
||||
["occurredAtUtc"] = OccurredAtUtc.ToString("O"),
|
||||
["rekorEntry"] = RekorEntry != null ? new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["artifactSha256"] = RekorEntry.ArtifactSha256,
|
||||
["integratedTimeUtc"] = RekorEntry.IntegratedTimeUtc.ToString("O"),
|
||||
["logIndex"] = RekorEntry.LogIndex,
|
||||
["uuid"] = RekorEntry.Uuid
|
||||
} : null,
|
||||
["severity"] = Severity.ToString(),
|
||||
["suppressedCount"] = SuppressedCount,
|
||||
["tenantId"] = TenantId,
|
||||
["watchlistEntryId"] = WatchlistEntryId.ToString(),
|
||||
["watchlistEntryName"] = WatchlistEntryName
|
||||
};
|
||||
|
||||
// Remove null entries for canonical output
|
||||
var filtered = sorted.Where(kv => kv.Value != null).ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
return JsonSerializer.Serialize(filtered, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an IdentityAlertEvent from a match result and Rekor entry details.
|
||||
/// </summary>
|
||||
public static IdentityAlertEvent FromMatch(
|
||||
IdentityMatchResult match,
|
||||
string rekorUuid,
|
||||
long logIndex,
|
||||
string artifactSha256,
|
||||
DateTimeOffset integratedTimeUtc,
|
||||
int suppressedCount = 0)
|
||||
{
|
||||
return new IdentityAlertEvent
|
||||
{
|
||||
EventKind = IdentityAlertEventKinds.IdentityMatched,
|
||||
TenantId = match.WatchlistEntry.TenantId,
|
||||
WatchlistEntryId = match.WatchlistEntry.Id,
|
||||
WatchlistEntryName = match.WatchlistEntry.DisplayName,
|
||||
MatchedIdentity = new IdentityAlertMatchedIdentity
|
||||
{
|
||||
Issuer = match.MatchedValues.Issuer,
|
||||
SubjectAlternativeName = match.MatchedValues.SubjectAlternativeName,
|
||||
KeyId = match.MatchedValues.KeyId
|
||||
},
|
||||
RekorEntry = new IdentityAlertRekorEntry
|
||||
{
|
||||
Uuid = rekorUuid,
|
||||
LogIndex = logIndex,
|
||||
ArtifactSha256 = artifactSha256,
|
||||
IntegratedTimeUtc = integratedTimeUtc
|
||||
},
|
||||
Severity = match.WatchlistEntry.Severity,
|
||||
SuppressedCount = suppressedCount,
|
||||
ChannelOverrides = match.WatchlistEntry.ChannelOverrides
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity values that triggered a watchlist match.
|
||||
/// </summary>
|
||||
public sealed record IdentityAlertMatchedIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// OIDC issuer URL from the signing identity.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate Subject Alternative Name from the signing identity.
|
||||
/// </summary>
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for keyful signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the Rekor entry that triggered the alert.
|
||||
/// </summary>
|
||||
public sealed record IdentityAlertRekorEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor entry UUID.
|
||||
/// </summary>
|
||||
public required string Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index (sequence number) in the Rekor log.
|
||||
/// </summary>
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the artifact that was signed.
|
||||
/// </summary>
|
||||
public required string ArtifactSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the entry was integrated into the Rekor log.
|
||||
/// </summary>
|
||||
public required DateTimeOffset IntegratedTimeUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityAlertEventKinds.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-002
|
||||
// Description: Defines event kind constants for identity alerting.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for identity alert event kinds.
|
||||
/// These align with the existing AttestationEventRequest.Kind patterns.
|
||||
/// </summary>
|
||||
public static class IdentityAlertEventKinds
|
||||
{
|
||||
/// <summary>
|
||||
/// Event raised when a watched identity appears in a new Rekor entry.
|
||||
/// This is the primary alert event for identity monitoring.
|
||||
/// </summary>
|
||||
public const string IdentityMatched = "attestor.identity.matched";
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when an identity signs without a corresponding Signer request.
|
||||
/// This indicates potential credential compromise.
|
||||
/// (Phase 2 - requires Signer correlation)
|
||||
/// </summary>
|
||||
public const string IdentityUnexpected = "attestor.identity.unexpected";
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a watchlist entry is created.
|
||||
/// Used for audit trail.
|
||||
/// </summary>
|
||||
public const string WatchlistEntryCreated = "attestor.watchlist.entry.created";
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a watchlist entry is updated.
|
||||
/// Used for audit trail.
|
||||
/// </summary>
|
||||
public const string WatchlistEntryUpdated = "attestor.watchlist.entry.updated";
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a watchlist entry is deleted.
|
||||
/// Used for audit trail.
|
||||
/// </summary>
|
||||
public const string WatchlistEntryDeleted = "attestor.watchlist.entry.deleted";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IIdentityMatcher.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-003
|
||||
// Description: Interface for matching identities against watchlist entries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Matching;
|
||||
|
||||
/// <summary>
|
||||
/// Matches signing identities against watchlist entries.
|
||||
/// </summary>
|
||||
public interface IIdentityMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds all watchlist entries that match the given identity.
|
||||
/// </summary>
|
||||
/// <param name="identity">The signing identity to match.</param>
|
||||
/// <param name="tenantId">The tenant ID for scoping watchlist entries.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of all matching watchlist entries with match details.</returns>
|
||||
Task<IReadOnlyList<IdentityMatchResult>> MatchAsync(
|
||||
SignerIdentityInput identity,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Tests if a specific identity matches a specific watchlist entry.
|
||||
/// Used for testing patterns before saving.
|
||||
/// </summary>
|
||||
/// <param name="identity">The signing identity to test.</param>
|
||||
/// <param name="entry">The watchlist entry to test against.</param>
|
||||
/// <returns>Match result if matched, null otherwise.</returns>
|
||||
IdentityMatchResult? TestMatch(SignerIdentityInput identity, WatchedIdentity entry);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityMatcher.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-003
|
||||
// Description: Implementation of identity matching against watchlist entries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Matching;
|
||||
|
||||
/// <summary>
|
||||
/// Matches signing identities against watchlist entries with caching and performance optimization.
|
||||
/// </summary>
|
||||
public sealed class IdentityMatcher : IIdentityMatcher
|
||||
{
|
||||
private readonly IWatchlistRepository _repository;
|
||||
private readonly PatternCompiler _patternCompiler;
|
||||
private readonly ILogger<IdentityMatcher> _logger;
|
||||
|
||||
// Metrics
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Attestor.Watchlist");
|
||||
|
||||
public IdentityMatcher(
|
||||
IWatchlistRepository repository,
|
||||
PatternCompiler patternCompiler,
|
||||
ILogger<IdentityMatcher> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_patternCompiler = patternCompiler ?? throw new ArgumentNullException(nameof(patternCompiler));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<IdentityMatchResult>> MatchAsync(
|
||||
SignerIdentityInput identity,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("IdentityMatcher.MatchAsync");
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Get active watchlist entries for tenant (includes global and system)
|
||||
var entries = await _repository.GetActiveForMatchingAsync(tenantId, cancellationToken);
|
||||
|
||||
activity?.SetTag("watchlist_entries_count", entries.Count);
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var matches = new List<IdentityMatchResult>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var match = TestMatch(identity, entry);
|
||||
if (match is not null)
|
||||
{
|
||||
matches.Add(match);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
activity?.SetTag("matches_count", matches.Count);
|
||||
activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds);
|
||||
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Found {MatchCount} watchlist matches for identity (issuer={Issuer}, san={SAN}) in {ElapsedMs}ms",
|
||||
matches.Count,
|
||||
identity.Issuer ?? "(null)",
|
||||
identity.SubjectAlternativeName ?? "(null)",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching identity against watchlist for tenant {TenantId}", tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IdentityMatchResult? TestMatch(SignerIdentityInput identity, WatchedIdentity entry)
|
||||
{
|
||||
if (!entry.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matchedFields = MatchedFields.None;
|
||||
var matchScore = 0;
|
||||
|
||||
// Check issuer match
|
||||
if (!string.IsNullOrWhiteSpace(entry.Issuer))
|
||||
{
|
||||
var pattern = _patternCompiler.Compile(entry.Issuer, entry.MatchMode);
|
||||
if (pattern.IsMatch(identity.Issuer))
|
||||
{
|
||||
matchedFields |= MatchedFields.Issuer;
|
||||
matchScore += CalculateFieldScore(entry.MatchMode, entry.Issuer);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If issuer pattern is specified but doesn't match, this entry doesn't match
|
||||
// unless we match on other fields
|
||||
}
|
||||
}
|
||||
|
||||
// Check SAN match
|
||||
if (!string.IsNullOrWhiteSpace(entry.SubjectAlternativeName))
|
||||
{
|
||||
var pattern = _patternCompiler.Compile(entry.SubjectAlternativeName, entry.MatchMode);
|
||||
if (pattern.IsMatch(identity.SubjectAlternativeName))
|
||||
{
|
||||
matchedFields |= MatchedFields.SubjectAlternativeName;
|
||||
matchScore += CalculateFieldScore(entry.MatchMode, entry.SubjectAlternativeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check KeyId match
|
||||
if (!string.IsNullOrWhiteSpace(entry.KeyId))
|
||||
{
|
||||
var pattern = _patternCompiler.Compile(entry.KeyId, entry.MatchMode);
|
||||
if (pattern.IsMatch(identity.KeyId))
|
||||
{
|
||||
matchedFields |= MatchedFields.KeyId;
|
||||
matchScore += CalculateFieldScore(entry.MatchMode, entry.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we have a match
|
||||
// An entry matches if ALL specified patterns match
|
||||
var requiredMatches = GetRequiredMatches(entry);
|
||||
if ((matchedFields & requiredMatches) != requiredMatches)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// At least one field must have matched
|
||||
if (matchedFields == MatchedFields.None)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IdentityMatchResult
|
||||
{
|
||||
WatchlistEntry = entry,
|
||||
Fields = matchedFields,
|
||||
MatchedValues = new MatchedIdentityValues
|
||||
{
|
||||
Issuer = identity.Issuer,
|
||||
SubjectAlternativeName = identity.SubjectAlternativeName,
|
||||
KeyId = identity.KeyId
|
||||
},
|
||||
MatchScore = matchScore,
|
||||
MatchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which fields are required for a match based on what's specified in the entry.
|
||||
/// </summary>
|
||||
private static MatchedFields GetRequiredMatches(WatchedIdentity entry)
|
||||
{
|
||||
var required = MatchedFields.None;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Issuer))
|
||||
{
|
||||
required |= MatchedFields.Issuer;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.SubjectAlternativeName))
|
||||
{
|
||||
required |= MatchedFields.SubjectAlternativeName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.KeyId))
|
||||
{
|
||||
required |= MatchedFields.KeyId;
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a match score based on specificity.
|
||||
/// Exact matches score higher than wildcards.
|
||||
/// </summary>
|
||||
private static int CalculateFieldScore(WatchlistMatchMode mode, string pattern)
|
||||
{
|
||||
var baseScore = mode switch
|
||||
{
|
||||
WatchlistMatchMode.Exact => 100,
|
||||
WatchlistMatchMode.Prefix => 75,
|
||||
WatchlistMatchMode.Glob => 50,
|
||||
WatchlistMatchMode.Regex => 25,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
// Longer patterns are more specific
|
||||
var lengthBonus = Math.Min(pattern.Length, 50);
|
||||
|
||||
return baseScore + lengthBonus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatternCompiler.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-003
|
||||
// Description: Compiles patterns from various match modes into executable matchers.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Matching;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles patterns into executable matchers with caching for performance.
|
||||
/// </summary>
|
||||
public sealed class PatternCompiler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CompiledPattern> _cache = new();
|
||||
private readonly int _maxCacheSize;
|
||||
private readonly TimeSpan _regexTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PatternCompiler with the specified cache size and regex timeout.
|
||||
/// </summary>
|
||||
/// <param name="maxCacheSize">Maximum number of compiled patterns to cache. Default: 1000.</param>
|
||||
/// <param name="regexTimeout">Timeout for regex matching operations. Default: 100ms.</param>
|
||||
public PatternCompiler(int maxCacheSize = 1000, TimeSpan? regexTimeout = null)
|
||||
{
|
||||
_maxCacheSize = maxCacheSize;
|
||||
_regexTimeout = regexTimeout ?? TimeSpan.FromMilliseconds(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a pattern for the specified match mode.
|
||||
/// Results are cached for performance.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern to compile.</param>
|
||||
/// <param name="mode">The matching mode.</param>
|
||||
/// <returns>A compiled pattern that can be used for matching.</returns>
|
||||
public CompiledPattern Compile(string pattern, WatchlistMatchMode mode)
|
||||
{
|
||||
var cacheKey = $"{mode}:{pattern}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var compiled = CompileInternal(pattern, mode);
|
||||
|
||||
// Simple cache eviction: if we're at capacity, don't add more
|
||||
// A production system might use LRU eviction
|
||||
if (_cache.Count < _maxCacheSize)
|
||||
{
|
||||
_cache.TryAdd(cacheKey, compiled);
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a pattern for the specified match mode without caching.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern to validate.</param>
|
||||
/// <param name="mode">The matching mode.</param>
|
||||
/// <returns>Validation result indicating success or failure with error message.</returns>
|
||||
public PatternValidationResult Validate(string pattern, WatchlistMatchMode mode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
{
|
||||
return PatternValidationResult.Success();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var compiled = CompileInternal(pattern, mode);
|
||||
|
||||
// For regex mode, also test execution to catch catastrophic backtracking
|
||||
if (mode == WatchlistMatchMode.Regex)
|
||||
{
|
||||
compiled.IsMatch("test-sample-string-for-validation-purposes");
|
||||
}
|
||||
|
||||
return PatternValidationResult.Success();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return PatternValidationResult.Failure($"Invalid pattern: {ex.Message}");
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return PatternValidationResult.Failure("Pattern is too complex and may cause performance issues.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the pattern cache.
|
||||
/// </summary>
|
||||
public void ClearCache() => _cache.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current number of cached patterns.
|
||||
/// </summary>
|
||||
public int CacheCount => _cache.Count;
|
||||
|
||||
private CompiledPattern CompileInternal(string pattern, WatchlistMatchMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
WatchlistMatchMode.Exact => new ExactPattern(pattern),
|
||||
WatchlistMatchMode.Prefix => new PrefixPattern(pattern),
|
||||
WatchlistMatchMode.Glob => new GlobPattern(pattern, _regexTimeout),
|
||||
WatchlistMatchMode.Regex => new RegexPattern(pattern, _regexTimeout),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown match mode")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pattern validation.
|
||||
/// </summary>
|
||||
public sealed record PatternValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the pattern is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if validation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static PatternValidationResult Success() => new() { IsValid = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static PatternValidationResult Failure(string message) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for compiled patterns.
|
||||
/// </summary>
|
||||
public abstract class CompiledPattern
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests if the input string matches this pattern.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to test.</param>
|
||||
/// <returns>True if the input matches the pattern.</returns>
|
||||
public abstract bool IsMatch(string? input);
|
||||
|
||||
/// <summary>
|
||||
/// The original pattern string.
|
||||
/// </summary>
|
||||
public abstract string Pattern { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The match mode for this pattern.
|
||||
/// </summary>
|
||||
public abstract WatchlistMatchMode Mode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact (case-insensitive) pattern matcher.
|
||||
/// </summary>
|
||||
internal sealed class ExactPattern : CompiledPattern
|
||||
{
|
||||
private readonly string _pattern;
|
||||
|
||||
public ExactPattern(string pattern)
|
||||
{
|
||||
_pattern = pattern;
|
||||
}
|
||||
|
||||
public override string Pattern => _pattern;
|
||||
public override WatchlistMatchMode Mode => WatchlistMatchMode.Exact;
|
||||
|
||||
public override bool IsMatch(string? input)
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
return string.IsNullOrEmpty(_pattern);
|
||||
}
|
||||
|
||||
return string.Equals(input, _pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefix (case-insensitive) pattern matcher.
|
||||
/// </summary>
|
||||
internal sealed class PrefixPattern : CompiledPattern
|
||||
{
|
||||
private readonly string _pattern;
|
||||
|
||||
public PrefixPattern(string pattern)
|
||||
{
|
||||
_pattern = pattern;
|
||||
}
|
||||
|
||||
public override string Pattern => _pattern;
|
||||
public override WatchlistMatchMode Mode => WatchlistMatchMode.Prefix;
|
||||
|
||||
public override bool IsMatch(string? input)
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
return string.IsNullOrEmpty(_pattern);
|
||||
}
|
||||
|
||||
return input.StartsWith(_pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Glob pattern matcher (converts to regex).
|
||||
/// </summary>
|
||||
internal sealed class GlobPattern : CompiledPattern
|
||||
{
|
||||
private readonly string _pattern;
|
||||
private readonly Regex _regex;
|
||||
|
||||
public GlobPattern(string pattern, TimeSpan timeout)
|
||||
{
|
||||
_pattern = pattern;
|
||||
_regex = new Regex(
|
||||
GlobToRegex(pattern),
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled,
|
||||
timeout);
|
||||
}
|
||||
|
||||
public override string Pattern => _pattern;
|
||||
public override WatchlistMatchMode Mode => WatchlistMatchMode.Glob;
|
||||
|
||||
public override bool IsMatch(string? input)
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
return string.IsNullOrEmpty(_pattern);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return _regex.IsMatch(input);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GlobToRegex(string glob)
|
||||
{
|
||||
var regex = new System.Text.StringBuilder();
|
||||
regex.Append('^');
|
||||
|
||||
foreach (var c in glob)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '*':
|
||||
regex.Append(".*");
|
||||
break;
|
||||
case '?':
|
||||
regex.Append('.');
|
||||
break;
|
||||
case '.':
|
||||
case '(':
|
||||
case ')':
|
||||
case '[':
|
||||
case ']':
|
||||
case '{':
|
||||
case '}':
|
||||
case '^':
|
||||
case '$':
|
||||
case '|':
|
||||
case '\\':
|
||||
case '+':
|
||||
regex.Append('\\');
|
||||
regex.Append(c);
|
||||
break;
|
||||
default:
|
||||
regex.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
regex.Append('$');
|
||||
return regex.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regular expression pattern matcher.
|
||||
/// </summary>
|
||||
internal sealed class RegexPattern : CompiledPattern
|
||||
{
|
||||
private readonly string _pattern;
|
||||
private readonly Regex _regex;
|
||||
|
||||
public RegexPattern(string pattern, TimeSpan timeout)
|
||||
{
|
||||
_pattern = pattern;
|
||||
_regex = new Regex(
|
||||
pattern,
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled,
|
||||
timeout);
|
||||
}
|
||||
|
||||
public override string Pattern => _pattern;
|
||||
public override WatchlistMatchMode Mode => WatchlistMatchMode.Regex;
|
||||
|
||||
public override bool IsMatch(string? input)
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return _regex.IsMatch(input);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityAlertSeverity.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-001
|
||||
// Description: Defines severity levels for identity alerts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the severity level for alerts generated by watchlist matches.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum IdentityAlertSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Informational alert. Use for routine monitoring or expected activity.
|
||||
/// </summary>
|
||||
Info,
|
||||
|
||||
/// <summary>
|
||||
/// Warning alert. Default severity. Use for unexpected but not critical activity.
|
||||
/// </summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>
|
||||
/// Critical alert. Use for potential security incidents requiring immediate attention.
|
||||
/// </summary>
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityMatchResult.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-001
|
||||
// Description: Represents the result of matching an identity against a watchlist entry.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a successful match between an incoming identity and a watchlist entry.
|
||||
/// </summary>
|
||||
public sealed record IdentityMatchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The watchlist entry that matched.
|
||||
/// </summary>
|
||||
public required WatchedIdentity WatchlistEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which identity fields matched.
|
||||
/// </summary>
|
||||
public required MatchedFields Fields { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity values that triggered the match.
|
||||
/// </summary>
|
||||
public required MatchedIdentityValues MatchedValues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The match score (higher = more specific match).
|
||||
/// Used for prioritizing when multiple entries match.
|
||||
/// </summary>
|
||||
public int MatchScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the match was evaluated.
|
||||
/// </summary>
|
||||
public DateTimeOffset MatchedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags indicating which identity fields matched.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MatchedFields
|
||||
{
|
||||
/// <summary>No fields matched.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Issuer field matched.</summary>
|
||||
Issuer = 1,
|
||||
|
||||
/// <summary>Subject Alternative Name field matched.</summary>
|
||||
SubjectAlternativeName = 2,
|
||||
|
||||
/// <summary>Key ID field matched.</summary>
|
||||
KeyId = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The actual identity values that triggered a match.
|
||||
/// </summary>
|
||||
public sealed record MatchedIdentityValues
|
||||
{
|
||||
/// <summary>
|
||||
/// The issuer value from the incoming identity.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SAN value from the incoming identity.
|
||||
/// </summary>
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID from the incoming identity.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes a SHA-256 hash of the identity values for deduplication.
|
||||
/// </summary>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var combined = $"{Issuer ?? ""}|{SubjectAlternativeName ?? ""}|{KeyId ?? ""}";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(combined);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an identity to be matched against watchlist entries.
|
||||
/// </summary>
|
||||
public sealed record SignerIdentityInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC issuer URL.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The certificate Subject Alternative Name.
|
||||
/// </summary>
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key identifier for keyful signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signing mode (keyless, kms, hsm, fido2).
|
||||
/// </summary>
|
||||
public string? Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SignerIdentityInput from the Attestor's SignerIdentityDescriptor.
|
||||
/// </summary>
|
||||
public static SignerIdentityInput FromDescriptor(string? mode, string? issuer, string? san, string? keyId) => new()
|
||||
{
|
||||
Mode = mode,
|
||||
Issuer = issuer,
|
||||
SubjectAlternativeName = san,
|
||||
KeyId = keyId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchedIdentity.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-001
|
||||
// Description: Core domain model for identity watchlist entries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a watchlist entry for monitoring signing identity appearances in transparency logs.
|
||||
/// </summary>
|
||||
public sealed record WatchedIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this watchlist entry.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Tenant that owns this watchlist entry.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Visibility scope of this entry.
|
||||
/// Default: Tenant (visible only to owning tenant).
|
||||
/// </summary>
|
||||
public WatchlistScope Scope { get; init; } = WatchlistScope.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name for this watchlist entry.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(256, MinimumLength = 1)]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description explaining why this identity is being watched.
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OIDC issuer URL to match against.
|
||||
/// Example: "https://token.actions.githubusercontent.com"
|
||||
/// At least one of Issuer, SubjectAlternativeName, or KeyId must be specified.
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate Subject Alternative Name (SAN) pattern to match.
|
||||
/// Can be an email, URI, or DNS name depending on the signing identity type.
|
||||
/// Example: "repo:org/repo:ref:refs/heads/main" or "*@example.com"
|
||||
/// At least one of Issuer, SubjectAlternativeName, or KeyId must be specified.
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for keyful signing.
|
||||
/// At least one of Issuer, SubjectAlternativeName, or KeyId must be specified.
|
||||
/// </summary>
|
||||
[StringLength(512)]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern matching mode for identity fields.
|
||||
/// Default: Exact (case-insensitive equality).
|
||||
/// </summary>
|
||||
public WatchlistMatchMode MatchMode { get; init; } = WatchlistMatchMode.Exact;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for alerts generated by this watchlist entry.
|
||||
/// Default: Warning.
|
||||
/// </summary>
|
||||
public IdentityAlertSeverity Severity { get; init; } = IdentityAlertSeverity.Warning;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this watchlist entry is actively monitored.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of notification channel IDs to route alerts to.
|
||||
/// When null or empty, uses the tenant's default attestation alert channels.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ChannelOverrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deduplication window in minutes. Alerts for the same identity within this
|
||||
/// window are suppressed and counted. Default: 60 minutes.
|
||||
/// </summary>
|
||||
[Range(1, 10080)] // 1 minute to 7 days
|
||||
public int SuppressDuplicatesMinutes { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Searchable tags for categorization.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this entry was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this entry was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the user/service that created this entry.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the user/service that last updated this entry.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the watchlist entry has at least one identity field specified
|
||||
/// and that patterns are valid for the selected match mode.
|
||||
/// </summary>
|
||||
/// <returns>A validation result indicating success or failure with error messages.</returns>
|
||||
public WatchlistValidationResult Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate at least one identity field is specified
|
||||
if (string.IsNullOrWhiteSpace(Issuer) &&
|
||||
string.IsNullOrWhiteSpace(SubjectAlternativeName) &&
|
||||
string.IsNullOrWhiteSpace(KeyId))
|
||||
{
|
||||
errors.Add("At least one identity field (Issuer, SubjectAlternativeName, or KeyId) must be specified.");
|
||||
}
|
||||
|
||||
// Validate display name
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
errors.Add("DisplayName is required.");
|
||||
}
|
||||
|
||||
// Validate tenant ID
|
||||
if (string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
errors.Add("TenantId is required.");
|
||||
}
|
||||
|
||||
// Validate regex patterns if match mode is Regex
|
||||
if (MatchMode == WatchlistMatchMode.Regex)
|
||||
{
|
||||
ValidateRegexPattern(Issuer, "Issuer", errors);
|
||||
ValidateRegexPattern(SubjectAlternativeName, "SubjectAlternativeName", errors);
|
||||
ValidateRegexPattern(KeyId, "KeyId", errors);
|
||||
}
|
||||
|
||||
// Validate glob patterns don't exceed length limits
|
||||
if (MatchMode == WatchlistMatchMode.Glob)
|
||||
{
|
||||
if (Issuer?.Length > 256)
|
||||
{
|
||||
errors.Add("Glob pattern for Issuer must not exceed 256 characters.");
|
||||
}
|
||||
if (SubjectAlternativeName?.Length > 256)
|
||||
{
|
||||
errors.Add("Glob pattern for SubjectAlternativeName must not exceed 256 characters.");
|
||||
}
|
||||
if (KeyId?.Length > 256)
|
||||
{
|
||||
errors.Add("Glob pattern for KeyId must not exceed 256 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate suppress duplicates is positive
|
||||
if (SuppressDuplicatesMinutes < 1)
|
||||
{
|
||||
errors.Add("SuppressDuplicatesMinutes must be at least 1.");
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? WatchlistValidationResult.Success()
|
||||
: WatchlistValidationResult.Failure(errors);
|
||||
}
|
||||
|
||||
private static void ValidateRegexPattern(string? pattern, string fieldName, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Test compile the regex with timeout to detect catastrophic backtracking patterns
|
||||
var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// Test against a sample string to verify it doesn't hang
|
||||
regex.IsMatch("test-sample-string-for-validation");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern for {fieldName}: {ex.Message}");
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
errors.Add($"Regex pattern for {fieldName} is too complex and may cause performance issues.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this entry with updated timestamps.
|
||||
/// </summary>
|
||||
public WatchedIdentity WithUpdated(string updatedBy) => this with
|
||||
{
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a watchlist entry.
|
||||
/// </summary>
|
||||
public sealed record WatchlistValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of validation errors if validation failed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static WatchlistValidationResult Success() => new() { IsValid = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result with the specified errors.
|
||||
/// </summary>
|
||||
public static WatchlistValidationResult Failure(IEnumerable<string> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors.ToList()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistMatchMode.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-001
|
||||
// Description: Defines pattern matching modes for identity matching.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines how identity patterns are matched against incoming entries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum WatchlistMatchMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Case-insensitive exact string equality.
|
||||
/// This is the default and safest matching mode.
|
||||
/// </summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive prefix match (starts-with).
|
||||
/// Example: "https://accounts.google.com/" matches any Google OIDC issuer.
|
||||
/// </summary>
|
||||
Prefix,
|
||||
|
||||
/// <summary>
|
||||
/// Glob pattern matching with * (any chars) and ? (single char).
|
||||
/// Example: "*@example.com" matches "alice@example.com".
|
||||
/// </summary>
|
||||
Glob,
|
||||
|
||||
/// <summary>
|
||||
/// Full regular expression matching with safety constraints.
|
||||
/// Patterns are validated on creation and have execution timeout (100ms).
|
||||
/// Use with caution due to potential performance impact.
|
||||
/// </summary>
|
||||
Regex
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistScope.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-001
|
||||
// Description: Defines visibility scope levels for watchlist entries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the visibility scope of a watchlist entry.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum WatchlistScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry visible only to the owning tenant.
|
||||
/// This is the default and most restrictive scope.
|
||||
/// </summary>
|
||||
Tenant,
|
||||
|
||||
/// <summary>
|
||||
/// Entry visible to all tenants. Requires admin privileges to create.
|
||||
/// Use for organization-wide identity monitoring.
|
||||
/// </summary>
|
||||
Global,
|
||||
|
||||
/// <summary>
|
||||
/// System-managed entries, read-only for all tenants.
|
||||
/// Used for bootstrap and platform-level monitoring.
|
||||
/// </summary>
|
||||
System
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IIdentityAlertPublisher.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-005
|
||||
// Description: Interface for publishing identity alert events to notification system.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Watchlist.Events;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes identity alert events to the notification system.
|
||||
/// </summary>
|
||||
public interface IIdentityAlertPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an identity alert event.
|
||||
/// </summary>
|
||||
/// <param name="alertEvent">The alert event to publish.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task PublishAsync(IdentityAlertEvent alertEvent, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation that discards events. Used when notification system is not configured.
|
||||
/// </summary>
|
||||
public sealed class NullIdentityAlertPublisher : IIdentityAlertPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly NullIdentityAlertPublisher Instance = new();
|
||||
|
||||
private NullIdentityAlertPublisher() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(IdentityAlertEvent alertEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation that records events for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdentityAlertPublisher : IIdentityAlertPublisher
|
||||
{
|
||||
private readonly List<IdentityAlertEvent> _events = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(IdentityAlertEvent alertEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.Add(alertEvent);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all published events.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IdentityAlertEvent> GetEvents()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded events.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityMonitorBackgroundService.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-005
|
||||
// Description: Background service that monitors new Attestor entries for watchlist matches.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that monitors new Attestor entries for identity watchlist matches.
|
||||
/// Supports both change-feed (streaming) and polling modes.
|
||||
/// </summary>
|
||||
public sealed class IdentityMonitorBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IdentityMonitorService _monitorService;
|
||||
private readonly IAttestorEntrySource _entrySource;
|
||||
private readonly WatchlistMonitorOptions _options;
|
||||
private readonly ILogger<IdentityMonitorBackgroundService> _logger;
|
||||
|
||||
// Rate limiting
|
||||
private readonly SemaphoreSlim _rateLimiter;
|
||||
private readonly Timer? _rateLimiterRefill;
|
||||
|
||||
public IdentityMonitorBackgroundService(
|
||||
IdentityMonitorService monitorService,
|
||||
IAttestorEntrySource entrySource,
|
||||
IOptions<WatchlistMonitorOptions> options,
|
||||
ILogger<IdentityMonitorBackgroundService> logger)
|
||||
{
|
||||
_monitorService = monitorService ?? throw new ArgumentNullException(nameof(monitorService));
|
||||
_entrySource = entrySource ?? throw new ArgumentNullException(nameof(entrySource));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Initialize rate limiter
|
||||
_rateLimiter = new SemaphoreSlim(_options.MaxEventsPerSecond, _options.MaxEventsPerSecond);
|
||||
|
||||
// Refill rate limiter every second
|
||||
_rateLimiterRefill = new Timer(
|
||||
_ => RefillRateLimiter(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Identity watchlist monitoring is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Identity watchlist monitor starting. Mode: {Mode}, Max events/sec: {MaxEventsPerSecond}",
|
||||
_options.Mode,
|
||||
_options.MaxEventsPerSecond);
|
||||
|
||||
// Initial delay
|
||||
await Task.Delay(_options.InitialDelay, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (_options.Mode == WatchlistMonitorMode.ChangeFeed)
|
||||
{
|
||||
await RunChangeFeedModeAsync(stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunPollingModeAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Identity watchlist monitor stopping due to cancellation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Identity watchlist monitor failed with unexpected error");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunChangeFeedModeAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Starting change-feed mode monitoring");
|
||||
|
||||
await foreach (var entry in _entrySource.StreamEntriesAsync(stoppingToken))
|
||||
{
|
||||
await ProcessEntryWithRateLimitAsync(entry, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunPollingModeAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Starting polling mode monitoring with interval {Interval}", _options.PollingInterval);
|
||||
|
||||
DateTimeOffset lastPolledAt = DateTimeOffset.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await _entrySource.GetEntriesSinceAsync(lastPolledAt, stoppingToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await ProcessEntryWithRateLimitAsync(entry, stoppingToken);
|
||||
}
|
||||
|
||||
lastPolledAt = now;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during polling cycle, will retry");
|
||||
}
|
||||
|
||||
await Task.Delay(_options.PollingInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessEntryWithRateLimitAsync(AttestorEntryInfo entry, CancellationToken stoppingToken)
|
||||
{
|
||||
// Apply rate limiting
|
||||
await _rateLimiter.WaitAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await _monitorService.ProcessEntryAsync(entry, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process entry {RekorUuid}", entry.RekorUuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefillRateLimiter()
|
||||
{
|
||||
// Release permits up to max
|
||||
var toRelease = _options.MaxEventsPerSecond - _rateLimiter.CurrentCount;
|
||||
if (toRelease > 0)
|
||||
{
|
||||
_rateLimiter.Release(toRelease);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_rateLimiterRefill?.Dispose();
|
||||
_rateLimiter.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of Attestor entries for monitoring.
|
||||
/// </summary>
|
||||
public interface IAttestorEntrySource
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams new entries in real-time (change-feed mode).
|
||||
/// </summary>
|
||||
IAsyncEnumerable<AttestorEntryInfo> StreamEntriesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries created since the specified time (polling mode).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestorEntryInfo>> GetEntriesSinceAsync(
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation for when entry source is not configured.
|
||||
/// </summary>
|
||||
public sealed class NullAttestorEntrySource : IAttestorEntrySource
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly NullAttestorEntrySource Instance = new();
|
||||
|
||||
private NullAttestorEntrySource() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AttestorEntryInfo> StreamEntriesAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Never yield any entries
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AttestorEntryInfo>> GetEntriesSinceAsync(
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntryInfo>>([]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory entry source for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAttestorEntrySource : IAttestorEntrySource
|
||||
{
|
||||
private readonly Channel<AttestorEntryInfo> _channel = Channel.CreateUnbounded<AttestorEntryInfo>();
|
||||
private readonly List<AttestorEntryInfo> _entries = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds an entry to the source.
|
||||
/// </summary>
|
||||
public void AddEntry(AttestorEntryInfo entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
}
|
||||
_channel.Writer.TryWrite(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AttestorEntryInfo> StreamEntriesAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await foreach (var entry in _channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AttestorEntryInfo>> GetEntriesSinceAsync(
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _entries
|
||||
.Where(e => e.IntegratedTimeUtc > since)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntryInfo>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityMonitorService.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-005
|
||||
// Description: Core service for processing entries and emitting identity alerts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Watchlist.Events;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Core service that processes Attestor entries and emits identity alerts.
|
||||
/// </summary>
|
||||
public sealed class IdentityMonitorService
|
||||
{
|
||||
private readonly IIdentityMatcher _matcher;
|
||||
private readonly IAlertDedupRepository _dedupRepository;
|
||||
private readonly IIdentityAlertPublisher _alertPublisher;
|
||||
private readonly WatchlistMonitorOptions _options;
|
||||
private readonly ILogger<IdentityMonitorService> _logger;
|
||||
|
||||
// Metrics
|
||||
private static readonly Meter Meter = new("StellaOps.Attestor.Watchlist", "1.0.0");
|
||||
|
||||
private static readonly Counter<long> EntriesScannedTotal = Meter.CreateCounter<long>(
|
||||
"attestor.watchlist.entries_scanned_total",
|
||||
description: "Total entries processed by identity monitor");
|
||||
|
||||
private static readonly Counter<long> MatchesTotal = Meter.CreateCounter<long>(
|
||||
"attestor.watchlist.matches_total",
|
||||
description: "Total watchlist pattern matches");
|
||||
|
||||
private static readonly Counter<long> AlertsEmittedTotal = Meter.CreateCounter<long>(
|
||||
"attestor.watchlist.alerts_emitted_total",
|
||||
description: "Total alerts emitted to notification system");
|
||||
|
||||
private static readonly Counter<long> AlertsSuppressedTotal = Meter.CreateCounter<long>(
|
||||
"attestor.watchlist.alerts_suppressed_total",
|
||||
description: "Total alerts suppressed by deduplication");
|
||||
|
||||
private static readonly Histogram<double> ScanLatencySeconds = Meter.CreateHistogram<double>(
|
||||
"attestor.watchlist.scan_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Per-entry scan duration");
|
||||
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Attestor.Watchlist");
|
||||
|
||||
public IdentityMonitorService(
|
||||
IIdentityMatcher matcher,
|
||||
IAlertDedupRepository dedupRepository,
|
||||
IIdentityAlertPublisher alertPublisher,
|
||||
IOptions<WatchlistMonitorOptions> options,
|
||||
ILogger<IdentityMonitorService> logger)
|
||||
{
|
||||
_matcher = matcher ?? throw new ArgumentNullException(nameof(matcher));
|
||||
_dedupRepository = dedupRepository ?? throw new ArgumentNullException(nameof(dedupRepository));
|
||||
_alertPublisher = alertPublisher ?? throw new ArgumentNullException(nameof(alertPublisher));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a new Attestor entry and emits alerts for any watchlist matches.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to process.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of alerts emitted.</returns>
|
||||
public async Task<int> ProcessEntryAsync(AttestorEntryInfo entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("IdentityMonitorService.ProcessEntry");
|
||||
activity?.SetTag("rekor_uuid", entry.RekorUuid);
|
||||
activity?.SetTag("tenant_id", entry.TenantId);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
EntriesScannedTotal.Add(1);
|
||||
|
||||
// Build identity input from entry
|
||||
var identityInput = SignerIdentityInput.FromDescriptor(
|
||||
entry.SignerMode,
|
||||
entry.SignerIssuer,
|
||||
entry.SignerSan,
|
||||
entry.SignerKeyId);
|
||||
|
||||
// Find matches
|
||||
var matches = await _matcher.MatchAsync(identityInput, entry.TenantId, cancellationToken);
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
MatchesTotal.Add(matches.Count);
|
||||
activity?.SetTag("matches_count", matches.Count);
|
||||
|
||||
var alertsEmitted = 0;
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var alertResult = await ProcessMatchAsync(match, entry, cancellationToken);
|
||||
if (alertResult.AlertSent)
|
||||
{
|
||||
alertsEmitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return alertsEmitted;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
ScanLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds);
|
||||
activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single match, applying deduplication and emitting alert if needed.
|
||||
/// </summary>
|
||||
private async Task<(bool AlertSent, int SuppressedCount)> ProcessMatchAsync(
|
||||
IdentityMatchResult match,
|
||||
AttestorEntryInfo entry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var identityHash = match.MatchedValues.ComputeHash();
|
||||
var dedupWindow = match.WatchlistEntry.SuppressDuplicatesMinutes;
|
||||
|
||||
// Check deduplication
|
||||
var dedupStatus = await _dedupRepository.CheckAndUpdateAsync(
|
||||
match.WatchlistEntry.Id,
|
||||
identityHash,
|
||||
dedupWindow,
|
||||
cancellationToken);
|
||||
|
||||
if (dedupStatus.ShouldSuppress)
|
||||
{
|
||||
AlertsSuppressedTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("severity", match.WatchlistEntry.Severity.ToString()));
|
||||
|
||||
_logger.LogDebug(
|
||||
"Suppressed alert for watchlist entry {EntryId} (identity hash: {IdentityHash}, suppressed count: {Count})",
|
||||
match.WatchlistEntry.Id,
|
||||
identityHash,
|
||||
dedupStatus.SuppressedCount);
|
||||
|
||||
return (false, dedupStatus.SuppressedCount);
|
||||
}
|
||||
|
||||
// Create and publish alert
|
||||
var alertEvent = IdentityAlertEvent.FromMatch(
|
||||
match,
|
||||
entry.RekorUuid,
|
||||
entry.LogIndex,
|
||||
entry.ArtifactSha256,
|
||||
entry.IntegratedTimeUtc,
|
||||
dedupStatus.SuppressedCount);
|
||||
|
||||
await _alertPublisher.PublishAsync(alertEvent, cancellationToken);
|
||||
|
||||
AlertsEmittedTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("severity", match.WatchlistEntry.Severity.ToString()));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted identity alert for watchlist entry '{EntryName}' (ID: {EntryId}) " +
|
||||
"triggered by Rekor entry {RekorUuid}. Severity: {Severity}. Previously suppressed: {SuppressedCount}",
|
||||
match.WatchlistEntry.DisplayName,
|
||||
match.WatchlistEntry.Id,
|
||||
entry.RekorUuid,
|
||||
match.WatchlistEntry.Severity,
|
||||
dedupStatus.SuppressedCount);
|
||||
|
||||
return (true, dedupStatus.SuppressedCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an Attestor entry needed for identity monitoring.
|
||||
/// </summary>
|
||||
public sealed record AttestorEntryInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor entry UUID.
|
||||
/// </summary>
|
||||
public required string RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact SHA-256 digest.
|
||||
/// </summary>
|
||||
public required string ArtifactSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index.
|
||||
/// </summary>
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when entry was integrated into Rekor.
|
||||
/// </summary>
|
||||
public required DateTimeOffset IntegratedTimeUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing mode (keyless, kms, hsm, fido2).
|
||||
/// </summary>
|
||||
public string? SignerMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OIDC issuer URL.
|
||||
/// </summary>
|
||||
public string? SignerIssuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate SAN.
|
||||
/// </summary>
|
||||
public string? SignerSan { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistMonitorOptions.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-005
|
||||
// Description: Configuration options for the identity monitoring service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the identity watchlist monitor.
|
||||
/// </summary>
|
||||
public sealed record WatchlistMonitorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Attestor:Watchlist";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the watchlist monitoring service is enabled.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Monitoring mode: ChangeFeed (real-time) or Polling (batch).
|
||||
/// Default: ChangeFeed for real-time monitoring.
|
||||
/// </summary>
|
||||
public WatchlistMonitorMode Mode { get; init; } = WatchlistMonitorMode.ChangeFeed;
|
||||
|
||||
/// <summary>
|
||||
/// Polling interval when Mode is Polling.
|
||||
/// Default: 5 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan PollingInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of alert events to emit per second (rate limiting).
|
||||
/// Default: 100.
|
||||
/// </summary>
|
||||
public int MaxEventsPerSecond { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Default deduplication window in minutes.
|
||||
/// Used when watchlist entry doesn't specify.
|
||||
/// Default: 60 minutes.
|
||||
/// </summary>
|
||||
public int DefaultDedupWindowMinutes { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for regex pattern matching in milliseconds.
|
||||
/// Default: 100ms.
|
||||
/// </summary>
|
||||
public int RegexTimeoutMs { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of watchlist entries per tenant.
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int MaxWatchlistEntriesPerTenant { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum size of the compiled pattern cache.
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int PatternCacheSize { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Initial delay before starting monitoring after service startup.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL channel name for LISTEN/NOTIFY.
|
||||
/// Default: "attestor_entries_inserted".
|
||||
/// </summary>
|
||||
public string NotifyChannelName { get; init; } = "attestor_entries_inserted";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monitoring mode for the identity watchlist service.
|
||||
/// </summary>
|
||||
public enum WatchlistMonitorMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Real-time monitoring using PostgreSQL LISTEN/NOTIFY.
|
||||
/// Recommended for connected environments.
|
||||
/// </summary>
|
||||
ChangeFeed,
|
||||
|
||||
/// <summary>
|
||||
/// Batch polling at regular intervals.
|
||||
/// Use for air-gapped or environments where LISTEN/NOTIFY is not available.
|
||||
/// </summary>
|
||||
Polling
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Description: Dependency injection registration for watchlist services.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Monitoring;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering watchlist services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds identity watchlist services with in-memory storage (for testing/development).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddWatchlistServicesInMemory(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Configuration
|
||||
services.Configure<WatchlistMonitorOptions>(
|
||||
configuration.GetSection(WatchlistMonitorOptions.SectionName));
|
||||
|
||||
// Storage
|
||||
services.AddSingleton<IWatchlistRepository, InMemoryWatchlistRepository>();
|
||||
services.AddSingleton<IAlertDedupRepository, InMemoryAlertDedupRepository>();
|
||||
|
||||
// Matching
|
||||
services.AddSingleton<PatternCompiler>(sp =>
|
||||
{
|
||||
var options = configuration.GetSection(WatchlistMonitorOptions.SectionName)
|
||||
.Get<WatchlistMonitorOptions>() ?? new WatchlistMonitorOptions();
|
||||
return new PatternCompiler(
|
||||
options.PatternCacheSize,
|
||||
TimeSpan.FromMilliseconds(options.RegexTimeoutMs));
|
||||
});
|
||||
services.AddSingleton<IIdentityMatcher, IdentityMatcher>();
|
||||
|
||||
// Monitoring
|
||||
services.AddSingleton<IIdentityAlertPublisher, NullIdentityAlertPublisher>();
|
||||
services.AddSingleton<IAttestorEntrySource, NullAttestorEntrySource>();
|
||||
services.AddSingleton<IdentityMonitorService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds identity watchlist services with PostgreSQL storage.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddWatchlistServicesPostgres(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string connectionString)
|
||||
{
|
||||
// Configuration
|
||||
services.Configure<WatchlistMonitorOptions>(
|
||||
configuration.GetSection(WatchlistMonitorOptions.SectionName));
|
||||
|
||||
// Storage
|
||||
services.AddSingleton<IWatchlistRepository>(sp =>
|
||||
new PostgresWatchlistRepository(
|
||||
connectionString,
|
||||
sp.GetRequiredService<Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresWatchlistRepository>>()));
|
||||
|
||||
services.AddSingleton<IAlertDedupRepository>(sp =>
|
||||
new PostgresAlertDedupRepository(
|
||||
connectionString,
|
||||
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresAlertDedupRepository>>()));
|
||||
|
||||
// Matching
|
||||
services.AddSingleton<PatternCompiler>(sp =>
|
||||
{
|
||||
var options = configuration.GetSection(WatchlistMonitorOptions.SectionName)
|
||||
.Get<WatchlistMonitorOptions>() ?? new WatchlistMonitorOptions();
|
||||
return new PatternCompiler(
|
||||
options.PatternCacheSize,
|
||||
TimeSpan.FromMilliseconds(options.RegexTimeoutMs));
|
||||
});
|
||||
services.AddSingleton<IIdentityMatcher, IdentityMatcher>();
|
||||
|
||||
// Monitoring
|
||||
services.AddSingleton<IdentityMonitorService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the identity monitor background service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddWatchlistMonitorBackgroundService(this IServiceCollection services)
|
||||
{
|
||||
services.AddHostedService<IdentityMonitorBackgroundService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Attestor.Watchlist</RootNamespace>
|
||||
<Description>Identity watchlist and monitoring for transparency log alerting.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,152 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IWatchlistRepository.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-004
|
||||
// Description: Repository interface for watchlist persistence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting and retrieving watchlist entries.
|
||||
/// </summary>
|
||||
public interface IWatchlistRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a watchlist entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The entry ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The entry if found, null otherwise.</returns>
|
||||
Task<WatchedIdentity?> GetAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists watchlist entries for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="includeGlobal">Whether to include global and system scope entries.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of watchlist entries.</returns>
|
||||
Task<IReadOnlyList<WatchedIdentity>> ListAsync(
|
||||
string tenantId,
|
||||
bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active (enabled) entries for matching.
|
||||
/// Includes tenant, global, and system scope entries.
|
||||
/// Results are cached for performance (refresh on write, 5-second staleness OK).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of active watchlist entries.</returns>
|
||||
Task<IReadOnlyList<WatchedIdentity>> GetActiveForMatchingAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a watchlist entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to upsert.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The persisted entry.</returns>
|
||||
Task<WatchedIdentity> UpsertAsync(WatchedIdentity entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a watchlist entry.
|
||||
/// </summary>
|
||||
/// <param name="id">The entry ID.</param>
|
||||
/// <param name="tenantId">The tenant ID (for authorization).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(Guid id, string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of watchlist entries for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The count of entries.</returns>
|
||||
Task<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for tracking alert deduplication.
|
||||
/// </summary>
|
||||
public interface IAlertDedupRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if an alert should be suppressed based on deduplication rules.
|
||||
/// </summary>
|
||||
/// <param name="watchlistId">The watchlist entry ID.</param>
|
||||
/// <param name="identityHash">SHA-256 hash of the identity values.</param>
|
||||
/// <param name="dedupWindowMinutes">The deduplication window in minutes.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dedup status including whether to suppress and count of suppressed alerts.</returns>
|
||||
Task<AlertDedupStatus> CheckAndUpdateAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
int dedupWindowMinutes,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of suppressed alerts within the current window.
|
||||
/// </summary>
|
||||
/// <param name="watchlistId">The watchlist entry ID.</param>
|
||||
/// <param name="identityHash">SHA-256 hash of the identity values.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Count of suppressed alerts.</returns>
|
||||
Task<int> GetSuppressedCountAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired dedup records.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of records cleaned up.</returns>
|
||||
Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of checking alert deduplication status.
|
||||
/// </summary>
|
||||
public sealed record AlertDedupStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the alert should be suppressed.
|
||||
/// </summary>
|
||||
public required bool ShouldSuppress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of alerts suppressed in the current window.
|
||||
/// </summary>
|
||||
public required int SuppressedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the current dedup window expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? WindowExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status indicating the alert should be sent.
|
||||
/// </summary>
|
||||
public static AlertDedupStatus Send(int previouslySuppressed = 0) => new()
|
||||
{
|
||||
ShouldSuppress = false,
|
||||
SuppressedCount = previouslySuppressed
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status indicating the alert should be suppressed.
|
||||
/// </summary>
|
||||
public static AlertDedupStatus Suppress(int count, DateTimeOffset expiresAt) => new()
|
||||
{
|
||||
ShouldSuppress = true,
|
||||
SuppressedCount = count,
|
||||
WindowExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryWatchlistRepository.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-004
|
||||
// Description: In-memory implementation for testing and development.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of watchlist repository for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryWatchlistRepository : IWatchlistRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, WatchedIdentity> _entries = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WatchedIdentity?> GetAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(id, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<WatchedIdentity>> ListAsync(
|
||||
string tenantId,
|
||||
bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _entries.Values
|
||||
.Where(e => e.TenantId == tenantId ||
|
||||
(includeGlobal && (e.Scope == WatchlistScope.Global || e.Scope == WatchlistScope.System)))
|
||||
.OrderBy(e => e.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WatchedIdentity>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<WatchedIdentity>> GetActiveForMatchingAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _entries.Values
|
||||
.Where(e => e.Enabled &&
|
||||
(e.TenantId == tenantId ||
|
||||
e.Scope == WatchlistScope.Global ||
|
||||
e.Scope == WatchlistScope.System))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WatchedIdentity>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WatchedIdentity> UpsertAsync(WatchedIdentity entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries[entry.Id] = entry;
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(Guid id, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_entries.TryGetValue(id, out var entry))
|
||||
{
|
||||
// Check tenant authorization (tenant can only delete their own or if they're admin for global)
|
||||
if (entry.TenantId == tenantId || entry.Scope != WatchlistScope.Tenant)
|
||||
{
|
||||
return Task.FromResult(_entries.TryRemove(id, out _));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = _entries.Values.Count(e => e.TenantId == tenantId);
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all entries. For testing only.
|
||||
/// </summary>
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entries. For testing only.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<WatchedIdentity> GetAll() => _entries.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of alert dedup repository for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAlertDedupRepository : IAlertDedupRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DedupRecord> _records = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AlertDedupStatus> CheckAndUpdateAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
int dedupWindowMinutes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{watchlistId}:{identityHash}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var windowEnd = now.AddMinutes(dedupWindowMinutes);
|
||||
|
||||
if (_records.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.WindowExpiresAt > now)
|
||||
{
|
||||
// Still in dedup window - suppress and increment count
|
||||
var updated = existing with
|
||||
{
|
||||
AlertCount = existing.AlertCount + 1,
|
||||
LastAlertAt = now
|
||||
};
|
||||
_records[key] = updated;
|
||||
|
||||
return Task.FromResult(AlertDedupStatus.Suppress(updated.AlertCount, existing.WindowExpiresAt));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window expired - start new window and return suppressed count
|
||||
var previousCount = existing.AlertCount;
|
||||
var newRecord = new DedupRecord
|
||||
{
|
||||
WatchlistId = watchlistId,
|
||||
IdentityHash = identityHash,
|
||||
LastAlertAt = now,
|
||||
WindowExpiresAt = windowEnd,
|
||||
AlertCount = 0
|
||||
};
|
||||
_records[key] = newRecord;
|
||||
|
||||
return Task.FromResult(AlertDedupStatus.Send(previousCount));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// First alert - create new record
|
||||
var newRecord = new DedupRecord
|
||||
{
|
||||
WatchlistId = watchlistId,
|
||||
IdentityHash = identityHash,
|
||||
LastAlertAt = now,
|
||||
WindowExpiresAt = windowEnd,
|
||||
AlertCount = 0
|
||||
};
|
||||
_records[key] = newRecord;
|
||||
|
||||
return Task.FromResult(AlertDedupStatus.Send());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> GetSuppressedCountAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{watchlistId}:{identityHash}";
|
||||
if (_records.TryGetValue(key, out var record))
|
||||
{
|
||||
return Task.FromResult(record.AlertCount);
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredKeys = _records
|
||||
.Where(kvp => kvp.Value.WindowExpiresAt < now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(expiredKeys.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all records. For testing only.
|
||||
/// </summary>
|
||||
public void Clear() => _records.Clear();
|
||||
|
||||
private sealed record DedupRecord
|
||||
{
|
||||
public required Guid WatchlistId { get; init; }
|
||||
public required string IdentityHash { get; init; }
|
||||
public required DateTimeOffset LastAlertAt { get; init; }
|
||||
public required DateTimeOffset WindowExpiresAt { get; init; }
|
||||
public required int AlertCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresWatchlistRepository.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-004
|
||||
// Description: PostgreSQL implementation of watchlist repository.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the watchlist repository with caching.
|
||||
/// </summary>
|
||||
public sealed class PostgresWatchlistRepository : IWatchlistRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PostgresWatchlistRepository> _logger;
|
||||
private readonly TimeSpan _cacheExpiration = TimeSpan.FromSeconds(5);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public PostgresWatchlistRepository(
|
||||
string connectionString,
|
||||
IMemoryCache cache,
|
||||
ILogger<PostgresWatchlistRepository> logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WatchedIdentity?> GetAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE id = @id";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapToEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WatchedIdentity>> ListAsync(
|
||||
string tenantId,
|
||||
bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
var sql = includeGlobal
|
||||
? @"SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE tenant_id = @tenant_id OR scope IN ('Global', 'System')
|
||||
ORDER BY display_name"
|
||||
: @"SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY display_name";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var results = new List<WatchedIdentity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapToEntry(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WatchedIdentity>> GetActiveForMatchingAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = $"watchlist:active:{tenantId}";
|
||||
|
||||
if (_cache.TryGetValue<IReadOnlyList<WatchedIdentity>>(cacheKey, out var cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE enabled = TRUE
|
||||
AND (tenant_id = @tenant_id OR scope IN ('Global', 'System'))";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var results = new List<WatchedIdentity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapToEntry(reader));
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, results, _cacheExpiration);
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WatchedIdentity> UpsertAsync(WatchedIdentity entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = @"
|
||||
INSERT INTO attestor.identity_watchlist (
|
||||
id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
) VALUES (
|
||||
@id, @tenant_id, @scope, @display_name, @description,
|
||||
@issuer, @subject_alternative_name, @key_id, @match_mode,
|
||||
@severity, @enabled, @channel_overrides::jsonb, @suppress_duplicates_minutes,
|
||||
@tags, @created_at, @updated_at, @created_by, @updated_by
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
issuer = EXCLUDED.issuer,
|
||||
subject_alternative_name = EXCLUDED.subject_alternative_name,
|
||||
key_id = EXCLUDED.key_id,
|
||||
match_mode = EXCLUDED.match_mode,
|
||||
severity = EXCLUDED.severity,
|
||||
enabled = EXCLUDED.enabled,
|
||||
channel_overrides = EXCLUDED.channel_overrides,
|
||||
suppress_duplicates_minutes = EXCLUDED.suppress_duplicates_minutes,
|
||||
tags = EXCLUDED.tags,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING id, created_at, updated_at";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", entry.Id);
|
||||
cmd.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||
cmd.Parameters.AddWithValue("scope", entry.Scope.ToString());
|
||||
cmd.Parameters.AddWithValue("display_name", entry.DisplayName);
|
||||
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("issuer", (object?)entry.Issuer ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("subject_alternative_name", (object?)entry.SubjectAlternativeName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("key_id", (object?)entry.KeyId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("match_mode", entry.MatchMode.ToString());
|
||||
cmd.Parameters.AddWithValue("severity", entry.Severity.ToString());
|
||||
cmd.Parameters.AddWithValue("enabled", entry.Enabled);
|
||||
cmd.Parameters.AddWithValue("channel_overrides",
|
||||
entry.ChannelOverrides is not null ? JsonSerializer.Serialize(entry.ChannelOverrides, JsonOptions) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("suppress_duplicates_minutes", entry.SuppressDuplicatesMinutes);
|
||||
cmd.Parameters.AddWithValue("tags", entry.Tags?.ToArray() ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("created_at", entry.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updated_at", entry.UpdatedAt);
|
||||
cmd.Parameters.AddWithValue("created_by", entry.CreatedBy);
|
||||
cmd.Parameters.AddWithValue("updated_by", entry.UpdatedBy);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
// Invalidate cache for affected tenant
|
||||
_cache.Remove($"watchlist:active:{entry.TenantId}");
|
||||
|
||||
return entry with
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
CreatedAt = reader.GetDateTime(1),
|
||||
UpdatedAt = reader.GetDateTime(2)
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Upsert failed to return entry ID");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(Guid id, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// Only allow deletion if tenant owns the entry or it's their tenant
|
||||
const string sql = @"
|
||||
DELETE FROM attestor.identity_watchlist
|
||||
WHERE id = @id AND (tenant_id = @tenant_id OR scope != 'Tenant')
|
||||
RETURNING tenant_id";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var deletedTenantId = reader.GetString(0);
|
||||
_cache.Remove($"watchlist:active:{deletedTenantId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = "SELECT COUNT(*) FROM attestor.identity_watchlist WHERE tenant_id = @tenant_id";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static WatchedIdentity MapToEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
var channelOverridesJson = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
var channelOverrides = channelOverridesJson is not null
|
||||
? JsonSerializer.Deserialize<List<string>>(channelOverridesJson, JsonOptions)
|
||||
: null;
|
||||
|
||||
var tagsArray = reader.IsDBNull(13) ? null : (string[])reader.GetValue(13);
|
||||
|
||||
return new WatchedIdentity
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Scope = Enum.Parse<WatchlistScope>(reader.GetString(2), ignoreCase: true),
|
||||
DisplayName = reader.GetString(3),
|
||||
Description = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Issuer = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
SubjectAlternativeName = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
KeyId = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
MatchMode = Enum.Parse<WatchlistMatchMode>(reader.GetString(8), ignoreCase: true),
|
||||
Severity = Enum.Parse<IdentityAlertSeverity>(reader.GetString(9), ignoreCase: true),
|
||||
Enabled = reader.GetBoolean(10),
|
||||
ChannelOverrides = channelOverrides,
|
||||
SuppressDuplicatesMinutes = reader.GetInt32(12),
|
||||
Tags = tagsArray?.ToList(),
|
||||
CreatedAt = reader.GetDateTime(14),
|
||||
UpdatedAt = reader.GetDateTime(15),
|
||||
CreatedBy = reader.GetString(16),
|
||||
UpdatedBy = reader.GetString(17)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the alert deduplication repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresAlertDedupRepository : IAlertDedupRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<PostgresAlertDedupRepository> _logger;
|
||||
|
||||
public PostgresAlertDedupRepository(string connectionString, ILogger<PostgresAlertDedupRepository> logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AlertDedupStatus> CheckAndUpdateAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
int dedupWindowMinutes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var windowStart = now.AddMinutes(-dedupWindowMinutes);
|
||||
|
||||
// Atomic upsert with window check
|
||||
const string sql = @"
|
||||
INSERT INTO attestor.identity_alert_dedup (watchlist_id, identity_hash, last_alert_at, alert_count)
|
||||
VALUES (@watchlist_id, @identity_hash, @now, 0)
|
||||
ON CONFLICT (watchlist_id, identity_hash) DO UPDATE SET
|
||||
last_alert_at = CASE
|
||||
WHEN attestor.identity_alert_dedup.last_alert_at < @window_start THEN @now
|
||||
ELSE attestor.identity_alert_dedup.last_alert_at
|
||||
END,
|
||||
alert_count = CASE
|
||||
WHEN attestor.identity_alert_dedup.last_alert_at < @window_start THEN 0
|
||||
ELSE attestor.identity_alert_dedup.alert_count + 1
|
||||
END
|
||||
RETURNING last_alert_at, alert_count,
|
||||
(last_alert_at >= @window_start AND last_alert_at != @now) AS should_suppress";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("watchlist_id", watchlistId);
|
||||
cmd.Parameters.AddWithValue("identity_hash", identityHash);
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
cmd.Parameters.AddWithValue("window_start", windowStart);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var lastAlertAt = reader.GetDateTime(0);
|
||||
var alertCount = reader.GetInt32(1);
|
||||
var shouldSuppress = reader.GetBoolean(2);
|
||||
|
||||
if (shouldSuppress)
|
||||
{
|
||||
var windowEnd = lastAlertAt.AddMinutes(dedupWindowMinutes);
|
||||
return AlertDedupStatus.Suppress(alertCount, windowEnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
return AlertDedupStatus.Send(alertCount);
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDedupStatus.Send();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetSuppressedCountAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = @"
|
||||
SELECT alert_count FROM attestor.identity_alert_dedup
|
||||
WHERE watchlist_id = @watchlist_id AND identity_hash = @identity_hash";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("watchlist_id", watchlistId);
|
||||
cmd.Parameters.AddWithValue("identity_hash", identityHash);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken);
|
||||
return result is not null ? Convert.ToInt32(result) : 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// Delete records older than 7 days
|
||||
const string sql = @"
|
||||
DELETE FROM attestor.identity_alert_dedup
|
||||
WHERE last_alert_at < NOW() - INTERVAL '7 days'";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user