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

@@ -0,0 +1,200 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.dev/schemas/predicates/binary-micro-witness.v1.schema.json",
"$comment": "Compact binary-level patch verification witness for auditor portability.",
"title": "Binary Micro-Witness Predicate",
"description": "Compact DSSE predicate for binary-level patch verification, optimized for third-party audit and offline verification (<1KB target size).",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"binary",
"cve",
"verdict",
"confidence",
"evidence",
"tooling",
"computedAt"
],
"properties": {
"schemaVersion": {
"type": "string",
"const": "1.0.0",
"description": "Schema version (semver)."
},
"binary": {
"$ref": "#/$defs/BinaryRef",
"description": "Binary artifact being verified."
},
"cve": {
"$ref": "#/$defs/CveRef",
"description": "CVE or advisory being verified."
},
"verdict": {
"type": "string",
"enum": ["patched", "vulnerable", "inconclusive", "partial"],
"description": "Verification verdict."
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Overall confidence score (0.0-1.0)."
},
"evidence": {
"type": "array",
"items": {
"$ref": "#/$defs/FunctionEvidence"
},
"minItems": 1,
"maxItems": 10,
"description": "Compact function match evidence (top matches only, max 10)."
},
"deltaSigDigest": {
"type": "string",
"pattern": "^sha256:[a-fA-F0-9]{64}$",
"description": "Digest of full DeltaSig predicate for detailed analysis."
},
"sbomRef": {
"$ref": "#/$defs/SbomRef",
"description": "SBOM component reference."
},
"tooling": {
"$ref": "#/$defs/Tooling",
"description": "Tooling metadata for reproducibility."
},
"computedAt": {
"type": "string",
"format": "date-time",
"description": "When the verification was computed (RFC 3339)."
}
},
"$defs": {
"BinaryRef": {
"type": "object",
"additionalProperties": false,
"description": "Compact binary reference.",
"required": ["digest"],
"properties": {
"digest": {
"type": "string",
"pattern": "^sha256:[a-fA-F0-9]{64}$",
"description": "SHA-256 digest of the binary."
},
"purl": {
"type": "string",
"description": "Package URL (purl) if known."
},
"arch": {
"type": "string",
"description": "Target architecture (e.g., 'linux-amd64')."
},
"filename": {
"type": "string",
"description": "Filename or path (for display)."
}
}
},
"CveRef": {
"type": "object",
"additionalProperties": false,
"description": "CVE/advisory reference.",
"required": ["id"],
"properties": {
"id": {
"type": "string",
"pattern": "^(CVE-\\d{4}-\\d{4,}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|[A-Z]+-\\d+)$",
"description": "CVE or advisory identifier."
},
"advisory": {
"type": "string",
"format": "uri",
"description": "Optional advisory URL or upstream reference."
},
"patchCommit": {
"type": "string",
"pattern": "^[a-fA-F0-9]{7,40}$",
"description": "Upstream commit hash if known."
}
}
},
"FunctionEvidence": {
"type": "object",
"additionalProperties": false,
"description": "Compact function match evidence.",
"required": ["function", "state", "score", "method"],
"properties": {
"function": {
"type": "string",
"description": "Function/symbol name."
},
"state": {
"type": "string",
"enum": ["patched", "vulnerable", "modified", "unchanged", "unknown"],
"description": "Match state."
},
"score": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Match confidence score (0.0-1.0)."
},
"method": {
"type": "string",
"enum": ["semantic_ksg", "byte_exact", "cfg_structural", "ir_semantic", "chunk_rolling"],
"description": "Match method used."
},
"hash": {
"type": "string",
"description": "Function hash in analyzed binary."
}
}
},
"SbomRef": {
"type": "object",
"additionalProperties": false,
"description": "SBOM component reference.",
"properties": {
"sbomDigest": {
"type": "string",
"pattern": "^sha256:[a-fA-F0-9]{64}$",
"description": "SBOM document digest."
},
"bomRef": {
"type": "string",
"description": "Component bomRef within the SBOM."
},
"purl": {
"type": "string",
"description": "Component purl within the SBOM."
}
}
},
"Tooling": {
"type": "object",
"additionalProperties": false,
"description": "Tooling metadata for reproducibility.",
"required": ["binaryIndexVersion", "lifter", "matchAlgorithm"],
"properties": {
"binaryIndexVersion": {
"type": "string",
"description": "BinaryIndex version."
},
"lifter": {
"type": "string",
"enum": ["b2r2", "ghidra", "radare2"],
"description": "Lifter used."
},
"matchAlgorithm": {
"type": "string",
"enum": ["semantic_ksg", "byte_exact", "cfg_structural", "ir_semantic"],
"description": "Match algorithm."
},
"normalizationRecipe": {
"type": "string",
"description": "Normalization recipe ID (for reproducibility)."
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
-- -----------------------------------------------------------------------------
-- Migration: 20260129_001_create_identity_watchlist
-- Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
-- Task: WATCH-004
-- Description: Creates identity watchlist and alert deduplication tables.
-- -----------------------------------------------------------------------------
-- Watchlist entries table
CREATE TABLE IF NOT EXISTS attestor.identity_watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'Tenant',
display_name TEXT NOT NULL,
description TEXT,
-- Identity matching fields (at least one required)
issuer TEXT,
subject_alternative_name TEXT,
key_id TEXT,
match_mode TEXT NOT NULL DEFAULT 'Exact',
-- Alert configuration
severity TEXT NOT NULL DEFAULT 'Warning',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
channel_overrides JSONB,
suppress_duplicates_minutes INT NOT NULL DEFAULT 60,
-- Metadata
tags TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
-- Constraints
CONSTRAINT chk_at_least_one_identity CHECK (
issuer IS NOT NULL OR
subject_alternative_name IS NOT NULL OR
key_id IS NOT NULL
),
CONSTRAINT chk_scope_valid CHECK (scope IN ('Tenant', 'Global', 'System')),
CONSTRAINT chk_match_mode_valid CHECK (match_mode IN ('Exact', 'Prefix', 'Glob', 'Regex')),
CONSTRAINT chk_severity_valid CHECK (severity IN ('Info', 'Warning', 'Critical')),
CONSTRAINT chk_suppress_duplicates_positive CHECK (suppress_duplicates_minutes >= 1)
);
-- Performance indexes for active entry lookup
CREATE INDEX IF NOT EXISTS idx_watchlist_tenant_enabled
ON attestor.identity_watchlist(tenant_id)
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_watchlist_scope_enabled
ON attestor.identity_watchlist(scope)
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_watchlist_issuer
ON attestor.identity_watchlist(issuer)
WHERE enabled = TRUE AND issuer IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_watchlist_san
ON attestor.identity_watchlist(subject_alternative_name)
WHERE enabled = TRUE AND subject_alternative_name IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_watchlist_keyid
ON attestor.identity_watchlist(key_id)
WHERE enabled = TRUE AND key_id IS NOT NULL;
-- Alert deduplication table
CREATE TABLE IF NOT EXISTS attestor.identity_alert_dedup (
watchlist_id UUID NOT NULL,
identity_hash TEXT NOT NULL,
last_alert_at TIMESTAMPTZ NOT NULL,
alert_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (watchlist_id, identity_hash)
);
-- Index for cleanup
CREATE INDEX IF NOT EXISTS idx_alert_dedup_last_alert
ON attestor.identity_alert_dedup(last_alert_at);
-- Comment documentation
COMMENT ON TABLE attestor.identity_watchlist IS
'Watchlist entries for monitoring signing identity appearances in transparency logs.';
COMMENT ON COLUMN attestor.identity_watchlist.scope IS
'Visibility scope: Tenant (owning tenant only), Global (all tenants), System (read-only).';
COMMENT ON COLUMN attestor.identity_watchlist.match_mode IS
'Pattern matching mode: Exact, Prefix, Glob, or Regex.';
COMMENT ON COLUMN attestor.identity_watchlist.suppress_duplicates_minutes IS
'Deduplication window in minutes. Alerts for same identity within window are suppressed.';
COMMENT ON TABLE attestor.identity_alert_dedup IS
'Tracks alert deduplication state to prevent alert storms.';

View File

@@ -0,0 +1,414 @@
// -----------------------------------------------------------------------------
// PostgresWatchlistRepository.cs
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-004
// Description: PostgreSQL implementation of watchlist repository.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Attestor.Watchlist.Models;
using StellaOps.Attestor.Watchlist.Storage;
namespace StellaOps.Attestor.Infrastructure.Watchlist;
/// <summary>
/// PostgreSQL implementation of the watchlist repository with caching.
/// </summary>
public sealed class PostgresWatchlistRepository : IWatchlistRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresWatchlistRepository> _logger;
private readonly ConcurrentDictionary<string, CachedEntries> _cache = new();
private readonly TimeSpan _cacheTimeout = TimeSpan.FromSeconds(5);
public PostgresWatchlistRepository(
NpgsqlDataSource dataSource,
ILogger<PostgresWatchlistRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<WatchedIdentity?> GetAsync(Guid id, CancellationToken cancellationToken = default)
{
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 conn = await _dataSource.OpenConnectionAsync(cancellationToken);
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)
{
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 = @tenantId 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 = @tenantId
ORDER BY display_name
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
var entries = new List<WatchedIdentity>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
entries.Add(MapToEntry(reader));
}
return entries;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WatchedIdentity>> GetActiveForMatchingAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
// Check cache first
if (_cache.TryGetValue(tenantId, out var cached) &&
cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.Entries;
}
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 = @tenantId OR scope IN ('Global', 'System'))
ORDER BY id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
var entries = new List<WatchedIdentity>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
entries.Add(MapToEntry(reader));
}
// Update cache
_cache[tenantId] = new CachedEntries(entries, DateTimeOffset.UtcNow.Add(_cacheTimeout));
return entries;
}
/// <inheritdoc />
public async Task<WatchedIdentity> UpsertAsync(
WatchedIdentity entry,
CancellationToken cancellationToken = default)
{
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, @tenantId, @scope, @displayName, @description,
@issuer, @san, @keyId, @matchMode,
@severity, @enabled, @channelOverrides, @suppressMinutes,
@tags, @createdAt, @updatedAt, @createdBy, @updatedBy
)
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, 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
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", entry.Id);
cmd.Parameters.AddWithValue("tenantId", entry.TenantId);
cmd.Parameters.AddWithValue("scope", entry.Scope.ToString());
cmd.Parameters.AddWithValue("displayName", entry.DisplayName);
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("issuer", (object?)entry.Issuer ?? DBNull.Value);
cmd.Parameters.AddWithValue("san", (object?)entry.SubjectAlternativeName ?? DBNull.Value);
cmd.Parameters.AddWithValue("keyId", (object?)entry.KeyId ?? DBNull.Value);
cmd.Parameters.AddWithValue("matchMode", entry.MatchMode.ToString());
cmd.Parameters.AddWithValue("severity", entry.Severity.ToString());
cmd.Parameters.AddWithValue("enabled", entry.Enabled);
cmd.Parameters.AddWithValue("channelOverrides",
NpgsqlDbType.Jsonb,
entry.ChannelOverrides is { Count: > 0 }
? System.Text.Json.JsonSerializer.Serialize(entry.ChannelOverrides)
: DBNull.Value);
cmd.Parameters.AddWithValue("suppressMinutes", entry.SuppressDuplicatesMinutes);
cmd.Parameters.AddWithValue("tags",
NpgsqlDbType.Array | NpgsqlDbType.Text,
entry.Tags is { Count: > 0 } ? entry.Tags.ToArray() : Array.Empty<string>());
cmd.Parameters.AddWithValue("createdAt", entry.CreatedAt);
cmd.Parameters.AddWithValue("updatedAt", entry.UpdatedAt);
cmd.Parameters.AddWithValue("createdBy", entry.CreatedBy);
cmd.Parameters.AddWithValue("updatedBy", entry.UpdatedBy);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
// Invalidate cache
InvalidateCache(entry.TenantId);
return MapToEntry(reader);
}
throw new InvalidOperationException("Upsert did not return the expected row");
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(
Guid id,
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM attestor.identity_watchlist
WHERE id = @id AND (tenant_id = @tenantId OR @tenantId = 'system-admin')
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("tenantId", tenantId);
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
if (deleted > 0)
{
InvalidateCache(tenantId);
}
return deleted > 0;
}
/// <inheritdoc />
public async Task<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM attestor.identity_watchlist
WHERE tenant_id = @tenantId OR scope IN ('Global', 'System')
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenantId", tenantId);
var result = await cmd.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(result);
}
private void InvalidateCache(string tenantId)
{
_cache.TryRemove(tenantId, out _);
}
private static WatchedIdentity MapToEntry(NpgsqlDataReader reader)
{
var channelOverridesJson = reader.IsDBNull(reader.GetOrdinal("channel_overrides"))
? null
: reader.GetString(reader.GetOrdinal("channel_overrides"));
IReadOnlyList<string>? channelOverrides = null;
if (!string.IsNullOrEmpty(channelOverridesJson))
{
channelOverrides = System.Text.Json.JsonSerializer.Deserialize<List<string>>(channelOverridesJson);
}
var tagsOrdinal = reader.GetOrdinal("tags");
IReadOnlyList<string>? tags = reader.IsDBNull(tagsOrdinal)
? null
: (string[])reader.GetValue(tagsOrdinal);
return new WatchedIdentity
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Scope = Enum.Parse<WatchlistScope>(reader.GetString(reader.GetOrdinal("scope"))),
DisplayName = reader.GetString(reader.GetOrdinal("display_name")),
Description = reader.IsDBNull(reader.GetOrdinal("description"))
? null
: reader.GetString(reader.GetOrdinal("description")),
Issuer = reader.IsDBNull(reader.GetOrdinal("issuer"))
? null
: reader.GetString(reader.GetOrdinal("issuer")),
SubjectAlternativeName = reader.IsDBNull(reader.GetOrdinal("subject_alternative_name"))
? null
: reader.GetString(reader.GetOrdinal("subject_alternative_name")),
KeyId = reader.IsDBNull(reader.GetOrdinal("key_id"))
? null
: reader.GetString(reader.GetOrdinal("key_id")),
MatchMode = Enum.Parse<WatchlistMatchMode>(reader.GetString(reader.GetOrdinal("match_mode"))),
Severity = Enum.Parse<IdentityAlertSeverity>(reader.GetString(reader.GetOrdinal("severity"))),
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
ChannelOverrides = channelOverrides,
SuppressDuplicatesMinutes = reader.GetInt32(reader.GetOrdinal("suppress_duplicates_minutes")),
Tags = tags,
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetDateTime(reader.GetOrdinal("updated_at")),
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
UpdatedBy = reader.GetString(reader.GetOrdinal("updated_by"))
};
}
private sealed record CachedEntries(IReadOnlyList<WatchedIdentity> Entries, DateTimeOffset ExpiresAt);
}
/// <summary>
/// PostgreSQL implementation of the alert dedup repository.
/// </summary>
public sealed class PostgresAlertDedupRepository : IAlertDedupRepository
{
private readonly NpgsqlDataSource _dataSource;
public PostgresAlertDedupRepository(NpgsqlDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
/// <inheritdoc />
public async Task<AlertDedupStatus> CheckAndUpdateAsync(
Guid watchlistId,
string identityHash,
int dedupWindowMinutes,
CancellationToken cancellationToken = default)
{
var windowStart = DateTimeOffset.UtcNow.AddMinutes(-dedupWindowMinutes);
var windowEnd = DateTimeOffset.UtcNow.AddMinutes(dedupWindowMinutes);
const string sql = """
INSERT INTO attestor.identity_alert_dedup (watchlist_id, identity_hash, last_alert_at, alert_count)
VALUES (@watchlistId, @identityHash, @now, 1)
ON CONFLICT (watchlist_id, identity_hash) DO UPDATE SET
alert_count = CASE
WHEN attestor.identity_alert_dedup.last_alert_at < @windowStart
THEN 1
ELSE attestor.identity_alert_dedup.alert_count + 1
END,
last_alert_at = CASE
WHEN attestor.identity_alert_dedup.last_alert_at < @windowStart
THEN @now
ELSE attestor.identity_alert_dedup.last_alert_at
END
RETURNING
CASE WHEN last_alert_at < @now THEN FALSE ELSE TRUE END as should_suppress,
alert_count,
last_alert_at + INTERVAL '1 minute' * @dedupMinutes as window_expires
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("watchlistId", watchlistId);
cmd.Parameters.AddWithValue("identityHash", identityHash);
cmd.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("windowStart", windowStart);
cmd.Parameters.AddWithValue("dedupMinutes", dedupWindowMinutes);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
var shouldSuppress = reader.GetBoolean(0);
var alertCount = reader.GetInt32(1);
var windowExpires = reader.GetDateTime(2);
return shouldSuppress
? AlertDedupStatus.Suppress(alertCount, windowExpires)
: AlertDedupStatus.Send(alertCount - 1);
}
return AlertDedupStatus.Send();
}
/// <inheritdoc />
public async Task<int> GetSuppressedCountAsync(
Guid watchlistId,
string identityHash,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT alert_count FROM attestor.identity_alert_dedup
WHERE watchlist_id = @watchlistId AND identity_hash = @identityHash
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("watchlistId", watchlistId);
cmd.Parameters.AddWithValue("identityHash", identityHash);
var result = await cmd.ExecuteScalarAsync(cancellationToken);
return result is null ? 0 : Convert.ToInt32(result);
}
/// <inheritdoc />
public async Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
{
// Clean up 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 conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
return await cmd.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -25,6 +25,7 @@ using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure;
using StellaOps.Attestor.Spdx3;
using StellaOps.Attestor.Watchlist;
using StellaOps.Attestor.WebService.Options;
using StellaOps.Configuration;
using StellaOps.Cryptography.DependencyInjection;
@@ -184,6 +185,10 @@ internal static class AttestorWebServiceComposition
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
// Identity watchlist services (WATCH-006)
builder.Services.AddMemoryCache();
builder.Services.AddWatchlistServicesInMemory(builder.Configuration);
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.WithMetrics(metricsBuilder =>
@@ -269,6 +274,25 @@ internal static class AttestorWebServiceComposition
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.read", "attestor.verify", "attestor.write"));
});
// Watchlist authorization policies (WATCH-006)
options.AddPolicy("watchlist:read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.read", "watchlist.write", "attestor.write"));
});
options.AddPolicy("watchlist:write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.write", "attestor.write"));
});
options.AddPolicy("watchlist:admin", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.admin", "attestor.write"));
});
});
}
@@ -382,6 +406,7 @@ internal static class AttestorWebServiceComposition
app.MapControllers();
app.MapAttestorEndpoints(attestorOptions);
app.MapWatchlistEndpoints();
app.TryRefreshStellaRouterEndpoints(routerOptions);
}

View File

@@ -31,5 +31,6 @@
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,454 @@
// -----------------------------------------------------------------------------
// WatchlistEndpoints.cs
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-006
// Description: REST API endpoints for identity watchlist management.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using StellaOps.Attestor.Watchlist.Matching;
using StellaOps.Attestor.Watchlist.Models;
using StellaOps.Attestor.Watchlist.Storage;
namespace StellaOps.Attestor.WebService;
/// <summary>
/// Maps watchlist management endpoints.
/// </summary>
internal static class WatchlistEndpoints
{
public static void MapWatchlistEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/watchlist")
.WithTags("Watchlist")
.RequireAuthorization();
// List watchlist entries
group.MapGet("", ListWatchlistEntries)
.RequireAuthorization("watchlist:read")
.Produces<WatchlistListResponse>(StatusCodes.Status200OK)
.WithSummary("List watchlist entries")
.WithDescription("Returns all watchlist entries for the tenant, optionally including global entries.");
// Get single entry
group.MapGet("{id:guid}", GetWatchlistEntry)
.RequireAuthorization("watchlist:read")
.Produces<WatchlistEntryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithSummary("Get watchlist entry")
.WithDescription("Returns a single watchlist entry by ID.");
// Create entry
group.MapPost("", CreateWatchlistEntry)
.RequireAuthorization("watchlist:write")
.Produces<WatchlistEntryResponse>(StatusCodes.Status201Created)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
.WithSummary("Create watchlist entry")
.WithDescription("Creates a new watchlist entry for monitoring identity appearances.");
// Update entry
group.MapPut("{id:guid}", UpdateWatchlistEntry)
.RequireAuthorization("watchlist:write")
.Produces<WatchlistEntryResponse>(StatusCodes.Status200OK)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.WithSummary("Update watchlist entry")
.WithDescription("Updates an existing watchlist entry.");
// Delete entry
group.MapDelete("{id:guid}", DeleteWatchlistEntry)
.RequireAuthorization("watchlist:write")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.WithSummary("Delete watchlist entry")
.WithDescription("Deletes a watchlist entry.");
// Test pattern
group.MapPost("{id:guid}/test", TestWatchlistPattern)
.RequireAuthorization("watchlist:read")
.Produces<WatchlistTestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithSummary("Test watchlist pattern")
.WithDescription("Tests if a sample identity matches the watchlist entry pattern.");
// List recent alerts
group.MapGet("alerts", ListWatchlistAlerts)
.RequireAuthorization("watchlist:read")
.Produces<WatchlistAlertsResponse>(StatusCodes.Status200OK)
.WithSummary("List recent alerts")
.WithDescription("Returns recent alerts generated by watchlist matches.");
}
private static async Task<IResult> ListWatchlistEntries(
HttpContext context,
IWatchlistRepository repository,
bool includeGlobal = true,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(context);
var entries = await repository.ListAsync(tenantId, includeGlobal, cancellationToken);
var response = new WatchlistListResponse
{
Items = entries.Select(WatchlistEntryResponse.FromDomain).ToList(),
TotalCount = entries.Count
};
return Results.Ok(response);
}
private static async Task<IResult> GetWatchlistEntry(
Guid id,
HttpContext context,
IWatchlistRepository repository,
CancellationToken cancellationToken = default)
{
var entry = await repository.GetAsync(id, cancellationToken);
if (entry is null)
{
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
}
var tenantId = GetTenantId(context);
if (!CanAccessEntry(entry, tenantId))
{
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
}
return Results.Ok(WatchlistEntryResponse.FromDomain(entry));
}
private static async Task<IResult> CreateWatchlistEntry(
WatchlistEntryRequest request,
HttpContext context,
IWatchlistRepository repository,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(context);
var userId = GetUserId(context);
// Only admins can create Global/System entries
if (request.Scope is WatchlistScope.Global or WatchlistScope.System)
{
if (!IsAdmin(context))
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Only administrators can create global or system scope entries.");
}
}
var entry = request.ToDomain(tenantId, userId);
var validation = entry.Validate();
if (!validation.IsValid)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["entry"] = validation.Errors.ToArray()
});
}
var created = await repository.UpsertAsync(entry, cancellationToken);
return Results.Created($"/api/v1/watchlist/{created.Id}", WatchlistEntryResponse.FromDomain(created));
}
private static async Task<IResult> UpdateWatchlistEntry(
Guid id,
WatchlistEntryRequest request,
HttpContext context,
IWatchlistRepository repository,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(context);
var userId = GetUserId(context);
var existing = await repository.GetAsync(id, cancellationToken);
if (existing is null || !CanAccessEntry(existing, tenantId))
{
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
}
// Can't change scope unless admin
if (request.Scope != existing.Scope && !IsAdmin(context))
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Only administrators can change entry scope.");
}
var updated = request.ToDomain(tenantId, userId) with
{
Id = id,
CreatedAt = existing.CreatedAt,
CreatedBy = existing.CreatedBy
};
var validation = updated.Validate();
if (!validation.IsValid)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["entry"] = validation.Errors.ToArray()
});
}
var saved = await repository.UpsertAsync(updated, cancellationToken);
return Results.Ok(WatchlistEntryResponse.FromDomain(saved));
}
private static async Task<IResult> DeleteWatchlistEntry(
Guid id,
HttpContext context,
IWatchlistRepository repository,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(context);
var existing = await repository.GetAsync(id, cancellationToken);
if (existing is null || !CanAccessEntry(existing, tenantId))
{
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
}
// System entries cannot be deleted
if (existing.Scope == WatchlistScope.System)
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "System scope entries cannot be deleted.");
}
// Global entries require admin
if (existing.Scope == WatchlistScope.Global && !IsAdmin(context))
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Only administrators can delete global scope entries.");
}
await repository.DeleteAsync(id, tenantId, cancellationToken);
return Results.NoContent();
}
private static async Task<IResult> TestWatchlistPattern(
Guid id,
WatchlistTestRequest request,
HttpContext context,
IWatchlistRepository repository,
IIdentityMatcher matcher,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(context);
var entry = await repository.GetAsync(id, cancellationToken);
if (entry is null || !CanAccessEntry(entry, tenantId))
{
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
}
var identity = new SignerIdentityInput
{
Issuer = request.Issuer,
SubjectAlternativeName = request.SubjectAlternativeName,
KeyId = request.KeyId
};
var match = matcher.TestMatch(identity, entry);
return Results.Ok(new WatchlistTestResponse
{
Matches = match is not null,
MatchedFields = match?.Fields ?? MatchedFields.None,
MatchScore = match?.MatchScore ?? 0,
Entry = WatchlistEntryResponse.FromDomain(entry)
});
}
private static Task<IResult> ListWatchlistAlerts(
HttpContext context,
int? limit = 100,
string? since = null,
string? severity = null,
CancellationToken cancellationToken = default)
{
// TODO: Implement alert history retrieval
// This would query a separate alerts table or event store
var response = new WatchlistAlertsResponse
{
Items = [],
TotalCount = 0
};
return Task.FromResult<IResult>(Results.Ok(response));
}
private static string GetTenantId(HttpContext context)
{
return context.User.FindFirst("tenant_id")?.Value ?? "default";
}
private static string GetUserId(HttpContext context)
{
return context.User.FindFirst("sub")?.Value ??
context.User.FindFirst("name")?.Value ??
"anonymous";
}
private static bool IsAdmin(HttpContext context)
{
return context.User.IsInRole("admin") ||
context.User.HasClaim("scope", "watchlist:admin");
}
private static bool CanAccessEntry(WatchedIdentity entry, string tenantId)
{
return entry.TenantId == tenantId ||
entry.Scope is WatchlistScope.Global or WatchlistScope.System;
}
}
#region Request/Response Contracts
/// <summary>
/// Request to create or update a watchlist entry.
/// </summary>
public sealed record WatchlistEntryRequest
{
public required string DisplayName { get; init; }
public string? Description { get; init; }
public string? Issuer { get; init; }
public string? SubjectAlternativeName { get; init; }
public string? KeyId { get; init; }
public WatchlistMatchMode MatchMode { get; init; } = WatchlistMatchMode.Exact;
public IdentityAlertSeverity Severity { get; init; } = IdentityAlertSeverity.Warning;
public bool Enabled { get; init; } = true;
public IReadOnlyList<string>? ChannelOverrides { get; init; }
public int SuppressDuplicatesMinutes { get; init; } = 60;
public IReadOnlyList<string>? Tags { get; init; }
public WatchlistScope Scope { get; init; } = WatchlistScope.Tenant;
public WatchedIdentity ToDomain(string tenantId, string userId) => new()
{
TenantId = tenantId,
DisplayName = DisplayName,
Description = Description,
Issuer = Issuer,
SubjectAlternativeName = SubjectAlternativeName,
KeyId = KeyId,
MatchMode = MatchMode,
Severity = Severity,
Enabled = Enabled,
ChannelOverrides = ChannelOverrides,
SuppressDuplicatesMinutes = SuppressDuplicatesMinutes,
Tags = Tags,
Scope = Scope,
CreatedBy = userId,
UpdatedBy = userId
};
}
/// <summary>
/// Response for a single watchlist entry.
/// </summary>
public sealed record WatchlistEntryResponse
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string DisplayName { get; init; }
public string? Description { get; init; }
public string? Issuer { get; init; }
public string? SubjectAlternativeName { get; init; }
public string? KeyId { get; init; }
public required WatchlistMatchMode MatchMode { get; init; }
public required IdentityAlertSeverity Severity { get; init; }
public required bool Enabled { get; init; }
public IReadOnlyList<string>? ChannelOverrides { get; init; }
public required int SuppressDuplicatesMinutes { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
public required WatchlistScope Scope { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public required string CreatedBy { get; init; }
public required string UpdatedBy { get; init; }
public static WatchlistEntryResponse FromDomain(WatchedIdentity entry) => new()
{
Id = entry.Id,
TenantId = entry.TenantId,
DisplayName = entry.DisplayName,
Description = entry.Description,
Issuer = entry.Issuer,
SubjectAlternativeName = entry.SubjectAlternativeName,
KeyId = entry.KeyId,
MatchMode = entry.MatchMode,
Severity = entry.Severity,
Enabled = entry.Enabled,
ChannelOverrides = entry.ChannelOverrides,
SuppressDuplicatesMinutes = entry.SuppressDuplicatesMinutes,
Tags = entry.Tags,
Scope = entry.Scope,
CreatedAt = entry.CreatedAt,
UpdatedAt = entry.UpdatedAt,
CreatedBy = entry.CreatedBy,
UpdatedBy = entry.UpdatedBy
};
}
/// <summary>
/// Response for listing watchlist entries.
/// </summary>
public sealed record WatchlistListResponse
{
public required IReadOnlyList<WatchlistEntryResponse> Items { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to test a watchlist pattern.
/// </summary>
public sealed record WatchlistTestRequest
{
public string? Issuer { get; init; }
public string? SubjectAlternativeName { get; init; }
public string? KeyId { get; init; }
}
/// <summary>
/// Response from testing a watchlist pattern.
/// </summary>
public sealed record WatchlistTestResponse
{
public required bool Matches { get; init; }
public required MatchedFields MatchedFields { get; init; }
public required int MatchScore { get; init; }
public required WatchlistEntryResponse Entry { get; init; }
}
/// <summary>
/// Response for listing watchlist alerts.
/// </summary>
public sealed record WatchlistAlertsResponse
{
public required IReadOnlyList<WatchlistAlertItem> Items { get; init; }
public required int TotalCount { get; init; }
public string? ContinuationToken { get; init; }
}
/// <summary>
/// A single alert item.
/// </summary>
public sealed record WatchlistAlertItem
{
public required Guid AlertId { get; init; }
public required Guid WatchlistEntryId { get; init; }
public required string WatchlistEntryName { get; init; }
public required IdentityAlertSeverity Severity { get; init; }
public string? MatchedIssuer { get; init; }
public string? MatchedSan { get; init; }
public string? MatchedKeyId { get; init; }
public string? RekorUuid { get; init; }
public long? RekorLogIndex { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
}
#endregion

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

View File

@@ -1,11 +1,13 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.EvidencePack.Models;
using StellaOps.Attestor.EvidencePack.Services;
namespace StellaOps.Attestor.EvidencePack.IntegrationTests;
@@ -254,4 +256,220 @@ public class EvidencePackGenerationTests : IDisposable
"linux-x64")
.Build();
}
#region Replay Log Integration Tests (EU CRA/NIS2)
[Fact]
public async Task GeneratePack_WithReplayLog_IncludesReplayLogJson()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputDir = Path.Combine(_tempDir, "replay-log-test");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/stella:v2.5.0@sha256:abc123",
SbomPath = "sbom/stella.cdx.json",
CanonicalSbomDigest = "sha256:sbomdigest123456",
DsseEnvelopePath = "attestations/stella.dsse.json",
DsseSubjectDigest = "sha256:sbomdigest123456",
DsseSignatureValid = true,
SigningKeyId = "cosign-key-1",
CosignPublicKeyPath = "cosign.pub",
RekorLogId = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
RekorLogIndex = 12345678,
RekorTreeSize = 99999999,
RekorRootHash = "sha256:merklerootabc",
InclusionProofPath = "rekor-proofs/log-entries/12345678.json",
RekorInclusionValid = true,
RekorPublicKeyPath = "rekor-public-key.pub"
});
// Act
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
// Assert
var replayLogPath = Path.Combine(outputDir, "replay_log.json");
File.Exists(replayLogPath).Should().BeTrue("replay_log.json should be created");
var replayLogContent = await File.ReadAllTextAsync(replayLogPath);
replayLogContent.Should().Contain("schema_version");
replayLogContent.Should().Contain("compute_canonical_sbom_digest");
replayLogContent.Should().Contain("verify_dsse_signature");
replayLogContent.Should().Contain("verify_rekor_inclusion");
}
[Fact]
public async Task GeneratePack_WithReplayLog_ManifestReferencesReplayLog()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputDir = Path.Combine(_tempDir, "replay-log-manifest-test");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test-artifact"
});
// Act
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
// Assert - manifest should reference replay_log.json
var manifestPath = Path.Combine(outputDir, "manifest.json");
var manifestContent = await File.ReadAllTextAsync(manifestPath);
manifestContent.Should().Contain("replayLogPath");
manifestContent.Should().Contain("replay_log.json");
}
[Fact]
public async Task GeneratePack_WithReplayLog_VerifyMdContainsCraNis2Section()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputDir = Path.Combine(_tempDir, "replay-log-verify-md-test");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test-artifact",
SbomPath = "sbom/test.cdx.json",
CanonicalSbomDigest = "sha256:test"
});
// Act
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
// Assert - VERIFY.md should contain CRA/NIS2 section
var verifyMdPath = Path.Combine(outputDir, "VERIFY.md");
var verifyMdContent = await File.ReadAllTextAsync(verifyMdPath);
verifyMdContent.Should().Contain("CRA/NIS2");
verifyMdContent.Should().Contain("replay_log.json");
verifyMdContent.Should().Contain("compute_canonical_sbom_digest");
}
[Fact]
public async Task GeneratePack_TarGz_WithReplayLog_IncludesReplayLogInArchive()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputPath = Path.Combine(_tempDir, "evidence-pack-with-replay.tgz");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test-artifact",
SbomPath = "sbom/test.cdx.json",
CanonicalSbomDigest = "sha256:test",
DsseEnvelopePath = "attestations/test.dsse.json",
DsseSubjectDigest = "sha256:test",
DsseSignatureValid = true
});
// Act
await using (var stream = File.Create(outputPath))
{
await _serializer.SerializeToTarGzAsync(manifest, stream, "stella-release-2.5.0-evidence-pack", replayLog);
}
// Assert
File.Exists(outputPath).Should().BeTrue();
var fileInfo = new FileInfo(outputPath);
fileInfo.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task GeneratePack_Zip_WithReplayLog_IncludesReplayLogInArchive()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputPath = Path.Combine(_tempDir, "evidence-pack-with-replay.zip");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test-artifact",
Metadata = ImmutableDictionary<string, string>.Empty
.Add("compliance_framework", "EU_CRA_NIS2")
});
// Act
await using (var stream = File.Create(outputPath))
{
await _serializer.SerializeToZipAsync(manifest, stream, "stella-release-2.5.0-evidence-pack", replayLog);
}
// Assert
File.Exists(outputPath).Should().BeTrue();
var fileInfo = new FileInfo(outputPath);
fileInfo.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task GeneratePack_WithFailedVerification_ReplayLogShowsFailure()
{
// Arrange
var artifactPath = CreateTestArtifact("stella-2.5.0-linux-x64.tar.gz", 1024);
var manifest = CreateManifestWithArtifact(artifactPath);
var outputDir = Path.Combine(_tempDir, "replay-log-failure-test");
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test-artifact",
SbomPath = "sbom/test.cdx.json",
CanonicalSbomDigest = "sha256:computed",
DsseSubjectDigest = "sha256:different", // Mismatch!
DsseEnvelopePath = "attestations/test.dsse.json",
DsseSignatureValid = false,
DsseSignatureError = "Signature verification failed: key mismatch"
});
// Act
await _serializer.SerializeToDirectoryAsync(manifest, outputDir, replayLog);
// Assert
var replayLogPath = Path.Combine(outputDir, "replay_log.json");
var replayLogContent = await File.ReadAllTextAsync(replayLogPath);
replayLogContent.Should().Contain("\"result\": \"fail\"");
replayLogContent.Should().Contain("key mismatch");
replayLogContent.Should().Contain("does not match");
}
[Fact]
public void ReplayLogBuilder_SerializesToSnakeCaseJson()
{
// Arrange
var replayLogBuilder = new VerificationReplayLogBuilder();
var replayLog = replayLogBuilder.Build(new VerificationReplayLogRequest
{
ArtifactRef = "test",
SbomPath = "sbom/test.json",
CanonicalSbomDigest = "sha256:abc"
});
// Act
var json = replayLogBuilder.Serialize(replayLog);
// Assert - should use snake_case per advisory spec
json.Should().Contain("schema_version");
json.Should().Contain("replay_id");
json.Should().Contain("artifact_ref");
json.Should().Contain("verified_at");
json.Should().Contain("verifier_version");
// Should NOT contain camelCase
json.Should().NotContain("schemaVersion");
json.Should().NotContain("replayId");
json.Should().NotContain("artifactRef");
}
#endregion
}

View File

@@ -0,0 +1,4 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
global using Xunit;

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -10,16 +11,13 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.EvidencePack\StellaOps.Attestor.EvidencePack.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -9,15 +10,13 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.EvidencePack\StellaOps.Attestor.EvidencePack.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,258 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json tests
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.EvidencePack.Models;
using StellaOps.Attestor.EvidencePack.Services;
namespace StellaOps.Attestor.EvidencePack.Tests;
/// <summary>
/// Unit tests for VerificationReplayLogBuilder.
/// Tests the replay_log.json generation for EU CRA/NIS2 compliance.
/// </summary>
public class VerificationReplayLogBuilderTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly VerificationReplayLogBuilder _builder;
public VerificationReplayLogBuilderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 29, 12, 0, 0, TimeSpan.Zero));
_builder = new VerificationReplayLogBuilder(_timeProvider);
}
[Fact]
public void Build_WithMinimalRequest_ReturnsValidReplayLog()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123"
};
// Act
var log = _builder.Build(request);
// Assert
log.Should().NotBeNull();
log.SchemaVersion.Should().Be("1.0.0");
log.ArtifactRef.Should().Be("oci://registry.example.com/app:v1.0.0@sha256:abc123");
log.VerifierVersion.Should().Be("stellaops-attestor/1.0.0");
log.Result.Should().Be("pass");
log.ReplayId.Should().StartWith("replay_");
}
[Fact]
public void Build_WithFullVerificationRequest_ReturnsAllSteps()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
SbomPath = "sbom/app.cdx.json",
CanonicalSbomDigest = "sha256:sbomdigest123",
DsseEnvelopePath = "attestations/app.dsse.json",
DsseSubjectDigest = "sha256:sbomdigest123",
DsseSignatureValid = true,
SigningKeyId = "cosign-key-1",
SignatureAlgorithm = "ecdsa-p256",
SigningKeyFingerprint = "SHA256:keyfingerprint123",
CosignPublicKeyPath = "cosign.pub",
RekorLogId = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
RekorLogIndex = 12345678,
RekorTreeSize = 99999999,
RekorRootHash = "sha256:merklerootabc",
RekorIntegratedTime = 1706529600,
InclusionProofPath = "rekor-proofs/log-entries/12345678.json",
RekorInclusionValid = true,
CheckpointPath = "rekor-proofs/checkpoint.json",
CheckpointValid = true,
RekorPublicKeyPath = "rekor-public-key.pub",
RekorPublicKeyId = "rekor-key-1"
};
// Act
var log = _builder.Build(request);
// Assert
log.Result.Should().Be("pass");
log.Steps.Should().HaveCount(5);
// Step 1: Canonical SBOM digest
log.Steps[0].Step.Should().Be(1);
log.Steps[0].Action.Should().Be("compute_canonical_sbom_digest");
log.Steps[0].Input.Should().Be("sbom/app.cdx.json");
log.Steps[0].Output.Should().Be("sha256:sbomdigest123");
log.Steps[0].Result.Should().Be("pass");
// Step 2: DSSE subject match
log.Steps[1].Step.Should().Be(2);
log.Steps[1].Action.Should().Be("verify_dsse_subject_match");
log.Steps[1].Expected.Should().Be("sha256:sbomdigest123");
log.Steps[1].Actual.Should().Be("sha256:sbomdigest123");
log.Steps[1].Result.Should().Be("pass");
// Step 3: DSSE signature
log.Steps[2].Step.Should().Be(3);
log.Steps[2].Action.Should().Be("verify_dsse_signature");
log.Steps[2].KeyId.Should().Be("cosign-key-1");
log.Steps[2].Result.Should().Be("pass");
// Step 4: Rekor inclusion
log.Steps[3].Step.Should().Be(4);
log.Steps[3].Action.Should().Be("verify_rekor_inclusion");
log.Steps[3].Result.Should().Be("pass");
// Step 5: Rekor checkpoint
log.Steps[4].Step.Should().Be(5);
log.Steps[4].Action.Should().Be("verify_rekor_checkpoint");
log.Steps[4].Result.Should().Be("pass");
// Verification keys
log.VerificationKeys.Should().HaveCount(2);
log.VerificationKeys[0].Type.Should().Be("cosign");
log.VerificationKeys[0].Path.Should().Be("cosign.pub");
log.VerificationKeys[1].Type.Should().Be("rekor");
log.VerificationKeys[1].Path.Should().Be("rekor-public-key.pub");
// Rekor info
log.Rekor.Should().NotBeNull();
log.Rekor!.LogId.Should().Be("c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d");
log.Rekor.LogIndex.Should().Be(12345678);
log.Rekor.TreeSize.Should().Be(99999999);
}
[Fact]
public void Build_WithFailedDsseSignature_ReturnsFailResult()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
SbomPath = "sbom/app.cdx.json",
CanonicalSbomDigest = "sha256:sbomdigest123",
DsseEnvelopePath = "attestations/app.dsse.json",
DsseSubjectDigest = "sha256:sbomdigest123",
DsseSignatureValid = false,
DsseSignatureError = "Invalid signature: key mismatch",
SigningKeyId = "cosign-key-1",
CosignPublicKeyPath = "cosign.pub"
};
// Act
var log = _builder.Build(request);
// Assert
log.Result.Should().Be("fail");
log.Steps.Should().Contain(s => s.Action == "verify_dsse_signature" && s.Result == "fail");
log.Steps.First(s => s.Action == "verify_dsse_signature").Error
.Should().Be("Invalid signature: key mismatch");
}
[Fact]
public void Build_WithMismatchedDigests_ReturnsFailResult()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
SbomPath = "sbom/app.cdx.json",
CanonicalSbomDigest = "sha256:computeddigest",
DsseSubjectDigest = "sha256:differentdigest"
};
// Act
var log = _builder.Build(request);
// Assert
log.Result.Should().Be("fail");
var mismatchStep = log.Steps.First(s => s.Action == "verify_dsse_subject_match");
mismatchStep.Result.Should().Be("fail");
mismatchStep.Expected.Should().Be("sha256:differentdigest");
mismatchStep.Actual.Should().Be("sha256:computeddigest");
mismatchStep.Error.Should().Contain("does not match");
}
[Fact]
public void Serialize_ReturnsValidJson()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
SbomPath = "sbom/app.cdx.json",
CanonicalSbomDigest = "sha256:sbomdigest123"
};
var log = _builder.Build(request);
// Act
var json = _builder.Serialize(log);
// Assert
json.Should().NotBeNullOrEmpty();
json.Should().Contain("\"schema_version\"");
json.Should().Contain("\"replay_id\"");
json.Should().Contain("\"artifact_ref\"");
json.Should().Contain("\"steps\"");
json.Should().Contain("\"compute_canonical_sbom_digest\"");
// Should be valid JSON
var parsed = JsonDocument.Parse(json);
parsed.RootElement.GetProperty("schema_version").GetString().Should().Be("1.0.0");
}
[Fact]
public void Build_WithMetadata_IncludesMetadataInLog()
{
// Arrange
var request = new VerificationReplayLogRequest
{
ArtifactRef = "oci://registry.example.com/app:v1.0.0@sha256:abc123",
Metadata = ImmutableDictionary<string, string>.Empty
.Add("compliance_framework", "EU_CRA_NIS2")
.Add("auditor", "external-auditor-id")
};
// Act
var log = _builder.Build(request);
// Assert
log.Metadata.Should().NotBeNull();
log.Metadata!["compliance_framework"].Should().Be("EU_CRA_NIS2");
log.Metadata["auditor"].Should().Be("external-auditor-id");
}
[Fact]
public void Build_GeneratesUniqueReplayId()
{
// Arrange
var request1 = new VerificationReplayLogRequest { ArtifactRef = "artifact1" };
var request2 = new VerificationReplayLogRequest { ArtifactRef = "artifact2" };
// Act
var log1 = _builder.Build(request1);
var log2 = _builder.Build(request2);
// Assert
log1.ReplayId.Should().NotBe(log2.ReplayId);
}
[Fact]
public void Build_UsesProvidedTimeProvider()
{
// Arrange
var expectedTime = new DateTimeOffset(2026, 1, 29, 12, 0, 0, TimeSpan.Zero);
var request = new VerificationReplayLogRequest { ArtifactRef = "test" };
// Act
var log = _builder.Build(request);
// Assert
log.VerifiedAt.Should().Be(expectedTime);
}
}

View File

@@ -0,0 +1,264 @@
// -----------------------------------------------------------------------------
// BinaryMicroWitnessPredicateTests.cs
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
// Task: TASK-001 - Define binary-micro-witness predicate schema
// Description: Unit tests for binary micro-witness predicate serialization.
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.ProofChain.Predicates;
using StellaOps.Attestor.ProofChain.Statements;
using StellaOps.TestKit;
namespace StellaOps.Attestor.ProofChain.Tests;
public sealed class BinaryMicroWitnessPredicateTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Predicate_RoundTrip_PreservesAllFields()
{
// Arrange
var predicate = CreateSamplePredicate();
// Act
var json = JsonSerializer.Serialize(predicate, JsonOptions);
var deserialized = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.SchemaVersion.Should().Be(predicate.SchemaVersion);
deserialized.Binary.Digest.Should().Be(predicate.Binary.Digest);
deserialized.Cve.Id.Should().Be(predicate.Cve.Id);
deserialized.Verdict.Should().Be(predicate.Verdict);
deserialized.Confidence.Should().Be(predicate.Confidence);
deserialized.Evidence.Should().HaveCount(predicate.Evidence.Count);
deserialized.Tooling.BinaryIndexVersion.Should().Be(predicate.Tooling.BinaryIndexVersion);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Predicate_Serialization_ProducesDeterministicOutput()
{
// Arrange
var predicate = CreateSamplePredicate();
// Act
var json1 = JsonSerializer.Serialize(predicate, JsonOptions);
var json2 = JsonSerializer.Serialize(predicate, JsonOptions);
// Assert
json1.Should().Be(json2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Predicate_Serialization_OmitsNullFields()
{
// Arrange
var predicate = CreateMinimalPredicate();
// Act
var json = JsonSerializer.Serialize(predicate, JsonOptions);
// Assert
json.Should().NotContain("deltaSigDigest");
json.Should().NotContain("sbomRef");
json.Should().NotContain("advisory");
json.Should().NotContain("patchCommit");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Predicate_Serialization_MinimalIsCompact()
{
// Arrange - minimal witness (required fields only)
var predicate = CreateMinimalPredicate();
// Act
var json = JsonSerializer.Serialize(predicate, JsonOptions);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
// Assert - minimal micro-witness should be under 500 bytes
sizeBytes.Should().BeLessThan(500, "minimal micro-witness should be very compact");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Predicate_Serialization_FullIsUnder1500Bytes()
{
// Arrange - full witness with all optional fields
var predicate = CreateSamplePredicate();
// Act
var json = JsonSerializer.Serialize(predicate, JsonOptions);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
// Assert - full micro-witness should still be compact (<1.5KB)
sizeBytes.Should().BeLessThan(1500, "full micro-witness should be under 1.5KB for portability");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Statement_Serialization_IncludesInTotoFields()
{
// Arrange
var statement = new BinaryMicroWitnessStatement
{
Subject =
[
new Subject
{
Name = "libssl.so.3",
Digest = new Dictionary<string, string>
{
["sha256"] = "abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
}
}
],
Predicate = CreateSamplePredicate()
};
// Act
var json = JsonSerializer.Serialize(statement, JsonOptions);
// Assert
json.Should().Contain("\"_type\":\"https://in-toto.io/Statement/v1\"");
json.Should().Contain("\"predicateType\":\"https://stellaops.dev/predicates/binary-micro-witness@v1\"");
json.Should().Contain("\"subject\":");
json.Should().Contain("\"predicate\":");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PredicateType_HasCorrectUri()
{
BinaryMicroWitnessPredicate.PredicateType.Should().Be("https://stellaops.dev/predicates/binary-micro-witness@v1");
BinaryMicroWitnessPredicate.PredicateTypeName.Should().Be("stellaops/binary-micro-witness/v1");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(MicroWitnessVerdicts.Patched)]
[InlineData(MicroWitnessVerdicts.Vulnerable)]
[InlineData(MicroWitnessVerdicts.Inconclusive)]
[InlineData(MicroWitnessVerdicts.Partial)]
public void VerdictConstants_AreValidValues(string verdict)
{
// Arrange
var predicate = CreateMinimalPredicate() with { Verdict = verdict };
// Act
var json = JsonSerializer.Serialize(predicate, JsonOptions);
var deserialized = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(json, JsonOptions);
// Assert
deserialized!.Verdict.Should().Be(verdict);
}
private static BinaryMicroWitnessPredicate CreateSamplePredicate()
{
return new BinaryMicroWitnessPredicate
{
SchemaVersion = "1.0.0",
Binary = new MicroWitnessBinaryRef
{
Digest = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
Purl = "pkg:deb/debian/openssl@3.0.11-1",
Arch = "linux-amd64",
Filename = "libssl.so.3"
},
Cve = new MicroWitnessCveRef
{
Id = "CVE-2024-0567",
Advisory = "https://www.openssl.org/news/secadv/20240115.txt",
PatchCommit = "a1b2c3d4e5f6"
},
Verdict = MicroWitnessVerdicts.Patched,
Confidence = 0.95,
Evidence =
[
new MicroWitnessFunctionEvidence
{
Function = "SSL_CTX_new",
State = "patched",
Score = 0.97,
Method = "semantic_ksg",
Hash = "sha256:1234abcd"
},
new MicroWitnessFunctionEvidence
{
Function = "SSL_read",
State = "unchanged",
Score = 1.0,
Method = "byte_exact"
},
new MicroWitnessFunctionEvidence
{
Function = "SSL_write",
State = "unchanged",
Score = 1.0,
Method = "byte_exact"
}
],
DeltaSigDigest = "sha256:fullpredicatedigesthere1234567890abcdef1234567890abcdef12345678",
SbomRef = new MicroWitnessSbomRef
{
SbomDigest = "sha256:sbomdigest1234567890abcdef1234567890abcdef1234567890abcdef1234",
BomRef = "openssl-3.0.11",
Purl = "pkg:deb/debian/openssl@3.0.11-1"
},
Tooling = new MicroWitnessTooling
{
BinaryIndexVersion = "2.1.0",
Lifter = "b2r2",
MatchAlgorithm = "semantic_ksg",
NormalizationRecipe = "stella-norm-v3"
},
ComputedAt = new DateTimeOffset(2026, 1, 28, 12, 0, 0, TimeSpan.Zero)
};
}
private static BinaryMicroWitnessPredicate CreateMinimalPredicate()
{
return new BinaryMicroWitnessPredicate
{
SchemaVersion = "1.0.0",
Binary = new MicroWitnessBinaryRef
{
Digest = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
},
Cve = new MicroWitnessCveRef
{
Id = "CVE-2024-0567"
},
Verdict = MicroWitnessVerdicts.Patched,
Confidence = 0.95,
Evidence =
[
new MicroWitnessFunctionEvidence
{
Function = "vulnerable_func",
State = "patched",
Score = 0.95,
Method = "semantic_ksg"
}
],
Tooling = new MicroWitnessTooling
{
BinaryIndexVersion = "2.1.0",
Lifter = "b2r2",
MatchAlgorithm = "semantic_ksg"
},
ComputedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,265 @@
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.Watchlist.Events;
using StellaOps.Attestor.Watchlist.Models;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Events;
public sealed class IdentityAlertEventTests
{
[Fact]
public void ToCanonicalJson_ProducesDeterministicOutput()
{
var evt = CreateTestEvent();
var json1 = evt.ToCanonicalJson();
var json2 = evt.ToCanonicalJson();
json1.Should().Be(json2);
}
[Fact]
public void ToCanonicalJson_IsCamelCase()
{
var evt = CreateTestEvent();
var json = evt.ToCanonicalJson();
json.Should().Contain("eventId");
json.Should().Contain("tenantId");
json.Should().Contain("watchlistEntryId");
json.Should().NotContain("EventId");
}
[Fact]
public void ToCanonicalJson_ExcludesNullValues()
{
var evt = new IdentityAlertEvent
{
EventKind = IdentityAlertEventKinds.IdentityMatched,
TenantId = "tenant-1",
WatchlistEntryId = Guid.NewGuid(),
WatchlistEntryName = "Test Entry",
MatchedIdentity = new IdentityAlertMatchedIdentity
{
Issuer = "https://example.com"
// SAN and KeyId are null
},
RekorEntry = new IdentityAlertRekorEntry
{
Uuid = "rekor-uuid",
LogIndex = 12345,
ArtifactSha256 = "sha256:abc",
IntegratedTimeUtc = DateTimeOffset.UtcNow
},
Severity = IdentityAlertSeverity.Warning
};
var json = evt.ToCanonicalJson();
// Null values should not appear
json.Should().NotContain("subjectAlternativeName\":null");
json.Should().NotContain("keyId\":null");
}
[Fact]
public void ToCanonicalJson_IsValidJson()
{
var evt = CreateTestEvent();
var json = evt.ToCanonicalJson();
var action = () => JsonDocument.Parse(json);
action.Should().NotThrow();
}
[Fact]
public void ToCanonicalJson_HasSortedKeys()
{
var evt = CreateTestEvent();
var json = evt.ToCanonicalJson();
// Parse and extract keys in order
using var doc = JsonDocument.Parse(json);
var keys = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList();
// Keys should be sorted lexicographically
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
keys.Should().Equal(sortedKeys, "Top-level keys should be sorted lexicographically");
}
[Fact]
public void ToCanonicalJson_NestedObjectsHaveSortedKeys()
{
var evt = CreateTestEvent();
var json = evt.ToCanonicalJson();
using var doc = JsonDocument.Parse(json);
// Check matchedIdentity keys are sorted
if (doc.RootElement.TryGetProperty("matchedIdentity", out var matchedIdentity))
{
var keys = matchedIdentity.EnumerateObject().Select(p => p.Name).ToList();
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
keys.Should().Equal(sortedKeys, "matchedIdentity keys should be sorted");
}
// Check rekorEntry keys are sorted
if (doc.RootElement.TryGetProperty("rekorEntry", out var rekorEntry))
{
var keys = rekorEntry.EnumerateObject().Select(p => p.Name).ToList();
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
keys.Should().Equal(sortedKeys, "rekorEntry keys should be sorted");
}
}
[Fact]
public void ToCanonicalJson_HasNoWhitespace()
{
var evt = CreateTestEvent();
var json = evt.ToCanonicalJson();
// Should not contain newlines or indentation
json.Should().NotContain("\n");
json.Should().NotContain("\r");
json.Should().NotContain(" "); // No double spaces (indentation)
}
[Fact]
public void FromMatch_CreatesEventWithCorrectFields()
{
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Actions Watcher",
Issuer = "https://token.actions.githubusercontent.com",
Severity = IdentityAlertSeverity.Critical,
CreatedBy = "admin",
UpdatedBy = "admin"
};
var matchResult = new IdentityMatchResult
{
WatchlistEntry = watchlistEntry,
Fields = MatchedFields.Issuer,
MatchedValues = new MatchedIdentityValues
{
Issuer = "https://token.actions.githubusercontent.com",
SubjectAlternativeName = "repo:org/repo:ref:refs/heads/main"
},
MatchScore = 150
};
var evt = IdentityAlertEvent.FromMatch(
matchResult,
rekorUuid: "rekor-uuid-123",
logIndex: 99999,
artifactSha256: "sha256:abcdef123456",
integratedTimeUtc: DateTimeOffset.Parse("2026-01-29T10:00:00Z"),
suppressedCount: 5);
evt.EventKind.Should().Be(IdentityAlertEventKinds.IdentityMatched);
evt.TenantId.Should().Be("tenant-1");
evt.WatchlistEntryId.Should().Be(watchlistEntry.Id);
evt.WatchlistEntryName.Should().Be("GitHub Actions Watcher");
evt.Severity.Should().Be(IdentityAlertSeverity.Critical);
evt.SuppressedCount.Should().Be(5);
evt.MatchedIdentity.Issuer.Should().Be("https://token.actions.githubusercontent.com");
evt.MatchedIdentity.SubjectAlternativeName.Should().Be("repo:org/repo:ref:refs/heads/main");
evt.RekorEntry.Uuid.Should().Be("rekor-uuid-123");
evt.RekorEntry.LogIndex.Should().Be(99999);
evt.RekorEntry.ArtifactSha256.Should().Be("sha256:abcdef123456");
}
[Fact]
public void EventKinds_HasCorrectConstants()
{
IdentityAlertEventKinds.IdentityMatched.Should().Be("attestor.identity.matched");
IdentityAlertEventKinds.IdentityUnexpected.Should().Be("attestor.identity.unexpected");
}
[Fact]
public void MatchedIdentityValues_ComputeHash_IsDeterministic()
{
var values1 = new MatchedIdentityValues
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com",
KeyId = "key-123"
};
var values2 = new MatchedIdentityValues
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com",
KeyId = "key-123"
};
values1.ComputeHash().Should().Be(values2.ComputeHash());
}
[Fact]
public void MatchedIdentityValues_ComputeHash_DiffersForDifferentValues()
{
var values1 = new MatchedIdentityValues
{
Issuer = "https://example.com"
};
var values2 = new MatchedIdentityValues
{
Issuer = "https://different.com"
};
values1.ComputeHash().Should().NotBe(values2.ComputeHash());
}
[Fact]
public void MatchedIdentityValues_ComputeHash_HandlesNulls()
{
var values = new MatchedIdentityValues
{
Issuer = null,
SubjectAlternativeName = null,
KeyId = null
};
var hash = values.ComputeHash();
hash.Should().NotBeNullOrEmpty();
hash.Should().HaveLength(64); // SHA-256 hex
}
private static IdentityAlertEvent CreateTestEvent()
{
return new IdentityAlertEvent
{
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
EventKind = IdentityAlertEventKinds.IdentityMatched,
TenantId = "tenant-1",
WatchlistEntryId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
WatchlistEntryName = "Test Entry",
MatchedIdentity = new IdentityAlertMatchedIdentity
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com"
},
RekorEntry = new IdentityAlertRekorEntry
{
Uuid = "test-uuid",
LogIndex = 12345,
ArtifactSha256 = "sha256:test",
IntegratedTimeUtc = DateTimeOffset.Parse("2026-01-29T10:00:00Z")
},
Severity = IdentityAlertSeverity.Warning,
OccurredAtUtc = DateTimeOffset.Parse("2026-01-29T10:00:00Z")
};
}
}

View File

@@ -0,0 +1,237 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.Attestor.Watchlist.Matching;
using StellaOps.Attestor.Watchlist.Models;
using StellaOps.Attestor.Watchlist.Storage;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Matching;
public sealed class IdentityMatcherTests
{
private readonly Mock<IWatchlistRepository> _repositoryMock;
private readonly PatternCompiler _patternCompiler;
private readonly Mock<ILogger<IdentityMatcher>> _loggerMock;
private readonly IdentityMatcher _matcher;
public IdentityMatcherTests()
{
_repositoryMock = new Mock<IWatchlistRepository>();
_patternCompiler = new PatternCompiler();
_loggerMock = new Mock<ILogger<IdentityMatcher>>();
_matcher = new IdentityMatcher(_repositoryMock.Object, _patternCompiler, _loggerMock.Object);
}
[Fact]
public async Task MatchAsync_WithNoEntries_ReturnsEmptyList()
{
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
.ReturnsAsync([]);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com"
};
var matches = await _matcher.MatchAsync(identity, "tenant-1");
matches.Should().BeEmpty();
}
[Fact]
public async Task MatchAsync_WithMatchingEntry_ReturnsMatch()
{
var entry = CreateEntry(issuer: "https://example.com");
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
.ReturnsAsync([entry]);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com"
};
var matches = await _matcher.MatchAsync(identity, "tenant-1");
matches.Should().HaveCount(1);
matches[0].WatchlistEntry.Id.Should().Be(entry.Id);
matches[0].Fields.Should().HaveFlag(MatchedFields.Issuer);
}
[Fact]
public async Task MatchAsync_WithNonMatchingEntry_ReturnsEmptyList()
{
var entry = CreateEntry(issuer: "https://different.com");
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
.ReturnsAsync([entry]);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com"
};
var matches = await _matcher.MatchAsync(identity, "tenant-1");
matches.Should().BeEmpty();
}
[Fact]
public async Task MatchAsync_WithDisabledEntry_ReturnsEmptyList()
{
var entry = CreateEntry(issuer: "https://example.com", enabled: false);
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
.ReturnsAsync([entry]);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com"
};
var matches = await _matcher.MatchAsync(identity, "tenant-1");
matches.Should().BeEmpty();
}
[Fact]
public async Task MatchAsync_WithMultipleMatches_ReturnsAll()
{
var entry1 = CreateEntry(issuer: "https://example.com", displayName: "Entry 1");
var entry2 = CreateEntry(san: "user@example.com", displayName: "Entry 2");
_repositoryMock.Setup(r => r.GetActiveForMatchingAsync("tenant-1", default))
.ReturnsAsync([entry1, entry2]);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com"
};
var matches = await _matcher.MatchAsync(identity, "tenant-1");
matches.Should().HaveCount(2);
}
[Fact]
public void TestMatch_WithGlobPattern_MatchesWildcard()
{
var entry = CreateEntry(san: "*@example.com", matchMode: WatchlistMatchMode.Glob);
var identity = new SignerIdentityInput
{
SubjectAlternativeName = "alice@example.com"
};
var match = _matcher.TestMatch(identity, entry);
match.Should().NotBeNull();
match!.Fields.Should().HaveFlag(MatchedFields.SubjectAlternativeName);
}
[Fact]
public void TestMatch_WithPrefixPattern_MatchesPrefix()
{
var entry = CreateEntry(issuer: "https://accounts.google.com/", matchMode: WatchlistMatchMode.Prefix);
var identity = new SignerIdentityInput
{
Issuer = "https://accounts.google.com/oauth2/v1"
};
var match = _matcher.TestMatch(identity, entry);
match.Should().NotBeNull();
}
[Fact]
public void TestMatch_WithMultipleFields_RequiresAllToMatch()
{
var entry = CreateEntry(
issuer: "https://example.com",
san: "user@example.com");
// Only issuer matches
var identity1 = new SignerIdentityInput
{
Issuer = "https://example.com",
SubjectAlternativeName = "other@different.com"
};
var match1 = _matcher.TestMatch(identity1, entry);
match1.Should().BeNull();
// Both match
var identity2 = new SignerIdentityInput
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com"
};
var match2 = _matcher.TestMatch(identity2, entry);
match2.Should().NotBeNull();
match2!.Fields.Should().Be(MatchedFields.Issuer | MatchedFields.SubjectAlternativeName);
}
[Fact]
public void TestMatch_CalculatesMatchScore()
{
var exactEntry = CreateEntry(issuer: "https://example.com", matchMode: WatchlistMatchMode.Exact);
var globEntry = CreateEntry(issuer: "https://*", matchMode: WatchlistMatchMode.Glob);
var identity = new SignerIdentityInput
{
Issuer = "https://example.com"
};
var exactMatch = _matcher.TestMatch(identity, exactEntry);
var globMatch = _matcher.TestMatch(identity, globEntry);
exactMatch!.MatchScore.Should().BeGreaterThan(globMatch!.MatchScore);
}
[Fact]
public void TestMatch_SetsMatchedValues()
{
var entry = CreateEntry(issuer: "https://example.com");
var identity = new SignerIdentityInput
{
Issuer = "https://example.com",
SubjectAlternativeName = "user@example.com",
KeyId = "key-123"
};
var match = _matcher.TestMatch(identity, entry);
match!.MatchedValues.Issuer.Should().Be("https://example.com");
match.MatchedValues.SubjectAlternativeName.Should().Be("user@example.com");
match.MatchedValues.KeyId.Should().Be("key-123");
}
private static WatchedIdentity CreateEntry(
string? issuer = null,
string? san = null,
string? keyId = null,
WatchlistMatchMode matchMode = WatchlistMatchMode.Exact,
bool enabled = true,
string displayName = "Test Entry")
{
return new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = displayName,
Issuer = issuer,
SubjectAlternativeName = san,
KeyId = keyId,
MatchMode = matchMode,
Enabled = enabled,
Severity = IdentityAlertSeverity.Warning,
CreatedBy = "test",
UpdatedBy = "test"
};
}
}

View File

@@ -0,0 +1,354 @@
using FluentAssertions;
using StellaOps.Attestor.Watchlist.Matching;
using StellaOps.Attestor.Watchlist.Models;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Matching;
public sealed class PatternCompilerTests
{
private readonly PatternCompiler _compiler = new();
#region Exact Mode Tests
[Fact]
public void Exact_MatchesSameString()
{
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
pattern.IsMatch("hello").Should().BeTrue();
}
[Fact]
public void Exact_IsCaseInsensitive()
{
var pattern = _compiler.Compile("Hello", WatchlistMatchMode.Exact);
pattern.IsMatch("HELLO").Should().BeTrue();
pattern.IsMatch("hello").Should().BeTrue();
pattern.IsMatch("HeLLo").Should().BeTrue();
}
[Fact]
public void Exact_DoesNotMatchDifferentString()
{
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
pattern.IsMatch("world").Should().BeFalse();
pattern.IsMatch("hello world").Should().BeFalse();
}
[Fact]
public void Exact_HandlesNull()
{
var pattern = _compiler.Compile("hello", WatchlistMatchMode.Exact);
pattern.IsMatch(null).Should().BeFalse();
}
#endregion
#region Prefix Mode Tests
[Fact]
public void Prefix_MatchesStringStartingWithPattern()
{
var pattern = _compiler.Compile("https://", WatchlistMatchMode.Prefix);
pattern.IsMatch("https://example.com").Should().BeTrue();
pattern.IsMatch("https://other.org/path").Should().BeTrue();
}
[Fact]
public void Prefix_IsCaseInsensitive()
{
var pattern = _compiler.Compile("HTTPS://", WatchlistMatchMode.Prefix);
pattern.IsMatch("https://example.com").Should().BeTrue();
}
[Fact]
public void Prefix_DoesNotMatchNonPrefix()
{
var pattern = _compiler.Compile("https://", WatchlistMatchMode.Prefix);
pattern.IsMatch("http://example.com").Should().BeFalse();
}
[Fact]
public void Prefix_MatchesExactSameString()
{
var pattern = _compiler.Compile("https://example.com", WatchlistMatchMode.Prefix);
pattern.IsMatch("https://example.com").Should().BeTrue();
}
#endregion
#region Glob Mode Tests
[Fact]
public void Glob_StarMatchesAnyCharacters()
{
var pattern = _compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
pattern.IsMatch("user@example.com").Should().BeTrue();
pattern.IsMatch("alice.bob@example.com").Should().BeTrue();
pattern.IsMatch("@example.com").Should().BeTrue();
}
[Fact]
public void Glob_QuestionMarkMatchesSingleCharacter()
{
var pattern = _compiler.Compile("user?@example.com", WatchlistMatchMode.Glob);
pattern.IsMatch("user1@example.com").Should().BeTrue();
pattern.IsMatch("userA@example.com").Should().BeTrue();
pattern.IsMatch("user@example.com").Should().BeFalse();
pattern.IsMatch("user12@example.com").Should().BeFalse();
}
[Fact]
public void Glob_IsCaseInsensitive()
{
var pattern = _compiler.Compile("*@EXAMPLE.COM", WatchlistMatchMode.Glob);
pattern.IsMatch("user@example.com").Should().BeTrue();
}
[Fact]
public void Glob_EscapesSpecialRegexCharacters()
{
var pattern = _compiler.Compile("test.example.com", WatchlistMatchMode.Glob);
pattern.IsMatch("test.example.com").Should().BeTrue();
pattern.IsMatch("testXexampleXcom").Should().BeFalse();
}
[Fact]
public void Glob_MatchesGitHubActionsPattern()
{
var pattern = _compiler.Compile("repo:*/main:*", WatchlistMatchMode.Glob);
pattern.IsMatch("repo:org/repo/main:workflow").Should().BeTrue();
}
#endregion
#region Regex Mode Tests
[Fact]
public void Regex_MatchesRegularExpression()
{
var pattern = _compiler.Compile(@"user\d+@example\.com", WatchlistMatchMode.Regex);
pattern.IsMatch("user123@example.com").Should().BeTrue();
pattern.IsMatch("user1@example.com").Should().BeTrue();
pattern.IsMatch("user@example.com").Should().BeFalse();
}
[Fact]
public void Regex_IsCaseInsensitive()
{
var pattern = _compiler.Compile(@"USER\d+", WatchlistMatchMode.Regex);
pattern.IsMatch("user123").Should().BeTrue();
pattern.IsMatch("USER123").Should().BeTrue();
}
[Fact]
public void Regex_HandlesTimeout()
{
// A potentially slow pattern
var compiler = new PatternCompiler(regexTimeout: TimeSpan.FromMilliseconds(10));
var pattern = compiler.Compile(@".*", WatchlistMatchMode.Regex);
// Should complete within timeout
pattern.IsMatch("test").Should().BeTrue();
}
[Fact]
public void Regex_ReturnsFalseOnNull()
{
var pattern = _compiler.Compile(@".*", WatchlistMatchMode.Regex);
pattern.IsMatch(null).Should().BeFalse();
}
#endregion
#region Cache Tests
[Fact]
public void Cache_ReturnsSameInstanceForSamePattern()
{
var pattern1 = _compiler.Compile("test", WatchlistMatchMode.Exact);
var pattern2 = _compiler.Compile("test", WatchlistMatchMode.Exact);
pattern1.Should().BeSameAs(pattern2);
}
[Fact]
public void Cache_ReturnsDifferentInstanceForDifferentMode()
{
var pattern1 = _compiler.Compile("test", WatchlistMatchMode.Exact);
var pattern2 = _compiler.Compile("test", WatchlistMatchMode.Prefix);
pattern1.Should().NotBeSameAs(pattern2);
}
[Fact]
public void ClearCache_RemovesAllCachedPatterns()
{
_compiler.Compile("test1", WatchlistMatchMode.Exact);
_compiler.Compile("test2", WatchlistMatchMode.Exact);
_compiler.CacheCount.Should().Be(2);
_compiler.ClearCache();
_compiler.CacheCount.Should().Be(0);
}
#endregion
#region Validation Tests
[Fact]
public void Validate_ValidExactPattern_ReturnsSuccess()
{
var result = _compiler.Validate("any string", WatchlistMatchMode.Exact);
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_ValidGlobPattern_ReturnsSuccess()
{
var result = _compiler.Validate("*@example.com", WatchlistMatchMode.Glob);
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_InvalidRegexPattern_ReturnsFailure()
{
var result = _compiler.Validate("[invalid(regex", WatchlistMatchMode.Regex);
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().NotBeNullOrEmpty();
}
[Fact]
public void Validate_EmptyPattern_ReturnsSuccess()
{
var result = _compiler.Validate("", WatchlistMatchMode.Exact);
result.IsValid.Should().BeTrue();
}
#endregion
#region Performance Tests
[Fact]
public void Performance_Match100EntriesUnder1Ms()
{
// Pre-compile 100 patterns of various modes
var patterns = new List<ICompiledPattern>();
for (int i = 0; i < 25; i++)
{
patterns.Add(_compiler.Compile($"issuer-{i}", WatchlistMatchMode.Exact));
patterns.Add(_compiler.Compile($"prefix-{i}*", WatchlistMatchMode.Prefix));
patterns.Add(_compiler.Compile($"*glob-{i}*", WatchlistMatchMode.Glob));
patterns.Add(_compiler.Compile($"regex-{i}.*", WatchlistMatchMode.Regex));
}
var testInput = "issuer-12";
// Warm up
foreach (var p in patterns)
{
p.IsMatch(testInput);
}
// Measure
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var p in patterns)
{
p.IsMatch(testInput);
}
sw.Stop();
// 100 matches should complete in under 1ms
sw.ElapsedMilliseconds.Should().BeLessThan(1,
"Matching 100 pre-compiled patterns against an input should take less than 1ms");
}
[Fact]
public void Performance_CachedPatternsAreFast()
{
// First compilation (creates cache entry)
var pattern = _compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
// Should return cached instance
_compiler.Compile("*@example.com", WatchlistMatchMode.Glob);
}
sw.Stop();
// 1000 cache hits should be very fast (< 10ms)
sw.ElapsedMilliseconds.Should().BeLessThan(10,
"1000 cache lookups should complete in under 10ms");
}
#endregion
#region Unicode Edge Case Tests
[Fact]
public void Exact_MatchesUnicodeStrings()
{
var pattern = _compiler.Compile("用户@例子.com", WatchlistMatchMode.Exact);
pattern.IsMatch("用户@例子.com").Should().BeTrue();
pattern.IsMatch("用户@例子.org").Should().BeFalse();
}
[Fact]
public void Glob_MatchesUnicodeWithWildcards()
{
var pattern = _compiler.Compile("*@例子.com", WatchlistMatchMode.Glob);
pattern.IsMatch("用户@例子.com").Should().BeTrue();
pattern.IsMatch("管理员@例子.com").Should().BeTrue();
}
[Fact]
public void Exact_MatchesCyrillicCharacters()
{
var pattern = _compiler.Compile("пользователь@пример.com", WatchlistMatchMode.Exact);
pattern.IsMatch("пользователь@пример.com").Should().BeTrue();
}
[Fact]
public void Prefix_MatchesGreekCharacters()
{
var pattern = _compiler.Compile("χρήστης@", WatchlistMatchMode.Prefix);
pattern.IsMatch("χρήστης@example.com").Should().BeTrue();
}
[Fact]
public void Glob_MatchesEmojiCharacters()
{
var pattern = _compiler.Compile("*@*.com", WatchlistMatchMode.Glob);
pattern.IsMatch("user🔐@example.com").Should().BeTrue();
}
[Fact]
public void Regex_MatchesUnicodeClasses()
{
// Match any Unicode letter followed by @example.com
var pattern = _compiler.Compile(@"^\p{L}+@example\.com$", WatchlistMatchMode.Regex);
pattern.IsMatch("user@example.com").Should().BeTrue();
pattern.IsMatch("用户@example.com").Should().BeTrue();
pattern.IsMatch("χρήστης@example.com").Should().BeTrue();
}
[Fact]
public void Exact_MatchesMixedScriptStrings()
{
var pattern = _compiler.Compile("user用户@example例子.com", WatchlistMatchMode.Exact);
pattern.IsMatch("user用户@example例子.com").Should().BeTrue();
}
[Fact]
public void Glob_HandlesUnicodeNormalization()
{
// é can be represented as single char or combining chars
var pattern = _compiler.Compile("café*", WatchlistMatchMode.Glob);
pattern.IsMatch("café@example.com").Should().BeTrue();
}
#endregion
}

View File

@@ -0,0 +1,223 @@
using FluentAssertions;
using StellaOps.Attestor.Watchlist.Models;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Models;
public sealed class WatchedIdentityTests
{
[Fact]
public void Validate_WithNoIdentityFields_ReturnsError()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("At least one identity field"));
}
[Fact]
public void Validate_WithIssuerOnly_ReturnsSuccess()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
Issuer = "https://token.actions.githubusercontent.com",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void Validate_WithSanOnly_ReturnsSuccess()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
SubjectAlternativeName = "user@example.com",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_WithKeyIdOnly_ReturnsSuccess()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
KeyId = "key-123",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_WithMissingDisplayName_ReturnsError()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "",
Issuer = "https://example.com",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("DisplayName"));
}
[Fact]
public void Validate_WithMissingTenantId_ReturnsError()
{
var entry = new WatchedIdentity
{
TenantId = "",
DisplayName = "Test",
Issuer = "https://example.com",
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("TenantId"));
}
[Fact]
public void Validate_WithInvalidRegex_ReturnsError()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
Issuer = "[invalid(regex",
MatchMode = WatchlistMatchMode.Regex,
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("regex") || e.Contains("Regex") || e.Contains("Invalid"));
}
[Fact]
public void Validate_WithTooLongGlobPattern_ReturnsError()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
Issuer = new string('a', 300),
MatchMode = WatchlistMatchMode.Glob,
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("256"));
}
[Fact]
public void Validate_WithValidGlobPattern_ReturnsSuccess()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test Entry",
SubjectAlternativeName = "*@example.com",
MatchMode = WatchlistMatchMode.Glob,
CreatedBy = "user",
UpdatedBy = "user"
};
var result = entry.Validate();
result.IsValid.Should().BeTrue();
}
[Fact]
public void WithUpdated_SetsUpdatedAtAndUpdatedBy()
{
var original = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test",
Issuer = "https://example.com",
CreatedBy = "original-user",
UpdatedBy = "original-user",
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
};
var updated = original.WithUpdated("new-user");
updated.UpdatedBy.Should().Be("new-user");
updated.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1));
updated.CreatedBy.Should().Be("original-user"); // Unchanged
}
[Theory]
[InlineData(WatchlistScope.Tenant)]
[InlineData(WatchlistScope.Global)]
[InlineData(WatchlistScope.System)]
public void Scope_DefaultsToTenant_CanBeSet(WatchlistScope scope)
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test",
Issuer = "https://example.com",
Scope = scope,
CreatedBy = "user",
UpdatedBy = "user"
};
entry.Scope.Should().Be(scope);
}
[Fact]
public void SuppressDuplicatesMinutes_DefaultsTo60()
{
var entry = new WatchedIdentity
{
TenantId = "tenant-1",
DisplayName = "Test",
Issuer = "https://example.com",
CreatedBy = "user",
UpdatedBy = "user"
};
entry.SuppressDuplicatesMinutes.Should().Be(60);
}
}

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// IdentityMonitorServiceIntegrationTests.cs
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-005
// Description: Integration tests verifying the full flow: entry → match → alert event.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Watchlist.Events;
using StellaOps.Attestor.Watchlist.Matching;
using StellaOps.Attestor.Watchlist.Models;
using StellaOps.Attestor.Watchlist.Monitoring;
using StellaOps.Attestor.Watchlist.Storage;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Monitoring;
/// <summary>
/// Integration tests for the identity monitoring service.
/// Verifies the complete flow: AttestorEntry → IIdentityMatcher → IIdentityAlertPublisher.
/// </summary>
public sealed class IdentityMonitorServiceIntegrationTests
{
private readonly InMemoryWatchlistRepository _watchlistRepository;
private readonly InMemoryAlertDedupRepository _dedupRepository;
private readonly InMemoryIdentityAlertPublisher _alertPublisher;
private readonly IdentityMatcher _matcher;
private readonly IdentityMonitorService _service;
public IdentityMonitorServiceIntegrationTests()
{
_watchlistRepository = new InMemoryWatchlistRepository();
_dedupRepository = new InMemoryAlertDedupRepository();
_alertPublisher = new InMemoryIdentityAlertPublisher();
var cache = new MemoryCache(new MemoryCacheOptions());
var patternCompiler = new PatternCompiler();
_matcher = new IdentityMatcher(
_watchlistRepository,
patternCompiler,
cache,
NullLogger<IdentityMatcher>.Instance);
_service = new IdentityMonitorService(
_matcher,
_dedupRepository,
_alertPublisher,
NullLogger<IdentityMonitorService>.Instance);
}
[Fact]
public async Task ProcessEntry_WithMatchingIdentity_EmitsAlert()
{
// Arrange: Create a watchlist entry
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Actions Watcher",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Critical,
Enabled = true,
SuppressDuplicatesMinutes = 60,
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
// Arrange: Create an attestor entry with matching identity
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "test-rekor-uuid-123",
LogIndex = 99999,
ArtifactSha256 = "sha256:abcdef123456",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com",
SubjectAlternativeName = "repo:org/repo:ref:refs/heads/main"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert: Alert should be emitted
_alertPublisher.PublishedEvents.Should().HaveCount(1);
var alert = _alertPublisher.PublishedEvents[0];
alert.EventKind.Should().Be(IdentityAlertEventKinds.IdentityMatched);
alert.TenantId.Should().Be("tenant-1");
alert.WatchlistEntryId.Should().Be(watchlistEntry.Id);
alert.WatchlistEntryName.Should().Be("GitHub Actions Watcher");
alert.Severity.Should().Be(IdentityAlertSeverity.Critical);
alert.MatchedIdentity.Issuer.Should().Be("https://token.actions.githubusercontent.com");
alert.RekorEntry.Uuid.Should().Be("test-rekor-uuid-123");
alert.RekorEntry.LogIndex.Should().Be(99999);
}
[Fact]
public async Task ProcessEntry_WithNonMatchingIdentity_DoesNotEmitAlert()
{
// Arrange: Create a watchlist entry for GitHub
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Actions Watcher",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Critical,
Enabled = true,
SuppressDuplicatesMinutes = 60,
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
// Arrange: Create an attestor entry with DIFFERENT issuer
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "test-rekor-uuid-456",
LogIndex = 99998,
ArtifactSha256 = "sha256:different123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://accounts.google.com", // Different issuer
SubjectAlternativeName = "user@example.com"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert: No alert should be emitted
_alertPublisher.PublishedEvents.Should().BeEmpty();
}
[Fact]
public async Task ProcessEntry_WithDuplicateIdentity_SuppressesDuplicateAlerts()
{
// Arrange: Create a watchlist entry with short dedup window
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Actions Watcher",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Warning,
Enabled = true,
SuppressDuplicatesMinutes = 60, // 60 minute dedup window
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
// Arrange: Create an attestor entry
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "test-rekor-uuid-789",
LogIndex = 99997,
ArtifactSha256 = "sha256:first123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com"
}
};
// Act: Process the same identity twice
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Second entry with same identity (should be deduplicated)
var entryInfo2 = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "test-rekor-uuid-790",
LogIndex = 99996,
ArtifactSha256 = "sha256:second123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com"
}
};
await _service.ProcessEntryAsync(entryInfo2, CancellationToken.None);
// Assert: Only first alert should be emitted (second is suppressed)
_alertPublisher.PublishedEvents.Should().HaveCount(1);
_alertPublisher.PublishedEvents[0].RekorEntry.Uuid.Should().Be("test-rekor-uuid-789");
}
[Fact]
public async Task ProcessEntry_WithGlobPattern_MatchesWildcard()
{
// Arrange: Create a watchlist entry with glob pattern
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "All GitHub Repos",
Issuer = "https://token.actions.githubusercontent.com",
SubjectAlternativeName = "repo:org/*",
MatchMode = WatchlistMatchMode.Glob,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Info,
Enabled = true,
SuppressDuplicatesMinutes = 1,
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
// Arrange: Create an entry matching the glob pattern
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "glob-test-uuid",
LogIndex = 12345,
ArtifactSha256 = "sha256:glob123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com",
SubjectAlternativeName = "repo:org/my-repo:ref:refs/heads/main"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert
_alertPublisher.PublishedEvents.Should().HaveCount(1);
_alertPublisher.PublishedEvents[0].MatchedIdentity.SubjectAlternativeName
.Should().Be("repo:org/my-repo:ref:refs/heads/main");
}
[Fact]
public async Task ProcessEntry_WithDisabledEntry_DoesNotMatch()
{
// Arrange: Create a DISABLED watchlist entry
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "Disabled Watcher",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Critical,
Enabled = false, // Disabled
SuppressDuplicatesMinutes = 60,
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "disabled-test-uuid",
LogIndex = 11111,
ArtifactSha256 = "sha256:disabled123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert: No alert (entry is disabled)
_alertPublisher.PublishedEvents.Should().BeEmpty();
}
[Fact]
public async Task ProcessEntry_WithGlobalScope_MatchesAcrossTenants()
{
// Arrange: Create a GLOBAL scope watchlist entry owned by tenant-admin
var watchlistEntry = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-admin",
DisplayName = "Global GitHub Watcher",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Global, // Global scope
Severity = IdentityAlertSeverity.Warning,
Enabled = true,
SuppressDuplicatesMinutes = 60,
CreatedBy = "admin",
UpdatedBy = "admin"
};
await _watchlistRepository.UpsertAsync(watchlistEntry, CancellationToken.None);
// Arrange: Entry from different tenant
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-other", // Different tenant
RekorUuid = "global-test-uuid",
LogIndex = 22222,
ArtifactSha256 = "sha256:global123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert: Global entry should match across tenants
_alertPublisher.PublishedEvents.Should().HaveCount(1);
}
[Fact]
public async Task ProcessEntry_WithMultipleMatches_EmitsMultipleAlerts()
{
// Arrange: Create TWO matching watchlist entries
var entry1 = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Watcher 1",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Critical,
Enabled = true,
SuppressDuplicatesMinutes = 1,
CreatedBy = "test",
UpdatedBy = "test"
};
var entry2 = new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "tenant-1",
DisplayName = "GitHub Watcher 2",
Issuer = "https://token.actions.githubusercontent.com",
MatchMode = WatchlistMatchMode.Prefix,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Warning,
Enabled = true,
SuppressDuplicatesMinutes = 1,
CreatedBy = "test",
UpdatedBy = "test"
};
await _watchlistRepository.UpsertAsync(entry1, CancellationToken.None);
await _watchlistRepository.UpsertAsync(entry2, CancellationToken.None);
var entryInfo = new AttestorEntryInfo
{
TenantId = "tenant-1",
RekorUuid = "multi-match-uuid",
LogIndex = 33333,
ArtifactSha256 = "sha256:multi123",
IntegratedTimeUtc = DateTimeOffset.UtcNow,
Identity = new SignerIdentityInput
{
Issuer = "https://token.actions.githubusercontent.com"
}
};
// Act
await _service.ProcessEntryAsync(entryInfo, CancellationToken.None);
// Assert: Both entries should match and emit alerts
_alertPublisher.PublishedEvents.Should().HaveCount(2);
_alertPublisher.PublishedEvents.Should().Contain(e => e.WatchlistEntryName == "GitHub Watcher 1");
_alertPublisher.PublishedEvents.Should().Contain(e => e.WatchlistEntryName == "GitHub Watcher 2");
}
}
/// <summary>
/// Test helper: Attestor entry information for processing.
/// </summary>
public sealed record AttestorEntryInfo
{
public required string TenantId { get; init; }
public required string RekorUuid { get; init; }
public required long LogIndex { get; init; }
public required string ArtifactSha256 { get; init; }
public required DateTimeOffset IntegratedTimeUtc { get; init; }
public required SignerIdentityInput Identity { get; init; }
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,256 @@
// -----------------------------------------------------------------------------
// PostgresWatchlistRepositoryTests.cs
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-004
// Description: Integration tests for PostgreSQL watchlist repository using Testcontainers.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Attestor.Infrastructure.Watchlist;
using StellaOps.Attestor.Watchlist.Models;
using StellaOps.Attestor.Watchlist.Storage;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Storage;
/// <summary>
/// Integration tests for PostgresWatchlistRepository.
/// These tests verify CRUD operations against a real PostgreSQL database via Testcontainers.
/// </summary>
[Trait("Category", "Integration")]
[Collection(WatchlistPostgresCollection.Name)]
public sealed class PostgresWatchlistRepositoryTests : IAsyncLifetime
{
private readonly WatchlistPostgresFixture _fixture;
private PostgresWatchlistRepository _repository = null!;
public PostgresWatchlistRepositoryTests(WatchlistPostgresFixture fixture)
{
_fixture = fixture;
}
public async ValueTask InitializeAsync()
{
var dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
_repository = new PostgresWatchlistRepository(
dataSource,
NullLogger<PostgresWatchlistRepository>.Instance);
await _fixture.TruncateAllTablesAsync();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task UpsertAsync_CreatesNewEntry_ReturnsEntry()
{
var entry = CreateTestEntry();
var result = await _repository.UpsertAsync(entry, CancellationToken.None);
result.Should().NotBeNull();
result.Id.Should().NotBe(Guid.Empty);
result.DisplayName.Should().Be(entry.DisplayName);
}
[Fact]
public async Task GetAsync_ExistingEntry_ReturnsEntry()
{
var entry = CreateTestEntry();
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
result.Should().NotBeNull();
result!.Id.Should().Be(created.Id);
}
[Fact]
public async Task GetAsync_NonExistentEntry_ReturnsNull()
{
var result = await _repository.GetAsync(Guid.NewGuid(), CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task ListAsync_WithTenantFilter_ReturnsOnlyTenantEntries()
{
var entry1 = CreateTestEntry("tenant-1");
var entry2 = CreateTestEntry("tenant-2");
await _repository.UpsertAsync(entry1, CancellationToken.None);
await _repository.UpsertAsync(entry2, CancellationToken.None);
var results = await _repository.ListAsync("tenant-1", includeGlobal: false, CancellationToken.None);
results.Should().AllSatisfy(e => e.TenantId.Should().Be("tenant-1"));
}
[Fact]
public async Task ListAsync_IncludeGlobal_ReturnsGlobalEntries()
{
var tenantEntry = CreateTestEntry("tenant-1", WatchlistScope.Tenant);
var globalEntry = CreateTestEntry("admin", WatchlistScope.Global);
await _repository.UpsertAsync(tenantEntry, CancellationToken.None);
await _repository.UpsertAsync(globalEntry, CancellationToken.None);
var results = await _repository.ListAsync("tenant-1", includeGlobal: true, CancellationToken.None);
results.Should().Contain(e => e.Scope == WatchlistScope.Global);
}
[Fact]
public async Task DeleteAsync_ExistingEntry_RemovesEntry()
{
var entry = CreateTestEntry();
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
await _repository.DeleteAsync(created.Id, entry.TenantId, CancellationToken.None);
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task DeleteAsync_TenantIsolation_CannotDeleteOtherTenantEntry()
{
var entry = CreateTestEntry("tenant-1");
var created = await _repository.UpsertAsync(entry, CancellationToken.None);
// Try to delete with different tenant
await _repository.DeleteAsync(created.Id, "tenant-2", CancellationToken.None);
// Entry should still exist
var result = await _repository.GetAsync(created.Id, CancellationToken.None);
result.Should().NotBeNull();
}
[Fact]
public async Task GetActiveForMatchingAsync_ReturnsOnlyEnabledEntries()
{
var enabledEntry = CreateTestEntry("tenant-1", enabled: true);
var disabledEntry = CreateTestEntry("tenant-1", enabled: false);
await _repository.UpsertAsync(enabledEntry, CancellationToken.None);
await _repository.UpsertAsync(disabledEntry, CancellationToken.None);
var results = await _repository.GetActiveForMatchingAsync("tenant-1", CancellationToken.None);
results.Should().AllSatisfy(e => e.Enabled.Should().BeTrue());
}
private static WatchedIdentity CreateTestEntry(
string tenantId = "test-tenant",
WatchlistScope scope = WatchlistScope.Tenant,
bool enabled = true)
{
return new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
DisplayName = $"Test Entry {Guid.NewGuid():N}",
Issuer = "https://example.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = scope,
Severity = IdentityAlertSeverity.Warning,
Enabled = enabled,
SuppressDuplicatesMinutes = 60,
CreatedBy = "test",
UpdatedBy = "test"
};
}
}
/// <summary>
/// Integration tests for PostgresAlertDedupRepository.
/// </summary>
[Trait("Category", "Integration")]
[Collection(WatchlistPostgresCollection.Name)]
public sealed class PostgresAlertDedupRepositoryTests : IAsyncLifetime
{
private readonly WatchlistPostgresFixture _fixture;
private PostgresAlertDedupRepository _repository = null!;
private PostgresWatchlistRepository _watchlistRepo = null!;
public PostgresAlertDedupRepositoryTests(WatchlistPostgresFixture fixture)
{
_fixture = fixture;
}
public async ValueTask InitializeAsync()
{
var dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
_repository = new PostgresAlertDedupRepository(dataSource);
_watchlistRepo = new PostgresWatchlistRepository(
dataSource,
NullLogger<PostgresWatchlistRepository>.Instance);
await _fixture.TruncateAllTablesAsync();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task CheckAndUpdateAsync_FirstAlert_AllowsAlert()
{
var watchlistEntry = CreateWatchlistEntry();
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
var result = await _repository.CheckAndUpdateAsync(
created.Id, "test-identity-hash", 60, CancellationToken.None);
result.ShouldSend.Should().BeTrue();
}
[Fact]
public async Task CheckAndUpdateAsync_DuplicateWithinWindow_SuppressesAlert()
{
var watchlistEntry = CreateWatchlistEntry();
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
// First alert
await _repository.CheckAndUpdateAsync(
created.Id, "test-identity-hash", 60, CancellationToken.None);
// Second alert within window
var result = await _repository.CheckAndUpdateAsync(
created.Id, "test-identity-hash", 60, CancellationToken.None);
// The dedup logic should track the duplicate
result.Should().NotBeNull();
}
[Fact]
public async Task CleanupExpiredAsync_RemovesOldRecords()
{
// Insert a dedup record, then clean up (all recent records will survive)
var watchlistEntry = CreateWatchlistEntry();
var created = await _watchlistRepo.UpsertAsync(watchlistEntry, CancellationToken.None);
await _repository.CheckAndUpdateAsync(
created.Id, "test-hash", 60, CancellationToken.None);
// Cleanup should not remove recent records (< 7 days old)
var removed = await _repository.CleanupExpiredAsync(CancellationToken.None);
removed.Should().Be(0);
}
private static WatchedIdentity CreateWatchlistEntry()
{
return new WatchedIdentity
{
Id = Guid.NewGuid(),
TenantId = "test-tenant",
DisplayName = $"Test Entry {Guid.NewGuid():N}",
Issuer = "https://example.com",
MatchMode = WatchlistMatchMode.Exact,
Scope = WatchlistScope.Tenant,
Severity = IdentityAlertSeverity.Warning,
Enabled = true,
SuppressDuplicatesMinutes = 60,
CreatedBy = "test",
UpdatedBy = "test"
};
}
}

View File

@@ -0,0 +1,106 @@
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Attestor.Watchlist.Tests.Storage;
/// <summary>
/// PostgreSQL integration test fixture for watchlist repository tests.
/// Starts a Testcontainers PostgreSQL instance and applies the watchlist migration.
/// </summary>
public sealed class WatchlistPostgresFixture : IAsyncLifetime
{
private const string PostgresImage = "postgres:16-alpine";
private PostgreSqlContainer? _container;
public string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not initialized");
public async ValueTask InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder()
.WithImage(PostgresImage)
.Build();
await _container.StartAsync();
}
catch (ArgumentException ex) when (
string.Equals(ex.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal) ||
ex.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase))
{
if (_container is not null)
{
try { await _container.DisposeAsync(); } catch { /* ignore */ }
}
_container = null;
throw SkipException.ForSkip(
$"Watchlist integration tests require Docker/Testcontainers. Skipping: {ex.Message}");
}
// Create the attestor schema and apply the migration
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var schemaCmd = new NpgsqlCommand("CREATE SCHEMA IF NOT EXISTS attestor;", conn);
await schemaCmd.ExecuteNonQueryAsync();
var migrationSql = await LoadMigrationSqlAsync();
await using var migrationCmd = new NpgsqlCommand(migrationSql, conn);
migrationCmd.CommandTimeout = 60;
await migrationCmd.ExecuteNonQueryAsync();
}
public async ValueTask DisposeAsync()
{
if (_container is not null)
{
await _container.DisposeAsync();
}
}
/// <summary>
/// Truncates all watchlist tables for test isolation.
/// </summary>
public async Task TruncateAllTablesAsync()
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"""
TRUNCATE TABLE attestor.identity_alert_dedup CASCADE;
TRUNCATE TABLE attestor.identity_watchlist CASCADE;
""", conn);
await cmd.ExecuteNonQueryAsync();
}
private static async Task<string> LoadMigrationSqlAsync()
{
var directory = AppContext.BaseDirectory;
while (directory is not null)
{
var migrationPath = Path.Combine(directory, "src", "Attestor",
"StellaOps.Attestor", "StellaOps.Attestor.Infrastructure",
"Migrations", "20260129_001_create_identity_watchlist.sql");
if (File.Exists(migrationPath))
{
return await File.ReadAllTextAsync(migrationPath);
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException(
"Cannot find watchlist migration SQL. Ensure the test runs from within the repository.");
}
}
[CollectionDefinition(Name)]
public sealed class WatchlistPostgresCollection : ICollectionFixture<WatchlistPostgresFixture>
{
public const string Name = "WatchlistPostgres";
}