tests fixes and some product advisories tunes ups

This commit is contained in:
master
2026-01-30 07:57:43 +02:00
parent 644887997c
commit 55744f6a39
345 changed files with 26290 additions and 2267 deletions

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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
{
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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 (&lt;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:&lt;64-hex-chars&gt;
/// </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:&lt;64-hex-chars&gt;
/// </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:&lt;64-hex-chars&gt;
/// </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";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}

View File

@@ -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
};
}

View File

@@ -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()
};
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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
};
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}