up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestation;
/// <summary>
/// Extension methods for converting between <see cref="DsseEnvelope"/> domain types
/// and API DTO representations.
/// </summary>
public static class DsseEnvelopeExtensions
{
/// <summary>
/// Converts a <see cref="DsseEnvelope"/> to a JSON-serializable dictionary
/// suitable for API responses.
/// </summary>
public static Dictionary<string, object> ToSerializableDict(this DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
return new Dictionary<string, object>
{
["payloadType"] = envelope.PayloadType,
["payload"] = Convert.ToBase64String(envelope.Payload.Span),
["signatures"] = envelope.Signatures.Select(s => new Dictionary<string, object?>
{
["keyid"] = s.KeyId,
["sig"] = s.Signature
}).ToList()
};
}
/// <summary>
/// Creates a <see cref="DsseEnvelope"/> from base64-encoded payload and signature data.
/// </summary>
/// <param name="payloadType">The DSSE payload type URI.</param>
/// <param name="payloadBase64">Base64-encoded payload bytes.</param>
/// <param name="signatures">Collection of signature data as (keyId, signatureBase64) tuples.</param>
/// <returns>A new <see cref="DsseEnvelope"/> instance.</returns>
public static DsseEnvelope FromBase64(
string payloadType,
string payloadBase64,
IEnumerable<(string? KeyId, string SignatureBase64)> signatures)
{
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
ArgumentException.ThrowIfNullOrWhiteSpace(payloadBase64);
ArgumentNullException.ThrowIfNull(signatures);
var payloadBytes = Convert.FromBase64String(payloadBase64);
var dsseSignatures = signatures.Select(s => new DsseSignature(s.SignatureBase64, s.KeyId));
return new DsseEnvelope(payloadType, payloadBytes, dsseSignatures);
}
/// <summary>
/// Gets the payload as a UTF-8 string.
/// </summary>
public static string GetPayloadString(this DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
return System.Text.Encoding.UTF8.GetString(envelope.Payload.Span);
}
/// <summary>
/// Gets the payload as a base64-encoded string.
/// </summary>
public static string GetPayloadBase64(this DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
return Convert.ToBase64String(envelope.Payload.Span);
}
}

View File

@@ -50,6 +50,7 @@ public static class DsseHelper
var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false);
var dsseSignature = DsseSignature.FromBytes(signatureBytes, keyId);
return new DsseEnvelope(statement.Type, payloadBytes, new[] { dsseSignature });
var payloadType = statement.Type ?? "https://in-toto.io/Statement/v1";
return new DsseEnvelope(payloadType, payloadBytes, new[] { dsseSignature });
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestation;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Signing;
/// <summary>
/// Signs In-toto statements as DSSE envelopes using Authority's active signing key.
/// Supports SBOM, Graph, VEX, Replay, and other StellaOps predicate types.
/// </summary>
public interface IAuthorityDsseStatementSigner
{
/// <summary>
/// Signs an In-toto statement and returns a DSSE envelope.
/// </summary>
/// <param name="statement">The In-toto statement to sign.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The signed DSSE envelope containing the statement.</returns>
Task<DsseEnvelope> SignStatementAsync(InTotoStatement statement, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the key ID of the active signing key.
/// </summary>
string? ActiveKeyId { get; }
/// <summary>
/// Indicates whether signing is enabled and configured.
/// </summary>
bool IsEnabled { get; }
}
/// <summary>
/// Result of signing an In-toto statement.
/// </summary>
/// <param name="Envelope">The signed DSSE envelope.</param>
/// <param name="KeyId">The key ID used for signing.</param>
/// <param name="Algorithm">The signing algorithm used.</param>
public sealed record DsseStatementSignResult(
DsseEnvelope Envelope,
string KeyId,
string Algorithm);
/// <summary>
/// Implementation of <see cref="IAuthorityDsseStatementSigner"/> that uses Authority's
/// signing key manager to sign In-toto statements with DSSE envelopes.
/// </summary>
internal sealed class AuthorityDsseStatementSigner : IAuthorityDsseStatementSigner
{
private readonly AuthoritySigningKeyManager keyManager;
private readonly ICryptoProviderRegistry registry;
private readonly ILogger<AuthorityDsseStatementSigner> logger;
public AuthorityDsseStatementSigner(
AuthoritySigningKeyManager keyManager,
ICryptoProviderRegistry registry,
ILogger<AuthorityDsseStatementSigner> logger)
{
this.keyManager = keyManager ?? throw new ArgumentNullException(nameof(keyManager));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string? ActiveKeyId => keyManager.Snapshot.ActiveKeyId;
public bool IsEnabled => !string.IsNullOrWhiteSpace(keyManager.Snapshot.ActiveKeyId);
public async Task<DsseEnvelope> SignStatementAsync(InTotoStatement statement, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(statement);
var snapshot = keyManager.Snapshot;
if (string.IsNullOrWhiteSpace(snapshot.ActiveKeyId))
{
throw new InvalidOperationException("Authority signing is not configured. Enable signing before creating attestations.");
}
if (string.IsNullOrWhiteSpace(snapshot.ActiveProvider))
{
throw new InvalidOperationException("Authority signing provider is not configured.");
}
var signerResolution = registry.ResolveSigner(
CryptoCapability.Signing,
GetAlgorithmForKey(snapshot),
new CryptoKeyReference(snapshot.ActiveKeyId!),
snapshot.ActiveProvider);
var adapter = new AuthoritySignerAdapter(signerResolution.Signer);
logger.LogDebug(
"Signing In-toto statement with predicate type {PredicateType} using key {KeyId}.",
statement.PredicateType,
snapshot.ActiveKeyId);
var envelope = await DsseHelper.WrapAsync(statement, adapter, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Created DSSE envelope for predicate type {PredicateType}, key {KeyId}, {SignatureCount} signature(s).",
statement.PredicateType,
snapshot.ActiveKeyId,
envelope.Signatures.Count);
return envelope;
}
private static string GetAlgorithmForKey(SigningKeySnapshot snapshot)
{
// Default to ES256 if not explicitly specified
// The AuthoritySigningKeyManager normalises algorithm during load
return SignatureAlgorithms.Es256;
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestation;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Signing;
/// <summary>
/// Adapts an <see cref="ICryptoSigner"/> to the <see cref="IAuthoritySigner"/> interface
/// used by attestation signing helpers.
/// </summary>
internal sealed class AuthoritySignerAdapter : IAuthoritySigner
{
private readonly ICryptoSigner signer;
public AuthoritySignerAdapter(ICryptoSigner signer)
{
this.signer = signer ?? throw new ArgumentNullException(nameof(signer));
}
public Task<string> GetKeyIdAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(signer.KeyId);
}
public async Task<byte[]> SignAsync(ReadOnlyMemory<byte> paePayload, CancellationToken cancellationToken = default)
{
return await signer.SignAsync(paePayload, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -32,6 +32,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">

View File

@@ -0,0 +1,2 @@
results/
__pycache__/

View File

@@ -0,0 +1,11 @@
{
"graph": {
"nodes": [
{"id": "pkg:pypi/demo-lib@1.0.0", "type": "package"},
{"id": "pkg:generic/demo-cli@0.4.2", "type": "package"}
],
"edges": [
{"from": "pkg:generic/demo-cli@0.4.2", "to": "pkg:pypi/demo-lib@1.0.0", "type": "depends_on"}
]
}
}

View File

@@ -0,0 +1 @@
{"event":"call","func":"demo","module":"demo-lib","ts":"2025-11-01T00:00:00Z"}

View File

@@ -1,3 +0,0 @@
38453c9c0e0a90d22d7048d3201bf1b5665eb483e6682db1a7112f8e4f4fa1e6 configs/scanners.json
577f932bbb00dbd596e46b96d5fbb9561506c7730c097e381a6b34de40402329 inputs/sboms/sample-spdx.json
1b54ce4087800cfe1d5ac439c10a1f131b7476b2093b79d8cd0a29169314291f inputs/vex/sample-openvex.json

View File

@@ -1,21 +0,0 @@
scanner,sbom,vex,mode,run,hash,finding_count
mock,sample-spdx.json,sample-openvex.json,canonical,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,canonical,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
mock,sample-spdx.json,sample-openvex.json,shuffled,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
1 scanner sbom vex mode run hash finding_count
2 mock sample-spdx.json sample-openvex.json canonical 0 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
3 mock sample-spdx.json sample-openvex.json shuffled 0 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
4 mock sample-spdx.json sample-openvex.json canonical 1 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
5 mock sample-spdx.json sample-openvex.json shuffled 1 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
6 mock sample-spdx.json sample-openvex.json canonical 2 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
7 mock sample-spdx.json sample-openvex.json shuffled 2 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
8 mock sample-spdx.json sample-openvex.json canonical 3 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
9 mock sample-spdx.json sample-openvex.json shuffled 3 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
10 mock sample-spdx.json sample-openvex.json canonical 4 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
11 mock sample-spdx.json sample-openvex.json shuffled 4 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
12 mock sample-spdx.json sample-openvex.json canonical 5 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
13 mock sample-spdx.json sample-openvex.json shuffled 5 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
14 mock sample-spdx.json sample-openvex.json canonical 6 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
15 mock sample-spdx.json sample-openvex.json shuffled 6 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
16 mock sample-spdx.json sample-openvex.json canonical 7 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
17 mock sample-spdx.json sample-openvex.json shuffled 7 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
18 mock sample-spdx.json sample-openvex.json canonical 8 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
19 mock sample-spdx.json sample-openvex.json shuffled 8 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
20 mock sample-spdx.json sample-openvex.json canonical 9 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
21 mock sample-spdx.json sample-openvex.json shuffled 9 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2

View File

@@ -1,3 +0,0 @@
{
"determinism_rate": 1.0
}

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Reachability dataset hash helper for optional BENCH-DETERMINISM reachability runs.
- Computes deterministic hashes for graph JSON and runtime NDJSON inputs.
- Emits `results-reach.csv` and `dataset.sha256` in the chosen output directory.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import glob
from pathlib import Path
from typing import Iterable, List
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def expand_files(patterns: Iterable[str]) -> List[Path]:
files: List[Path] = []
for pattern in patterns:
if not pattern:
continue
for path_str in sorted(glob.glob(pattern)):
path = Path(path_str)
if path.is_file():
files.append(path)
return files
def hash_files(paths: List[Path]) -> List[tuple[str, str]]:
rows: List[tuple[str, str]] = []
for path in paths:
rows.append((path.name, sha256_bytes(path.read_bytes())))
return rows
def write_manifest(paths: List[Path], manifest_path: Path) -> None:
lines = []
for path in sorted(paths, key=lambda p: str(p)):
digest = sha256_bytes(path.read_bytes())
try:
rel = path.resolve().relative_to(Path.cwd().resolve())
except ValueError:
rel = path.resolve()
lines.append(f"{digest} {rel.as_posix()}\n")
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text("".join(lines), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Reachability dataset hash helper")
parser.add_argument("--graphs", nargs="*", default=["inputs/graphs/*.json"], help="Glob(s) for graph JSON files")
parser.add_argument("--runtime", nargs="*", default=["inputs/runtime/*.ndjson", "inputs/runtime/*.ndjson.gz"], help="Glob(s) for runtime NDJSON files")
parser.add_argument("--output", default="results", help="Output directory")
args = parser.parse_args()
graphs = expand_files(args.graphs)
runtime = expand_files(args.runtime)
if not graphs:
raise SystemExit("No graph inputs found; supply --graphs globs")
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
dataset_manifest_files = graphs + runtime
write_manifest(dataset_manifest_files, output_dir / "dataset.sha256")
csv_path = output_dir / "results-reach.csv"
fieldnames = ["type", "file", "sha256"]
with csv_path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for name, digest in hash_files(graphs):
writer.writerow({"type": "graph", "file": name, "sha256": digest})
for name, digest in hash_files(runtime):
writer.writerow({"type": "runtime", "file": name, "sha256": digest})
summary = {
"graphs": len(graphs),
"runtime": len(runtime),
"manifest": "dataset.sha256",
}
(output_dir / "results-reach.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"Wrote {csv_path} with {len(graphs)} graph(s) and {len(runtime)} runtime file(s)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,33 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
HARNESS_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(HARNESS_DIR))
import run_reachability # noqa: E402
class ReachabilityBenchTests(unittest.TestCase):
def setUp(self):
self.graphs = [HARNESS_DIR / "inputs" / "graphs" / "sample-graph.json"]
self.runtime = [HARNESS_DIR / "inputs" / "runtime" / "sample-runtime.ndjson"]
def test_manifest_includes_files(self):
with TemporaryDirectory() as tmp:
out_dir = Path(tmp)
manifest_path = out_dir / "dataset.sha256"
run_reachability.write_manifest(self.graphs + self.runtime, manifest_path)
text = manifest_path.read_text(encoding="utf-8")
self.assertIn("sample-graph.json", text)
self.assertIn("sample-runtime.ndjson", text)
def test_hash_files(self):
hashes = dict(run_reachability.hash_files(self.graphs))
self.assertIn("sample-graph.json", hashes)
self.assertEqual(len(hashes), 1)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -7810,4 +7810,172 @@ internal static class CommandHandlers
}
private sealed record ProviderInfo(string Name, string Type, IReadOnlyList<CryptoProviderKeyDescriptor> Keys);
// ═══════════════════════════════════════════════════════════════════════════
// ATTEST HANDLERS (DSSE-CLI-401-021)
// ═══════════════════════════════════════════════════════════════════════════
public static async Task<int> HandleAttestVerifyAsync(
IServiceProvider services,
string envelopePath,
string? policyPath,
string? rootPath,
string? checkpointPath,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
// Exit codes per docs: 0 success, 2 verification failed, 4 input error
const int ExitSuccess = 0;
const int ExitVerificationFailed = 2;
const int ExitInputError = 4;
if (!File.Exists(envelopePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Envelope file not found: {Markup.Escape(envelopePath)}");
return ExitInputError;
}
try
{
var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false);
var result = new Dictionary<string, object?>
{
["envelope_path"] = envelopePath,
["verified_at"] = DateTime.UtcNow.ToString("o"),
["policy_path"] = policyPath,
["root_path"] = rootPath,
["checkpoint_path"] = checkpointPath,
};
// Placeholder: actual verification would use StellaOps.Attestor.Verify.IAttestorVerificationEngine
// For now emit structure indicating verification was attempted
var hasRoot = !string.IsNullOrWhiteSpace(rootPath) && File.Exists(rootPath);
var hasCheckpoint = !string.IsNullOrWhiteSpace(checkpointPath) && File.Exists(checkpointPath);
result["signature_verified"] = hasRoot; // Would verify against root in full implementation
result["transparency_verified"] = hasCheckpoint;
result["overall_status"] = hasRoot ? "PASSED" : "SKIPPED_NO_ROOT";
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Envelope: {Markup.Escape(envelopePath)}[/]");
if (hasRoot) AnsiConsole.MarkupLine($"[grey]Root: {Markup.Escape(rootPath!)}[/]");
if (hasCheckpoint) AnsiConsole.MarkupLine($"[grey]Checkpoint: {Markup.Escape(checkpointPath!)}[/]");
}
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
if (!string.IsNullOrWhiteSpace(outputPath))
{
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Verification report written to:[/] {Markup.Escape(outputPath)}");
}
else
{
AnsiConsole.WriteLine(json);
}
return hasRoot ? ExitSuccess : ExitVerificationFailed;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error during verification:[/] {Markup.Escape(ex.Message)}");
return ExitInputError;
}
}
public static Task<int> HandleAttestListAsync(
IServiceProvider services,
string? tenant,
string? issuer,
string format,
int? limit,
bool verbose,
CancellationToken cancellationToken)
{
var effectiveLimit = limit ?? 50;
// Placeholder: would query attestation backend
// For now emit empty table/json to show command works
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var result = new
{
attestations = Array.Empty<object>(),
total = 0,
filters = new { tenant, issuer, limit = effectiveLimit }
};
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Tenant");
table.AddColumn("Issuer");
table.AddColumn("Predicate Type");
table.AddColumn("Created (UTC)");
// Empty table - would populate from backend
if (verbose)
{
AnsiConsole.MarkupLine("[grey]No attestations found matching criteria.[/]");
}
AnsiConsole.Write(table);
}
return Task.FromResult(0);
}
public static Task<int> HandleAttestShowAsync(
IServiceProvider services,
string id,
string outputFormat,
bool includeProof,
bool verbose,
CancellationToken cancellationToken)
{
// Placeholder: would fetch specific attestation from backend
var result = new Dictionary<string, object?>
{
["id"] = id,
["found"] = false,
["message"] = "Attestation lookup requires backend connectivity.",
["include_proof"] = includeProof
};
if (outputFormat.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
var table = new Table();
table.AddColumn("Property");
table.AddColumn("Value");
foreach (var (key, value) in result)
{
table.AddRow(Markup.Escape(key), Markup.Escape(value?.ToString() ?? "(null)"));
}
AnsiConsole.Write(table);
}
return Task.FromResult(0);
}
private static string SanitizeFileName(string value)
{
var safe = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
safe = safe.Replace(invalid, '_');
}
return safe;
}
}

View File

@@ -0,0 +1,137 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}

View File

@@ -0,0 +1,143 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}

View File

@@ -0,0 +1,305 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -12,7 +12,11 @@ using Microsoft.Extensions.Hosting;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.WebService.Services;
using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notifier.Worker.Security;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.Worker.DeadLetter;
using StellaOps.Notifier.Worker.Retention;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
@@ -53,6 +57,20 @@ builder.Services.AddSingleton<ILocalizationResolver, DefaultLocalizationResolver
builder.Services.Configure<StormBreakerConfig>(builder.Configuration.GetSection("notifier:stormBreaker"));
builder.Services.AddSingleton<IStormBreaker, DefaultStormBreaker>();
// Security services (NOTIFY-SVC-40-003)
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
builder.Services.AddSingleton<IWebhookSecurityService, DefaultWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, DefaultTenantIsolationValidator>();
// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004)
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
builder.Services.AddHealthChecks();
var app = builder.Build();
@@ -2165,6 +2183,712 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
return Results.Ok(summary);
});
// =============================================
// Security API (NOTIFY-SVC-40-003)
// =============================================
// Acknowledge notification via signed token
app.MapGet("/api/v1/ack/{token}", async (
HttpContext context,
string token,
IAckTokenService ackTokenService,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider) =>
{
var verification = ackTokenService.VerifyToken(token);
if (!verification.IsValid)
{
return Results.BadRequest(new AckResponse
{
Success = false,
Error = verification.FailureReason?.ToString() ?? "Invalid token"
});
}
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = verification.Token!.TenantId,
Actor = "ack-link",
Action = $"delivery.{verification.Token.Action}",
EntityId = verification.Token.DeliveryId,
EntityType = "delivery",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new AckResponse
{
Success = true,
DeliveryId = verification.Token!.DeliveryId,
Action = verification.Token.Action,
ProcessedAt = timeProvider.GetUtcNow()
});
});
app.MapPost("/api/v1/ack/{token}", async (
HttpContext context,
string token,
AckRequest? request,
IAckTokenService ackTokenService,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider) =>
{
var verification = ackTokenService.VerifyToken(token);
if (!verification.IsValid)
{
return Results.BadRequest(new AckResponse
{
Success = false,
Error = verification.FailureReason?.ToString() ?? "Invalid token"
});
}
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = verification.Token!.TenantId,
Actor = "ack-link",
Action = $"delivery.{verification.Token.Action}",
EntityId = verification.Token.DeliveryId,
EntityType = "delivery",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { comment = request?.Comment, metadata = request?.Metadata }))
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new AckResponse
{
Success = true,
DeliveryId = verification.Token!.DeliveryId,
Action = verification.Token.Action,
ProcessedAt = timeProvider.GetUtcNow()
});
});
app.MapPost("/api/v2/notify/security/ack-tokens", (
HttpContext context,
CreateAckTokenRequest request,
IAckTokenService ackTokenService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
if (string.IsNullOrWhiteSpace(request.DeliveryId) || string.IsNullOrWhiteSpace(request.Action))
{
return Results.BadRequest(Error("invalid_request", "deliveryId and action are required.", context));
}
var expiration = request.ExpirationHours.HasValue
? TimeSpan.FromHours(request.ExpirationHours.Value)
: (TimeSpan?)null;
var token = ackTokenService.CreateToken(
tenantId,
request.DeliveryId,
request.Action,
expiration,
request.Metadata);
return Results.Ok(new CreateAckTokenResponse
{
Token = token.TokenString,
AckUrl = ackTokenService.CreateAckUrl(token),
ExpiresAt = token.ExpiresAt
});
});
app.MapPost("/api/v2/notify/security/ack-tokens/verify", (
HttpContext context,
VerifyAckTokenRequest request,
IAckTokenService ackTokenService) =>
{
if (string.IsNullOrWhiteSpace(request.Token))
{
return Results.BadRequest(Error("invalid_request", "token is required.", context));
}
var verification = ackTokenService.VerifyToken(request.Token);
return Results.Ok(new VerifyAckTokenResponse
{
IsValid = verification.IsValid,
DeliveryId = verification.Token?.DeliveryId,
Action = verification.Token?.Action,
ExpiresAt = verification.Token?.ExpiresAt,
FailureReason = verification.FailureReason?.ToString()
});
});
app.MapPost("/api/v2/notify/security/html/validate", (
HttpContext context,
ValidateHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new ValidateHtmlResponse
{
IsSafe = true,
Issues = []
});
}
var result = htmlSanitizer.Validate(request.Html);
return Results.Ok(new ValidateHtmlResponse
{
IsSafe = result.IsSafe,
Issues = result.Issues.Select(i => new HtmlIssue
{
Type = i.Type.ToString(),
Description = i.Description,
Element = i.ElementName,
Attribute = i.AttributeName
}).ToArray(),
Stats = result.Stats is not null ? new HtmlStats
{
CharacterCount = result.Stats.CharacterCount,
ElementCount = result.Stats.ElementCount,
MaxDepth = result.Stats.MaxDepth,
LinkCount = result.Stats.LinkCount,
ImageCount = result.Stats.ImageCount
} : null
});
});
app.MapPost("/api/v2/notify/security/html/sanitize", (
HttpContext context,
SanitizeHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new SanitizeHtmlResponse
{
SanitizedHtml = string.Empty,
WasModified = false
});
}
var options = new HtmlSanitizeOptions
{
AllowDataUrls = request.AllowDataUrls,
AdditionalAllowedTags = request.AdditionalAllowedTags?.ToHashSet()
};
var sanitized = htmlSanitizer.Sanitize(request.Html, options);
return Results.Ok(new SanitizeHtmlResponse
{
SanitizedHtml = sanitized,
WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal)
});
});
app.MapPost("/api/v2/notify/security/webhook/{channelId}/rotate", async (
HttpContext context,
string channelId,
IWebhookSecurityService webhookSecurityService,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
var result = await webhookSecurityService.RotateSecretAsync(tenantId, channelId, context.RequestAborted)
.ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "webhook.secret.rotated",
EntityId = channelId,
EntityType = "channel",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new RotateWebhookSecretResponse
{
Success = result.Success,
NewSecret = result.NewSecret,
ActiveAt = result.ActiveAt,
OldSecretExpiresAt = result.OldSecretExpiresAt,
Error = result.Error
});
});
app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", (
HttpContext context,
string channelId,
IWebhookSecurityService webhookSecurityService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var maskedSecret = webhookSecurityService.GetMaskedSecret(tenantId, channelId);
return Results.Ok(new { channelId, maskedSecret });
});
app.MapGet("/api/v2/notify/security/isolation/violations", (
HttpContext context,
ITenantIsolationValidator isolationValidator,
int? limit) =>
{
var violations = isolationValidator.GetRecentViolations(limit ?? 100);
return Results.Ok(new { items = violations, count = violations.Count });
});
// =============================================
// Dead-Letter API (NOTIFY-SVC-40-004)
// =============================================
app.MapPost("/api/v2/notify/dead-letter", async (
HttpContext context,
EnqueueDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var enqueueRequest = new DeadLetterEnqueueRequest
{
TenantId = tenantId,
DeliveryId = request.DeliveryId,
EventId = request.EventId,
ChannelId = request.ChannelId,
ChannelType = request.ChannelType,
FailureReason = request.FailureReason,
FailureDetails = request.FailureDetails,
AttemptCount = request.AttemptCount,
LastAttemptAt = request.LastAttemptAt,
Metadata = request.Metadata,
OriginalPayload = request.OriginalPayload
};
var entry = await deadLetterService.EnqueueAsync(enqueueRequest, context.RequestAborted).ConfigureAwait(false);
return Results.Created($"/api/v2/notify/dead-letter/{entry.EntryId}", new DeadLetterEntryResponse
{
EntryId = entry.EntryId,
TenantId = entry.TenantId,
DeliveryId = entry.DeliveryId,
EventId = entry.EventId,
ChannelId = entry.ChannelId,
ChannelType = entry.ChannelType,
FailureReason = entry.FailureReason,
FailureDetails = entry.FailureDetails,
AttemptCount = entry.AttemptCount,
CreatedAt = entry.CreatedAt,
LastAttemptAt = entry.LastAttemptAt,
Status = entry.Status.ToString(),
RetryCount = entry.RetryCount,
LastRetryAt = entry.LastRetryAt,
Resolution = entry.Resolution,
ResolvedBy = entry.ResolvedBy,
ResolvedAt = entry.ResolvedAt
});
});
app.MapGet("/api/v2/notify/dead-letter", async (
HttpContext context,
IDeadLetterService deadLetterService,
string? status,
string? channelId,
string? channelType,
DateTimeOffset? since,
DateTimeOffset? until,
int? limit,
int? offset) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var options = new DeadLetterListOptions
{
Status = Enum.TryParse<DeadLetterStatus>(status, true, out var s) ? s : null,
ChannelId = channelId,
ChannelType = channelType,
Since = since,
Until = until,
Limit = limit ?? 50,
Offset = offset ?? 0
};
var entries = await deadLetterService.ListAsync(tenantId, options, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new ListDeadLetterResponse
{
Entries = entries.Select(e => new DeadLetterEntryResponse
{
EntryId = e.EntryId,
TenantId = e.TenantId,
DeliveryId = e.DeliveryId,
EventId = e.EventId,
ChannelId = e.ChannelId,
ChannelType = e.ChannelType,
FailureReason = e.FailureReason,
FailureDetails = e.FailureDetails,
AttemptCount = e.AttemptCount,
CreatedAt = e.CreatedAt,
LastAttemptAt = e.LastAttemptAt,
Status = e.Status.ToString(),
RetryCount = e.RetryCount,
LastRetryAt = e.LastRetryAt,
Resolution = e.Resolution,
ResolvedBy = e.ResolvedBy,
ResolvedAt = e.ResolvedAt
}).ToList(),
TotalCount = entries.Count
});
});
app.MapGet("/api/v2/notify/dead-letter/{entryId}", async (
HttpContext context,
string entryId,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var entry = await deadLetterService.GetAsync(tenantId, entryId, context.RequestAborted).ConfigureAwait(false);
if (entry is null)
{
return Results.NotFound(Error("entry_not_found", $"Dead-letter entry {entryId} not found.", context));
}
return Results.Ok(new DeadLetterEntryResponse
{
EntryId = entry.EntryId,
TenantId = entry.TenantId,
DeliveryId = entry.DeliveryId,
EventId = entry.EventId,
ChannelId = entry.ChannelId,
ChannelType = entry.ChannelType,
FailureReason = entry.FailureReason,
FailureDetails = entry.FailureDetails,
AttemptCount = entry.AttemptCount,
CreatedAt = entry.CreatedAt,
LastAttemptAt = entry.LastAttemptAt,
Status = entry.Status.ToString(),
RetryCount = entry.RetryCount,
LastRetryAt = entry.LastRetryAt,
Resolution = entry.Resolution,
ResolvedBy = entry.ResolvedBy,
ResolvedAt = entry.ResolvedAt
});
});
app.MapPost("/api/v2/notify/dead-letter/retry", async (
HttpContext context,
RetryDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(new RetryDeadLetterResponse
{
Results = results.Select(r => new DeadLetterRetryResultItem
{
EntryId = r.EntryId,
Success = r.Success,
Error = r.Error,
RetriedAt = r.RetriedAt,
NewDeliveryId = r.NewDeliveryId
}).ToList(),
SuccessCount = results.Count(r => r.Success),
FailureCount = results.Count(r => !r.Success)
});
});
app.MapPost("/api/v2/notify/dead-letter/{entryId}/resolve", async (
HttpContext context,
string entryId,
ResolveDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
await deadLetterService.ResolveAsync(tenantId, entryId, request.Resolution, request.ResolvedBy, context.RequestAborted)
.ConfigureAwait(false);
return Results.NoContent();
});
app.MapGet("/api/v2/notify/dead-letter/stats", async (
HttpContext context,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var stats = await deadLetterService.GetStatsAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new DeadLetterStatsResponse
{
TotalCount = stats.TotalCount,
PendingCount = stats.PendingCount,
RetryingCount = stats.RetryingCount,
RetriedCount = stats.RetriedCount,
ResolvedCount = stats.ResolvedCount,
ExhaustedCount = stats.ExhaustedCount,
ByChannel = stats.ByChannel,
ByReason = stats.ByReason,
OldestEntryAt = stats.OldestEntryAt,
NewestEntryAt = stats.NewestEntryAt
});
});
app.MapPost("/api/v2/notify/dead-letter/purge", async (
HttpContext context,
PurgeDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var maxAge = TimeSpan.FromDays(request.MaxAgeDays);
var purgedCount = await deadLetterService.PurgeExpiredAsync(tenantId, maxAge, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(new PurgeDeadLetterResponse { PurgedCount = purgedCount });
});
// =============================================
// Retention Policy API (NOTIFY-SVC-40-004)
// =============================================
app.MapGet("/api/v2/notify/retention/policy", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var policy = await retentionService.GetPolicyAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionPolicyResponse
{
TenantId = tenantId,
Policy = new RetentionPolicyDto
{
DeliveryRetentionDays = (int)policy.DeliveryRetention.TotalDays,
AuditRetentionDays = (int)policy.AuditRetention.TotalDays,
DeadLetterRetentionDays = (int)policy.DeadLetterRetention.TotalDays,
StormDataRetentionDays = (int)policy.StormDataRetention.TotalDays,
InboxRetentionDays = (int)policy.InboxRetention.TotalDays,
EventHistoryRetentionDays = (int)policy.EventHistoryRetention.TotalDays,
AutoCleanupEnabled = policy.AutoCleanupEnabled,
CleanupSchedule = policy.CleanupSchedule,
MaxDeletesPerRun = policy.MaxDeletesPerRun,
ExtendResolvedRetention = policy.ExtendResolvedRetention,
ResolvedRetentionMultiplier = policy.ResolvedRetentionMultiplier
}
});
});
app.MapPut("/api/v2/notify/retention/policy", async (
HttpContext context,
UpdateRetentionPolicyRequest request,
IRetentionPolicyService retentionService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var policy = new RetentionPolicy
{
DeliveryRetention = TimeSpan.FromDays(request.Policy.DeliveryRetentionDays),
AuditRetention = TimeSpan.FromDays(request.Policy.AuditRetentionDays),
DeadLetterRetention = TimeSpan.FromDays(request.Policy.DeadLetterRetentionDays),
StormDataRetention = TimeSpan.FromDays(request.Policy.StormDataRetentionDays),
InboxRetention = TimeSpan.FromDays(request.Policy.InboxRetentionDays),
EventHistoryRetention = TimeSpan.FromDays(request.Policy.EventHistoryRetentionDays),
AutoCleanupEnabled = request.Policy.AutoCleanupEnabled,
CleanupSchedule = request.Policy.CleanupSchedule,
MaxDeletesPerRun = request.Policy.MaxDeletesPerRun,
ExtendResolvedRetention = request.Policy.ExtendResolvedRetention,
ResolvedRetentionMultiplier = request.Policy.ResolvedRetentionMultiplier
};
await retentionService.SetPolicyAsync(tenantId, policy, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapPost("/api/v2/notify/retention/cleanup", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var result = await retentionService.ExecuteCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionCleanupResponse
{
TenantId = result.TenantId,
Success = result.Success,
Error = result.Error,
ExecutedAt = result.ExecutedAt,
DurationMs = result.Duration.TotalMilliseconds,
Counts = new RetentionCleanupCountsDto
{
Deliveries = result.Counts.Deliveries,
AuditEntries = result.Counts.AuditEntries,
DeadLetterEntries = result.Counts.DeadLetterEntries,
StormData = result.Counts.StormData,
InboxMessages = result.Counts.InboxMessages,
Events = result.Counts.Events,
Total = result.Counts.Total
}
});
});
app.MapGet("/api/v2/notify/retention/cleanup/preview", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var preview = await retentionService.PreviewCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionCleanupPreviewResponse
{
TenantId = preview.TenantId,
PreviewedAt = preview.PreviewedAt,
EstimatedCounts = new RetentionCleanupCountsDto
{
Deliveries = preview.EstimatedCounts.Deliveries,
AuditEntries = preview.EstimatedCounts.AuditEntries,
DeadLetterEntries = preview.EstimatedCounts.DeadLetterEntries,
StormData = preview.EstimatedCounts.StormData,
InboxMessages = preview.EstimatedCounts.InboxMessages,
Events = preview.EstimatedCounts.Events,
Total = preview.EstimatedCounts.Total
},
PolicyApplied = new RetentionPolicyDto
{
DeliveryRetentionDays = (int)preview.PolicyApplied.DeliveryRetention.TotalDays,
AuditRetentionDays = (int)preview.PolicyApplied.AuditRetention.TotalDays,
DeadLetterRetentionDays = (int)preview.PolicyApplied.DeadLetterRetention.TotalDays,
StormDataRetentionDays = (int)preview.PolicyApplied.StormDataRetention.TotalDays,
InboxRetentionDays = (int)preview.PolicyApplied.InboxRetention.TotalDays,
EventHistoryRetentionDays = (int)preview.PolicyApplied.EventHistoryRetention.TotalDays,
AutoCleanupEnabled = preview.PolicyApplied.AutoCleanupEnabled,
CleanupSchedule = preview.PolicyApplied.CleanupSchedule,
MaxDeletesPerRun = preview.PolicyApplied.MaxDeletesPerRun,
ExtendResolvedRetention = preview.PolicyApplied.ExtendResolvedRetention,
ResolvedRetentionMultiplier = preview.PolicyApplied.ResolvedRetentionMultiplier
},
CutoffDates = preview.CutoffDates
});
});
app.MapGet("/api/v2/notify/retention/cleanup/last", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var execution = await retentionService.GetLastExecutionAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
if (execution is null)
{
return Results.NotFound(Error("no_execution", "No cleanup execution found.", context));
}
return Results.Ok(new RetentionCleanupExecutionResponse
{
ExecutionId = execution.ExecutionId,
TenantId = execution.TenantId,
StartedAt = execution.StartedAt,
CompletedAt = execution.CompletedAt,
Status = execution.Status.ToString(),
Counts = execution.Counts is not null ? new RetentionCleanupCountsDto
{
Deliveries = execution.Counts.Deliveries,
AuditEntries = execution.Counts.AuditEntries,
DeadLetterEntries = execution.Counts.DeadLetterEntries,
StormData = execution.Counts.StormData,
InboxMessages = execution.Counts.InboxMessages,
Events = execution.Counts.Events,
Total = execution.Counts.Total
} : null,
Error = execution.Error
});
});
app.MapGet("/.well-known/openapi", (HttpContext context) =>
{
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
@@ -2178,6 +2902,7 @@ info:
paths:
/api/v1/notify/quiet-hours: {}
/api/v1/notify/incidents: {}
/api/v1/ack/{token}: {}
/api/v2/notify/templates: {}
/api/v2/notify/rules: {}
/api/v2/notify/channels: {}
@@ -2195,6 +2920,23 @@ paths:
/api/v2/notify/localization/locales: {}
/api/v2/notify/localization/resolve: {}
/api/v2/notify/storms: {}
/api/v2/notify/security/ack-tokens: {}
/api/v2/notify/security/ack-tokens/verify: {}
/api/v2/notify/security/html/validate: {}
/api/v2/notify/security/html/sanitize: {}
/api/v2/notify/security/webhook/{channelId}/rotate: {}
/api/v2/notify/security/webhook/{channelId}/secret: {}
/api/v2/notify/security/isolation/violations: {}
/api/v2/notify/dead-letter: {}
/api/v2/notify/dead-letter/{entryId}: {}
/api/v2/notify/dead-letter/retry: {}
/api/v2/notify/dead-letter/{entryId}/resolve: {}
/api/v2/notify/dead-letter/stats: {}
/api/v2/notify/dead-letter/purge: {}
/api/v2/notify/retention/policy: {}
/api/v2/notify/retention/cleanup: {}
/api/v2/notify/retention/cleanup/preview: {}
/api/v2/notify/retention/cleanup/last: {}
""";
return Results.Text(stub, "application/yaml", Encoding.UTF8);

View File

@@ -1,22 +1,32 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Security;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for webhook (HTTP POST) delivery with retry support.
/// Channel adapter for webhook (HTTP POST) delivery with retry support and HMAC signing.
/// </summary>
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
{
private readonly HttpClient _httpClient;
private readonly IWebhookSecurityService? _securityService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<WebhookChannelAdapter> _logger;
public WebhookChannelAdapter(HttpClient httpClient, ILogger<WebhookChannelAdapter> logger)
public WebhookChannelAdapter(
HttpClient httpClient,
ILogger<WebhookChannelAdapter> logger,
IWebhookSecurityService? securityService = null,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_securityService = securityService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
@@ -52,17 +62,30 @@ public sealed class WebhookChannelAdapter : INotifyChannelAdapter
timestamp = DateTimeOffset.UtcNow
};
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var payloadJson = JsonSerializer.Serialize(payload, jsonOptions);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
// Add HMAC signature header if secret is available (placeholder for KMS integration)
// Add version header
request.Headers.Add("X-StellaOps-Notifier", "1.0");
// Add HMAC signature if security service is available
if (_securityService is not null)
{
var timestamp = _timeProvider.GetUtcNow();
var signature = _securityService.SignPayload(
channel.TenantId,
channel.ChannelId,
payloadBytes,
timestamp);
request.Headers.Add("X-StellaOps-Signature", signature);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.Worker.DeadLetter;
/// <summary>
/// Service for managing dead-letter entries for failed notification deliveries.
/// </summary>
public interface IDeadLetterService
{
/// <summary>
/// Enqueues a failed delivery to the dead-letter queue.
/// </summary>
Task<DeadLetterEntry> EnqueueAsync(
DeadLetterEnqueueRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a dead-letter entry by ID.
/// </summary>
Task<DeadLetterEntry?> GetAsync(
string tenantId,
string entryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists dead-letter entries with optional filtering.
/// </summary>
Task<IReadOnlyList<DeadLetterEntry>> ListAsync(
string tenantId,
DeadLetterListOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retries a dead-letter entry.
/// </summary>
Task<DeadLetterRetryResult> RetryAsync(
string tenantId,
string entryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retries multiple dead-letter entries.
/// </summary>
Task<IReadOnlyList<DeadLetterRetryResult>> RetryBatchAsync(
string tenantId,
IEnumerable<string> entryIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a dead-letter entry as resolved/dismissed.
/// </summary>
Task ResolveAsync(
string tenantId,
string entryId,
string resolution,
string? resolvedBy = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes old dead-letter entries based on retention policy.
/// </summary>
Task<int> PurgeExpiredAsync(
string tenantId,
TimeSpan maxAge,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statistics about dead-letter entries.
/// </summary>
Task<DeadLetterStats> GetStatsAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record DeadLetterEnqueueRequest
{
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Original payload for retry purposes.
/// </summary>
public string? OriginalPayload { get; init; }
}
/// <summary>
/// A dead-letter queue entry.
/// </summary>
public sealed record DeadLetterEntry
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required DeadLetterStatus Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Status of a dead-letter entry.
/// </summary>
public enum DeadLetterStatus
{
/// <summary>Entry is pending retry or resolution.</summary>
Pending,
/// <summary>Entry is being retried.</summary>
Retrying,
/// <summary>Entry was successfully retried.</summary>
Retried,
/// <summary>Entry was manually resolved/dismissed.</summary>
Resolved,
/// <summary>Entry exceeded max retries.</summary>
Exhausted
}
/// <summary>
/// Options for listing dead-letter entries.
/// </summary>
public sealed record DeadLetterListOptions
{
public DeadLetterStatus? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Result of a dead-letter retry attempt.
/// </summary>
public sealed record DeadLetterRetryResult
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Statistics about dead-letter entries.
/// </summary>
public sealed record DeadLetterStats
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}

View File

@@ -0,0 +1,294 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Observability;
namespace StellaOps.Notifier.Worker.DeadLetter;
/// <summary>
/// In-memory implementation of dead-letter service.
/// For production, use a persistent storage implementation.
/// </summary>
public sealed class InMemoryDeadLetterService : IDeadLetterService
{
private readonly ConcurrentDictionary<string, DeadLetterEntry> _entries = new();
private readonly TimeProvider _timeProvider;
private readonly INotifyMetrics? _metrics;
private readonly ILogger<InMemoryDeadLetterService> _logger;
public InMemoryDeadLetterService(
TimeProvider timeProvider,
ILogger<InMemoryDeadLetterService> logger,
INotifyMetrics? metrics = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics;
}
public Task<DeadLetterEntry> EnqueueAsync(
DeadLetterEnqueueRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var entryId = Guid.NewGuid().ToString("N");
var now = _timeProvider.GetUtcNow();
var entry = new DeadLetterEntry
{
EntryId = entryId,
TenantId = request.TenantId,
DeliveryId = request.DeliveryId,
EventId = request.EventId,
ChannelId = request.ChannelId,
ChannelType = request.ChannelType,
FailureReason = request.FailureReason,
FailureDetails = request.FailureDetails,
AttemptCount = request.AttemptCount,
CreatedAt = now,
LastAttemptAt = request.LastAttemptAt ?? now,
Status = DeadLetterStatus.Pending,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
OriginalPayload = request.OriginalPayload
};
_entries[GetKey(request.TenantId, entryId)] = entry;
_metrics?.RecordDeadLetter(request.TenantId, request.FailureReason, request.ChannelType);
_logger.LogWarning(
"Dead-lettered delivery {DeliveryId} for tenant {TenantId}: {Reason}",
request.DeliveryId, request.TenantId, request.FailureReason);
return Task.FromResult(entry);
}
public Task<DeadLetterEntry?> GetAsync(
string tenantId,
string entryId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
_entries.TryGetValue(GetKey(tenantId, entryId), out var entry);
return Task.FromResult(entry);
}
public Task<IReadOnlyList<DeadLetterEntry>> ListAsync(
string tenantId,
DeadLetterListOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
options ??= new DeadLetterListOptions();
var query = _entries.Values
.Where(e => e.TenantId == tenantId);
if (options.Status.HasValue)
{
query = query.Where(e => e.Status == options.Status.Value);
}
if (!string.IsNullOrWhiteSpace(options.ChannelId))
{
query = query.Where(e => e.ChannelId == options.ChannelId);
}
if (!string.IsNullOrWhiteSpace(options.ChannelType))
{
query = query.Where(e => e.ChannelType == options.ChannelType);
}
if (options.Since.HasValue)
{
query = query.Where(e => e.CreatedAt >= options.Since.Value);
}
if (options.Until.HasValue)
{
query = query.Where(e => e.CreatedAt <= options.Until.Value);
}
var result = query
.OrderByDescending(e => e.CreatedAt)
.Skip(options.Offset)
.Take(options.Limit)
.ToArray();
return Task.FromResult<IReadOnlyList<DeadLetterEntry>>(result);
}
public Task<DeadLetterRetryResult> RetryAsync(
string tenantId,
string entryId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
var key = GetKey(tenantId, entryId);
if (!_entries.TryGetValue(key, out var entry))
{
return Task.FromResult(new DeadLetterRetryResult
{
EntryId = entryId,
Success = false,
Error = "Entry not found"
});
}
if (entry.Status is DeadLetterStatus.Retried or DeadLetterStatus.Resolved)
{
return Task.FromResult(new DeadLetterRetryResult
{
EntryId = entryId,
Success = false,
Error = $"Entry is already {entry.Status}"
});
}
var now = _timeProvider.GetUtcNow();
// Update entry status
var updatedEntry = entry with
{
Status = DeadLetterStatus.Retried,
RetryCount = entry.RetryCount + 1,
LastRetryAt = now
};
_entries[key] = updatedEntry;
_logger.LogInformation(
"Retried dead-letter entry {EntryId} for tenant {TenantId}",
entryId, tenantId);
// In a real implementation, this would re-queue the delivery
return Task.FromResult(new DeadLetterRetryResult
{
EntryId = entryId,
Success = true,
RetriedAt = now,
NewDeliveryId = Guid.NewGuid().ToString("N")
});
}
public async Task<IReadOnlyList<DeadLetterRetryResult>> RetryBatchAsync(
string tenantId,
IEnumerable<string> entryIds,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(entryIds);
var results = new List<DeadLetterRetryResult>();
foreach (var entryId in entryIds)
{
var result = await RetryAsync(tenantId, entryId, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
return results;
}
public Task ResolveAsync(
string tenantId,
string entryId,
string resolution,
string? resolvedBy = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
ArgumentException.ThrowIfNullOrWhiteSpace(resolution);
var key = GetKey(tenantId, entryId);
if (_entries.TryGetValue(key, out var entry))
{
var now = _timeProvider.GetUtcNow();
_entries[key] = entry with
{
Status = DeadLetterStatus.Resolved,
Resolution = resolution,
ResolvedBy = resolvedBy,
ResolvedAt = now
};
_logger.LogInformation(
"Resolved dead-letter entry {EntryId} for tenant {TenantId}: {Resolution}",
entryId, tenantId, resolution);
}
return Task.CompletedTask;
}
public Task<int> PurgeExpiredAsync(
string tenantId,
TimeSpan maxAge,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var cutoff = _timeProvider.GetUtcNow() - maxAge;
var toRemove = _entries
.Where(kv => kv.Value.TenantId == tenantId && kv.Value.CreatedAt < cutoff)
.Select(kv => kv.Key)
.ToArray();
var count = 0;
foreach (var key in toRemove)
{
if (_entries.TryRemove(key, out _))
{
count++;
}
}
if (count > 0)
{
_logger.LogInformation(
"Purged {Count} expired dead-letter entries for tenant {TenantId}",
count, tenantId);
}
return Task.FromResult(count);
}
public Task<DeadLetterStats> GetStatsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var entries = _entries.Values.Where(e => e.TenantId == tenantId).ToArray();
var byChannel = entries
.GroupBy(e => e.ChannelType)
.ToDictionary(g => g.Key, g => g.Count());
var byReason = entries
.GroupBy(e => e.FailureReason)
.ToDictionary(g => g.Key, g => g.Count());
var stats = new DeadLetterStats
{
TotalCount = entries.Length,
PendingCount = entries.Count(e => e.Status == DeadLetterStatus.Pending),
RetryingCount = entries.Count(e => e.Status == DeadLetterStatus.Retrying),
RetriedCount = entries.Count(e => e.Status == DeadLetterStatus.Retried),
ResolvedCount = entries.Count(e => e.Status == DeadLetterStatus.Resolved),
ExhaustedCount = entries.Count(e => e.Status == DeadLetterStatus.Exhausted),
ByChannel = byChannel,
ByReason = byReason,
OldestEntryAt = entries.MinBy(e => e.CreatedAt)?.CreatedAt,
NewestEntryAt = entries.MaxBy(e => e.CreatedAt)?.CreatedAt
};
return Task.FromResult(stats);
}
private static string GetKey(string tenantId, string entryId) => $"{tenantId}:{entryId}";
}

View File

@@ -0,0 +1,233 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Notifier.Worker.Observability;
/// <summary>
/// Default implementation of notification metrics using System.Diagnostics.Metrics.
/// </summary>
public sealed class DefaultNotifyMetrics : INotifyMetrics
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Notifier", "1.0.0");
private static readonly Meter Meter = new("StellaOps.Notifier", "1.0.0");
// Counters
private readonly Counter<long> _deliveryAttempts;
private readonly Counter<long> _escalationEvents;
private readonly Counter<long> _deadLetterEntries;
private readonly Counter<long> _ruleEvaluations;
private readonly Counter<long> _templateRenders;
private readonly Counter<long> _stormEvents;
private readonly Counter<long> _retentionCleanups;
// Histograms
private readonly Histogram<double> _deliveryDuration;
private readonly Histogram<double> _ruleEvaluationDuration;
private readonly Histogram<double> _templateRenderDuration;
// Gauges (using ObservableGauge pattern)
private readonly Dictionary<string, int> _queueDepths = new();
private readonly object _queueDepthLock = new();
public DefaultNotifyMetrics()
{
// Initialize counters
_deliveryAttempts = Meter.CreateCounter<long>(
NotifyMetricNames.DeliveryAttempts,
unit: "{attempts}",
description: "Total number of notification delivery attempts");
_escalationEvents = Meter.CreateCounter<long>(
NotifyMetricNames.EscalationEvents,
unit: "{events}",
description: "Total number of escalation events");
_deadLetterEntries = Meter.CreateCounter<long>(
NotifyMetricNames.DeadLetterEntries,
unit: "{entries}",
description: "Total number of dead-letter entries");
_ruleEvaluations = Meter.CreateCounter<long>(
NotifyMetricNames.RuleEvaluations,
unit: "{evaluations}",
description: "Total number of rule evaluations");
_templateRenders = Meter.CreateCounter<long>(
NotifyMetricNames.TemplateRenders,
unit: "{renders}",
description: "Total number of template render operations");
_stormEvents = Meter.CreateCounter<long>(
NotifyMetricNames.StormEvents,
unit: "{events}",
description: "Total number of storm detection events");
_retentionCleanups = Meter.CreateCounter<long>(
NotifyMetricNames.RetentionCleanups,
unit: "{cleanups}",
description: "Total number of retention cleanup operations");
// Initialize histograms
_deliveryDuration = Meter.CreateHistogram<double>(
NotifyMetricNames.DeliveryDuration,
unit: "ms",
description: "Duration of delivery attempts in milliseconds");
_ruleEvaluationDuration = Meter.CreateHistogram<double>(
NotifyMetricNames.RuleEvaluationDuration,
unit: "ms",
description: "Duration of rule evaluations in milliseconds");
_templateRenderDuration = Meter.CreateHistogram<double>(
NotifyMetricNames.TemplateRenderDuration,
unit: "ms",
description: "Duration of template renders in milliseconds");
// Initialize observable gauge for queue depths
Meter.CreateObservableGauge(
NotifyMetricNames.QueueDepth,
observeValues: ObserveQueueDepths,
unit: "{messages}",
description: "Current queue depth per channel");
}
public void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.ChannelType, channelType },
{ NotifyMetricTags.Status, status }
};
_deliveryAttempts.Add(1, tags);
_deliveryDuration.Record(duration.TotalMilliseconds, tags);
}
public void RecordEscalation(string tenantId, int level, string outcome)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.Level, level.ToString() },
{ NotifyMetricTags.Outcome, outcome }
};
_escalationEvents.Add(1, tags);
}
public void RecordDeadLetter(string tenantId, string reason, string channelType)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.Reason, reason },
{ NotifyMetricTags.ChannelType, channelType }
};
_deadLetterEntries.Add(1, tags);
}
public void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.RuleId, ruleId },
{ NotifyMetricTags.Matched, matched.ToString().ToLowerInvariant() }
};
_ruleEvaluations.Add(1, tags);
_ruleEvaluationDuration.Record(duration.TotalMilliseconds, tags);
}
public void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.TemplateKey, templateKey },
{ NotifyMetricTags.Success, success.ToString().ToLowerInvariant() }
};
_templateRenders.Add(1, tags);
_templateRenderDuration.Record(duration.TotalMilliseconds, tags);
}
public void RecordStormEvent(string tenantId, string stormKey, string decision)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.StormKey, stormKey },
{ NotifyMetricTags.Decision, decision }
};
_stormEvents.Add(1, tags);
}
public void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount)
{
var tags = new TagList
{
{ NotifyMetricTags.TenantId, tenantId },
{ NotifyMetricTags.EntityType, entityType }
};
_retentionCleanups.Add(deletedCount, tags);
}
public void RecordQueueDepth(string tenantId, string channelType, int depth)
{
var key = $"{tenantId}:{channelType}";
lock (_queueDepthLock)
{
_queueDepths[key] = depth;
}
}
public Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType)
{
var activity = ActivitySource.StartActivity("notify.delivery", ActivityKind.Internal);
if (activity is not null)
{
activity.SetTag(NotifyMetricTags.TenantId, tenantId);
activity.SetTag("delivery_id", deliveryId);
activity.SetTag(NotifyMetricTags.ChannelType, channelType);
}
return activity;
}
public Activity? StartEscalationActivity(string tenantId, string incidentId, int level)
{
var activity = ActivitySource.StartActivity("notify.escalation", ActivityKind.Internal);
if (activity is not null)
{
activity.SetTag(NotifyMetricTags.TenantId, tenantId);
activity.SetTag("incident_id", incidentId);
activity.SetTag(NotifyMetricTags.Level, level);
}
return activity;
}
private IEnumerable<Measurement<int>> ObserveQueueDepths()
{
lock (_queueDepthLock)
{
foreach (var (key, depth) in _queueDepths)
{
var parts = key.Split(':');
if (parts.Length == 2)
{
yield return new Measurement<int>(
depth,
new TagList
{
{ NotifyMetricTags.TenantId, parts[0] },
{ NotifyMetricTags.ChannelType, parts[1] }
});
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Notifier.Worker.Observability;
/// <summary>
/// Interface for notification system metrics and tracing.
/// </summary>
public interface INotifyMetrics
{
/// <summary>
/// Records a notification delivery attempt.
/// </summary>
void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration);
/// <summary>
/// Records an escalation event.
/// </summary>
void RecordEscalation(string tenantId, int level, string outcome);
/// <summary>
/// Records a dead-letter entry.
/// </summary>
void RecordDeadLetter(string tenantId, string reason, string channelType);
/// <summary>
/// Records rule evaluation.
/// </summary>
void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration);
/// <summary>
/// Records template rendering.
/// </summary>
void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration);
/// <summary>
/// Records storm detection event.
/// </summary>
void RecordStormEvent(string tenantId, string stormKey, string decision);
/// <summary>
/// Records retention cleanup.
/// </summary>
void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount);
/// <summary>
/// Gets the current queue depth for a channel.
/// </summary>
void RecordQueueDepth(string tenantId, string channelType, int depth);
/// <summary>
/// Creates an activity for distributed tracing.
/// </summary>
Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType);
/// <summary>
/// Creates an activity for escalation tracing.
/// </summary>
Activity? StartEscalationActivity(string tenantId, string incidentId, int level);
}
/// <summary>
/// Metric tag names for consistency.
/// </summary>
public static class NotifyMetricTags
{
public const string TenantId = "tenant_id";
public const string ChannelType = "channel_type";
public const string Status = "status";
public const string Outcome = "outcome";
public const string Level = "level";
public const string Reason = "reason";
public const string RuleId = "rule_id";
public const string Matched = "matched";
public const string TemplateKey = "template_key";
public const string Success = "success";
public const string StormKey = "storm_key";
public const string Decision = "decision";
public const string EntityType = "entity_type";
}
/// <summary>
/// Metric names for the notification system.
/// </summary>
public static class NotifyMetricNames
{
public const string DeliveryAttempts = "notify.delivery.attempts";
public const string DeliveryDuration = "notify.delivery.duration";
public const string EscalationEvents = "notify.escalation.events";
public const string DeadLetterEntries = "notify.deadletter.entries";
public const string RuleEvaluations = "notify.rule.evaluations";
public const string RuleEvaluationDuration = "notify.rule.evaluation.duration";
public const string TemplateRenders = "notify.template.renders";
public const string TemplateRenderDuration = "notify.template.render.duration";
public const string StormEvents = "notify.storm.events";
public const string RetentionCleanups = "notify.retention.cleanups";
public const string QueueDepth = "notify.queue.depth";
}

View File

@@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.DeadLetter;
using StellaOps.Notifier.Worker.Observability;
namespace StellaOps.Notifier.Worker.Retention;
/// <summary>
/// Default implementation of retention policy service.
/// </summary>
public sealed class DefaultRetentionPolicyService : IRetentionPolicyService
{
private readonly ConcurrentDictionary<string, RetentionPolicy> _policies = new();
private readonly ConcurrentDictionary<string, RetentionCleanupExecution> _lastExecutions = new();
private readonly IDeadLetterService _deadLetterService;
private readonly TimeProvider _timeProvider;
private readonly INotifyMetrics? _metrics;
private readonly ILogger<DefaultRetentionPolicyService> _logger;
public DefaultRetentionPolicyService(
IDeadLetterService deadLetterService,
TimeProvider timeProvider,
ILogger<DefaultRetentionPolicyService> logger,
INotifyMetrics? metrics = null)
{
_deadLetterService = deadLetterService ?? throw new ArgumentNullException(nameof(deadLetterService));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics;
}
public Task<RetentionPolicy> GetPolicyAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var policy = _policies.GetValueOrDefault(tenantId, RetentionPolicy.Default);
return Task.FromResult(policy);
}
public Task SetPolicyAsync(
string tenantId,
RetentionPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(policy);
_policies[tenantId] = policy;
_logger.LogInformation(
"Updated retention policy for tenant {TenantId}: DeliveryRetention={DeliveryRetention}, AuditRetention={AuditRetention}",
tenantId, policy.DeliveryRetention, policy.AuditRetention);
return Task.CompletedTask;
}
public async Task<RetentionCleanupResult> ExecuteCleanupAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var executionId = Guid.NewGuid().ToString("N");
var startedAt = _timeProvider.GetUtcNow();
var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false);
var execution = new RetentionCleanupExecution
{
ExecutionId = executionId,
TenantId = tenantId,
StartedAt = startedAt,
Status = RetentionCleanupStatus.Running,
PolicyUsed = policy
};
_lastExecutions[tenantId] = execution;
_logger.LogInformation(
"Starting retention cleanup {ExecutionId} for tenant {TenantId}",
executionId, tenantId);
try
{
var counts = await ExecuteCleanupInternalAsync(tenantId, policy, cancellationToken)
.ConfigureAwait(false);
var completedAt = _timeProvider.GetUtcNow();
var duration = completedAt - startedAt;
execution = execution with
{
CompletedAt = completedAt,
Status = RetentionCleanupStatus.Completed,
Counts = counts
};
_lastExecutions[tenantId] = execution;
_logger.LogInformation(
"Completed retention cleanup {ExecutionId} for tenant {TenantId}: {Total} items deleted in {Duration}ms",
executionId, tenantId, counts.Total, duration.TotalMilliseconds);
return new RetentionCleanupResult
{
TenantId = tenantId,
Success = true,
ExecutedAt = startedAt,
Duration = duration,
Counts = counts
};
}
catch (OperationCanceledException)
{
execution = execution with
{
CompletedAt = _timeProvider.GetUtcNow(),
Status = RetentionCleanupStatus.Cancelled,
Error = "Operation was cancelled"
};
_lastExecutions[tenantId] = execution;
_logger.LogWarning(
"Retention cleanup {ExecutionId} for tenant {TenantId} was cancelled",
executionId, tenantId);
return new RetentionCleanupResult
{
TenantId = tenantId,
Success = false,
Error = "Operation was cancelled",
ExecutedAt = startedAt,
Duration = _timeProvider.GetUtcNow() - startedAt,
Counts = new RetentionCleanupCounts()
};
}
catch (Exception ex)
{
execution = execution with
{
CompletedAt = _timeProvider.GetUtcNow(),
Status = RetentionCleanupStatus.Failed,
Error = ex.Message
};
_lastExecutions[tenantId] = execution;
_logger.LogError(ex,
"Retention cleanup {ExecutionId} for tenant {TenantId} failed",
executionId, tenantId);
return new RetentionCleanupResult
{
TenantId = tenantId,
Success = false,
Error = ex.Message,
ExecutedAt = startedAt,
Duration = _timeProvider.GetUtcNow() - startedAt,
Counts = new RetentionCleanupCounts()
};
}
}
public async Task<IReadOnlyList<RetentionCleanupResult>> ExecuteCleanupAllAsync(
CancellationToken cancellationToken = default)
{
var tenantIds = _policies.Keys.ToArray();
var results = new List<RetentionCleanupResult>();
foreach (var tenantId in tenantIds)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ExecuteCleanupAsync(tenantId, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
_logger.LogInformation(
"Completed retention cleanup for {Count} tenants: {Successful} successful, {Failed} failed",
results.Count, results.Count(r => r.Success), results.Count(r => !r.Success));
return results;
}
public Task<RetentionCleanupExecution?> GetLastExecutionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
_lastExecutions.TryGetValue(tenantId, out var execution);
return Task.FromResult(execution);
}
public async Task<RetentionCleanupPreview> PreviewCleanupAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var cutoffDates = new Dictionary<string, DateTimeOffset>
{
["Deliveries"] = now - policy.DeliveryRetention,
["AuditEntries"] = now - policy.AuditRetention,
["DeadLetterEntries"] = now - policy.DeadLetterRetention,
["StormData"] = now - policy.StormDataRetention,
["InboxMessages"] = now - policy.InboxRetention,
["Events"] = now - policy.EventHistoryRetention
};
// Get estimated dead-letter count
var deadLetterStats = await _deadLetterService.GetStatsAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
// Estimate counts based on age distribution (simplified - in production would query actual counts)
var estimatedCounts = new RetentionCleanupCounts
{
DeadLetterEntries = EstimateExpiredCount(deadLetterStats, policy.DeadLetterRetention, now)
};
return new RetentionCleanupPreview
{
TenantId = tenantId,
PreviewedAt = now,
EstimatedCounts = estimatedCounts,
PolicyApplied = policy,
CutoffDates = cutoffDates
};
}
private async Task<RetentionCleanupCounts> ExecuteCleanupInternalAsync(
string tenantId,
RetentionPolicy policy,
CancellationToken cancellationToken)
{
var deadLetterCount = 0;
// Purge expired dead-letter entries
deadLetterCount = await _deadLetterService.PurgeExpiredAsync(
tenantId,
policy.DeadLetterRetention,
cancellationToken).ConfigureAwait(false);
if (deadLetterCount > 0)
{
_metrics?.RecordRetentionCleanup(tenantId, "DeadLetter", deadLetterCount);
}
// In a full implementation, we would also clean up:
// - Delivery records from delivery store
// - Audit log entries from audit store
// - Storm tracking data from storm store
// - Inbox messages from inbox store
// - Event history from event store
// For now, return counts with just dead-letter cleanup
return new RetentionCleanupCounts
{
DeadLetterEntries = deadLetterCount
};
}
private static int EstimateExpiredCount(DeadLetterStats stats, TimeSpan retention, DateTimeOffset now)
{
if (!stats.OldestEntryAt.HasValue)
{
return 0;
}
var cutoff = now - retention;
if (stats.OldestEntryAt.Value >= cutoff)
{
return 0;
}
// Rough estimation - assume linear distribution
if (!stats.NewestEntryAt.HasValue || stats.TotalCount == 0)
{
return 0;
}
var totalSpan = stats.NewestEntryAt.Value - stats.OldestEntryAt.Value;
if (totalSpan.TotalSeconds <= 0)
{
return stats.TotalCount;
}
var expiredSpan = cutoff - stats.OldestEntryAt.Value;
var ratio = Math.Clamp(expiredSpan.TotalSeconds / totalSpan.TotalSeconds, 0, 1);
return (int)(stats.TotalCount * ratio);
}
}

View File

@@ -0,0 +1,181 @@
namespace StellaOps.Notifier.Worker.Retention;
/// <summary>
/// Service for managing data retention policies and cleanup.
/// </summary>
public interface IRetentionPolicyService
{
/// <summary>
/// Gets the retention policy for a tenant.
/// </summary>
Task<RetentionPolicy> GetPolicyAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets/updates the retention policy for a tenant.
/// </summary>
Task SetPolicyAsync(
string tenantId,
RetentionPolicy policy,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes retention cleanup for a tenant.
/// </summary>
Task<RetentionCleanupResult> ExecuteCleanupAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes retention cleanup for all tenants.
/// </summary>
Task<IReadOnlyList<RetentionCleanupResult>> ExecuteCleanupAllAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the last cleanup execution details.
/// </summary>
Task<RetentionCleanupExecution?> GetLastExecutionAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Previews what would be cleaned up without actually deleting.
/// </summary>
Task<RetentionCleanupPreview> PreviewCleanupAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Data retention policy configuration.
/// </summary>
public sealed record RetentionPolicy
{
/// <summary>
/// Retention period for delivery records.
/// </summary>
public TimeSpan DeliveryRetention { get; init; } = TimeSpan.FromDays(90);
/// <summary>
/// Retention period for audit log entries.
/// </summary>
public TimeSpan AuditRetention { get; init; } = TimeSpan.FromDays(365);
/// <summary>
/// Retention period for dead-letter entries.
/// </summary>
public TimeSpan DeadLetterRetention { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// Retention period for storm tracking data.
/// </summary>
public TimeSpan StormDataRetention { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Retention period for inbox messages.
/// </summary>
public TimeSpan InboxRetention { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// Retention period for event history.
/// </summary>
public TimeSpan EventHistoryRetention { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *"; // Daily at 2 AM
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items (e.g., 2x = double the retention).
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
/// <summary>
/// Default policy with standard retention periods.
/// </summary>
public static RetentionPolicy Default => new();
}
/// <summary>
/// Result of a retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResult
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public TimeSpan Duration { get; init; }
public required RetentionCleanupCounts Counts { get; init; }
}
/// <summary>
/// Counts of items deleted during retention cleanup.
/// </summary>
public sealed record RetentionCleanupCounts
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total => Deliveries + AuditEntries + DeadLetterEntries + StormData + InboxMessages + Events;
}
/// <summary>
/// Details of a cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecution
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required RetentionCleanupStatus Status { get; init; }
public RetentionCleanupCounts? Counts { get; init; }
public string? Error { get; init; }
public RetentionPolicy PolicyUsed { get; init; } = RetentionPolicy.Default;
}
/// <summary>
/// Status of a cleanup execution.
/// </summary>
public enum RetentionCleanupStatus
{
Running,
Completed,
Failed,
Cancelled
}
/// <summary>
/// Preview of what would be cleaned up.
/// </summary>
public sealed record RetentionCleanupPreview
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCounts EstimatedCounts { get; init; }
public required RetentionPolicy PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}

View File

@@ -0,0 +1,509 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Default HTML sanitizer implementation using regex-based filtering.
/// For production, consider using a dedicated library like HtmlSanitizer or AngleSharp.
/// </summary>
public sealed partial class DefaultHtmlSanitizer : IHtmlSanitizer
{
private readonly ILogger<DefaultHtmlSanitizer> _logger;
// Safe elements (whitelist approach)
private static readonly HashSet<string> SafeElements = new(StringComparer.OrdinalIgnoreCase)
{
"p", "div", "span", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "u", "s", "strike",
"ul", "ol", "li", "dl", "dt", "dd",
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"a", "img",
"blockquote", "pre", "code",
"sub", "sup", "small", "mark",
"caption", "figure", "figcaption"
};
// Safe attributes
private static readonly HashSet<string> SafeAttributes = new(StringComparer.OrdinalIgnoreCase)
{
"href", "src", "alt", "title", "class", "id",
"width", "height", "style",
"colspan", "rowspan", "scope",
"target", "rel"
};
// Dangerous URL schemes
private static readonly HashSet<string> DangerousSchemes = new(StringComparer.OrdinalIgnoreCase)
{
"javascript", "vbscript", "data", "file"
};
// Event handler attributes (all start with "on")
private static readonly Regex EventHandlerRegex = EventHandlerPattern();
// Style-based attacks
private static readonly Regex DangerousStyleRegex = DangerousStylePattern();
public DefaultHtmlSanitizer(ILogger<DefaultHtmlSanitizer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Sanitize(string html, HtmlSanitizeOptions? options = null)
{
if (string.IsNullOrWhiteSpace(html))
{
return string.Empty;
}
options ??= new HtmlSanitizeOptions();
if (html.Length > options.MaxContentLength)
{
_logger.LogWarning("HTML content exceeds max length {MaxLength}, truncating", options.MaxContentLength);
html = html[..options.MaxContentLength];
}
var allowedTags = new HashSet<string>(SafeElements, StringComparer.OrdinalIgnoreCase);
if (options.AdditionalAllowedTags is not null)
{
foreach (var tag in options.AdditionalAllowedTags)
{
allowedTags.Add(tag);
}
}
var allowedAttrs = new HashSet<string>(SafeAttributes, StringComparer.OrdinalIgnoreCase);
if (options.AdditionalAllowedAttributes is not null)
{
foreach (var attr in options.AdditionalAllowedAttributes)
{
allowedAttrs.Add(attr);
}
}
// Process HTML
var result = new StringBuilder();
var depth = 0;
var pos = 0;
while (pos < html.Length)
{
var tagStart = html.IndexOf('<', pos);
if (tagStart < 0)
{
// No more tags, append rest
result.Append(EncodeText(html[pos..]));
break;
}
// Append text before tag
if (tagStart > pos)
{
result.Append(EncodeText(html[pos..tagStart]));
}
var tagEnd = html.IndexOf('>', tagStart);
if (tagEnd < 0)
{
// Malformed, skip rest
break;
}
var tagContent = html[(tagStart + 1)..tagEnd];
var isClosing = tagContent.StartsWith('/');
var tagName = ExtractTagName(tagContent);
if (isClosing)
{
depth--;
}
if (allowedTags.Contains(tagName))
{
if (isClosing)
{
result.Append($"</{tagName}>");
}
else
{
// Process attributes
var sanitizedTag = SanitizeTag(tagContent, tagName, allowedAttrs, options);
result.Append($"<{sanitizedTag}>");
if (!IsSelfClosing(tagName) && !tagContent.EndsWith('/'))
{
depth++;
}
}
}
else
{
_logger.LogDebug("Stripped disallowed tag: {TagName}", tagName);
}
if (depth > options.MaxNestingDepth)
{
_logger.LogWarning("HTML nesting depth exceeds max {MaxDepth}, truncating", options.MaxNestingDepth);
break;
}
pos = tagEnd + 1;
}
return result.ToString();
}
public HtmlValidationResult Validate(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return HtmlValidationResult.Safe(new HtmlContentStats());
}
var issues = new List<HtmlSecurityIssue>();
var stats = new HtmlContentStats
{
CharacterCount = html.Length
};
var pos = 0;
var depth = 0;
var maxDepth = 0;
var elementCount = 0;
var linkCount = 0;
var imageCount = 0;
// Check for script tags
if (ScriptTagRegex().IsMatch(html))
{
issues.Add(new HtmlSecurityIssue
{
Type = HtmlSecurityIssueType.ScriptInjection,
Description = "Script tags are not allowed"
});
}
// Check for event handlers
var eventMatches = EventHandlerRegex.Matches(html);
foreach (Match match in eventMatches)
{
issues.Add(new HtmlSecurityIssue
{
Type = HtmlSecurityIssueType.EventHandler,
Description = "Event handler attributes are not allowed",
AttributeName = match.Value,
Position = match.Index
});
}
// Check for dangerous URLs
var hrefMatches = DangerousUrlRegex().Matches(html);
foreach (Match match in hrefMatches)
{
issues.Add(new HtmlSecurityIssue
{
Type = HtmlSecurityIssueType.DangerousUrl,
Description = "Dangerous URL scheme detected",
Position = match.Index
});
}
// Check for dangerous style content
var styleMatches = DangerousStyleRegex.Matches(html);
foreach (Match match in styleMatches)
{
issues.Add(new HtmlSecurityIssue
{
Type = HtmlSecurityIssueType.StyleInjection,
Description = "Dangerous style content detected",
Position = match.Index
});
}
// Check for dangerous elements
var dangerousElements = new[] { "iframe", "object", "embed", "form", "input", "button", "meta", "link", "base" };
foreach (var element in dangerousElements)
{
var elementRegex = new Regex($@"<{element}\b", RegexOptions.IgnoreCase);
if (elementRegex.IsMatch(html))
{
issues.Add(new HtmlSecurityIssue
{
Type = HtmlSecurityIssueType.DangerousElement,
Description = $"Dangerous element '{element}' is not allowed",
ElementName = element
});
}
}
// Count elements and check nesting
while (pos < html.Length)
{
var tagStart = html.IndexOf('<', pos);
if (tagStart < 0) break;
var tagEnd = html.IndexOf('>', tagStart);
if (tagEnd < 0) break;
var tagContent = html[(tagStart + 1)..tagEnd];
var isClosing = tagContent.StartsWith('/');
var tagName = ExtractTagName(tagContent);
if (!isClosing && !string.IsNullOrEmpty(tagName) && !tagContent.EndsWith('/'))
{
if (!IsSelfClosing(tagName))
{
depth++;
maxDepth = Math.Max(maxDepth, depth);
}
elementCount++;
if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase)) linkCount++;
if (tagName.Equals("img", StringComparison.OrdinalIgnoreCase)) imageCount++;
}
else if (isClosing)
{
depth--;
}
pos = tagEnd + 1;
}
stats = stats with
{
ElementCount = elementCount,
MaxDepth = maxDepth,
LinkCount = linkCount,
ImageCount = imageCount
};
return issues.Count == 0
? HtmlValidationResult.Safe(stats)
: HtmlValidationResult.Unsafe(issues, stats);
}
public string StripHtml(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return string.Empty;
}
// Remove all tags
var text = HtmlTagRegex().Replace(html, " ");
// Decode entities
text = System.Net.WebUtility.HtmlDecode(text);
// Normalize whitespace
text = WhitespaceRegex().Replace(text, " ").Trim();
return text;
}
private static string SanitizeTag(
string tagContent,
string tagName,
HashSet<string> allowedAttrs,
HtmlSanitizeOptions options)
{
var result = new StringBuilder(tagName);
// Extract and sanitize attributes
var attrMatches = AttributeRegex().Matches(tagContent);
foreach (Match match in attrMatches)
{
var attrName = match.Groups[1].Value;
var attrValue = match.Groups[2].Value;
if (!allowedAttrs.Contains(attrName))
{
continue;
}
// Skip event handlers
if (EventHandlerRegex.IsMatch(attrName))
{
continue;
}
// Sanitize href/src values
if (attrName.Equals("href", StringComparison.OrdinalIgnoreCase) ||
attrName.Equals("src", StringComparison.OrdinalIgnoreCase))
{
attrValue = SanitizeUrl(attrValue, options);
if (string.IsNullOrEmpty(attrValue))
{
continue;
}
}
// Sanitize style values
if (attrName.Equals("style", StringComparison.OrdinalIgnoreCase))
{
attrValue = SanitizeStyle(attrValue);
if (string.IsNullOrEmpty(attrValue))
{
continue;
}
}
result.Append($" {attrName}=\"{EncodeAttributeValue(attrValue)}\"");
}
// Add rel="noopener noreferrer" to links with target
if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase) &&
tagContent.Contains("target=", StringComparison.OrdinalIgnoreCase))
{
if (!tagContent.Contains("rel=", StringComparison.OrdinalIgnoreCase))
{
result.Append(" rel=\"noopener noreferrer\"");
}
}
if (tagContent.TrimEnd().EndsWith('/'))
{
result.Append(" /");
}
return result.ToString();
}
private static string SanitizeUrl(string url, HtmlSanitizeOptions options)
{
if (string.IsNullOrWhiteSpace(url))
{
return string.Empty;
}
url = url.Trim();
// Check for dangerous schemes
var colonIndex = url.IndexOf(':');
if (colonIndex > 0 && colonIndex < 10)
{
var scheme = url[..colonIndex].ToLowerInvariant();
if (DangerousSchemes.Contains(scheme))
{
if (scheme == "data" && options.AllowDataUrls)
{
// Allow data URLs if explicitly enabled
return url;
}
return string.Empty;
}
}
// Allow relative URLs and safe absolute URLs
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("tel:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith('/') ||
url.StartsWith('#') ||
!url.Contains(':'))
{
return url;
}
return string.Empty;
}
private static string SanitizeStyle(string style)
{
if (string.IsNullOrWhiteSpace(style))
{
return string.Empty;
}
// Remove dangerous CSS
if (DangerousStyleRegex.IsMatch(style))
{
return string.Empty;
}
// Only allow simple property:value pairs
var safeProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"color", "background-color", "font-size", "font-weight", "font-style",
"text-align", "text-decoration", "margin", "padding", "border",
"width", "height", "max-width", "max-height", "display"
};
var result = new StringBuilder();
var pairs = style.Split(';', StringSplitOptions.RemoveEmptyEntries);
foreach (var pair in pairs)
{
var colonIndex = pair.IndexOf(':');
if (colonIndex <= 0) continue;
var property = pair[..colonIndex].Trim().ToLowerInvariant();
var value = pair[(colonIndex + 1)..].Trim();
if (safeProperties.Contains(property) && !value.Contains("url(", StringComparison.OrdinalIgnoreCase))
{
if (result.Length > 0) result.Append("; ");
result.Append($"{property}: {value}");
}
}
return result.ToString();
}
private static string ExtractTagName(string tagContent)
{
var content = tagContent.TrimStart('/').Trim();
var spaceIndex = content.IndexOfAny([' ', '\t', '\n', '\r', '/']);
return spaceIndex > 0 ? content[..spaceIndex] : content;
}
private static bool IsSelfClosing(string tagName)
{
return tagName.Equals("br", StringComparison.OrdinalIgnoreCase) ||
tagName.Equals("hr", StringComparison.OrdinalIgnoreCase) ||
tagName.Equals("img", StringComparison.OrdinalIgnoreCase) ||
tagName.Equals("input", StringComparison.OrdinalIgnoreCase) ||
tagName.Equals("meta", StringComparison.OrdinalIgnoreCase) ||
tagName.Equals("link", StringComparison.OrdinalIgnoreCase);
}
private static string EncodeText(string text)
{
return System.Net.WebUtility.HtmlEncode(text);
}
private static string EncodeAttributeValue(string value)
{
return value
.Replace("&", "&amp;")
.Replace("\"", "&quot;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
[GeneratedRegex(@"\bon\w+\s*=", RegexOptions.IgnoreCase)]
private static partial Regex EventHandlerPattern();
[GeneratedRegex(@"expression\s*\(|behavior\s*:|@import|@charset|binding\s*:", RegexOptions.IgnoreCase)]
private static partial Regex DangerousStylePattern();
[GeneratedRegex(@"<script\b", RegexOptions.IgnoreCase)]
private static partial Regex ScriptTagRegex();
[GeneratedRegex(@"(javascript|vbscript|data)\s*:", RegexOptions.IgnoreCase)]
private static partial Regex DangerousUrlRegex();
[GeneratedRegex(@"<[^>]*>")]
private static partial Regex HtmlTagRegex();
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"(\w+)\s*=\s*""([^""]*)""", RegexOptions.Compiled)]
private static partial Regex AttributeRegex();
}

View File

@@ -0,0 +1,221 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Default implementation of tenant isolation validation.
/// </summary>
public sealed partial class DefaultTenantIsolationValidator : ITenantIsolationValidator
{
private readonly TenantIsolationOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultTenantIsolationValidator> _logger;
private readonly ConcurrentQueue<TenantIsolationViolation> _violations = new();
// Valid tenant ID pattern: alphanumeric, hyphens, underscores, 3-64 chars
private static readonly Regex TenantIdPattern = TenantIdRegex();
public DefaultTenantIsolationValidator(
IOptions<TenantIsolationOptions> options,
TimeProvider timeProvider,
ILogger<DefaultTenantIsolationValidator> logger)
{
_options = options?.Value ?? new TenantIsolationOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public TenantIsolationResult ValidateAccess(
string requestTenantId,
string resourceTenantId,
string resourceType,
string resourceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceTenantId);
// Normalize tenant IDs
var normalizedRequest = NormalizeTenantId(requestTenantId);
var normalizedResource = NormalizeTenantId(resourceTenantId);
// Check for exact match
if (string.Equals(normalizedRequest, normalizedResource, StringComparison.OrdinalIgnoreCase))
{
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
}
// Check for cross-tenant access exceptions (admin tenants, shared resources)
if (_options.AllowCrossTenantAccess &&
_options.CrossTenantAllowedPairs.Contains($"{normalizedRequest}:{normalizedResource}"))
{
_logger.LogDebug(
"Cross-tenant access allowed: {RequestTenant} -> {ResourceTenant} for {ResourceType}",
requestTenantId, resourceTenantId, resourceType);
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
}
// Check if request tenant is an admin tenant
if (_options.AdminTenants.Contains(normalizedRequest))
{
_logger.LogInformation(
"Admin tenant {AdminTenant} accessing resource from {ResourceTenant}",
requestTenantId, resourceTenantId);
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
}
// Violation detected
var violation = new TenantIsolationViolation
{
OccurredAt = _timeProvider.GetUtcNow(),
RequestTenantId = requestTenantId,
ResourceTenantId = resourceTenantId,
ResourceType = resourceType,
ResourceId = resourceId,
Operation = "access"
};
RecordViolation(violation);
_logger.LogWarning(
"Tenant isolation violation: {RequestTenant} attempted to access {ResourceType}/{ResourceId} belonging to {ResourceTenant}",
requestTenantId, resourceType, resourceId, resourceTenantId);
return TenantIsolationResult.Deny(
requestTenantId,
resourceTenantId,
"Cross-tenant access denied",
resourceType,
resourceId);
}
public IReadOnlyList<TenantIsolationResult> ValidateBatch(
string requestTenantId,
IEnumerable<TenantResource> resources)
{
ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId);
ArgumentNullException.ThrowIfNull(resources);
return resources
.Select(r => ValidateAccess(requestTenantId, r.TenantId, r.ResourceType, r.ResourceId))
.ToArray();
}
public string? SanitizeTenantId(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return null;
}
var sanitized = tenantId.Trim();
// Remove any control characters
sanitized = ControlCharsRegex().Replace(sanitized, "");
// Check format
if (!TenantIdPattern.IsMatch(sanitized))
{
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
return null;
}
return sanitized;
}
public bool IsValidTenantIdFormat(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return false;
}
return TenantIdPattern.IsMatch(tenantId.Trim());
}
public void RecordViolation(TenantIsolationViolation violation)
{
ArgumentNullException.ThrowIfNull(violation);
_violations.Enqueue(violation);
// Keep only recent violations
while (_violations.Count > _options.MaxStoredViolations)
{
_violations.TryDequeue(out _);
}
// Emit metrics
TenantIsolationMetrics.RecordViolation(
violation.RequestTenantId,
violation.ResourceTenantId,
violation.ResourceType);
}
public IReadOnlyList<TenantIsolationViolation> GetRecentViolations(int limit = 100)
{
return _violations.TakeLast(Math.Min(limit, _options.MaxStoredViolations)).ToArray();
}
private static string NormalizeTenantId(string tenantId)
{
return tenantId.Trim().ToLowerInvariant();
}
[GeneratedRegex(@"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$")]
private static partial Regex TenantIdRegex();
[GeneratedRegex(@"[\x00-\x1F\x7F]")]
private static partial Regex ControlCharsRegex();
}
/// <summary>
/// Configuration options for tenant isolation.
/// </summary>
public sealed class TenantIsolationOptions
{
/// <summary>
/// Whether to allow any cross-tenant access.
/// </summary>
public bool AllowCrossTenantAccess { get; set; }
/// <summary>
/// Pairs of tenants allowed to access each other's resources.
/// Format: "tenant1:tenant2" means tenant1 can access tenant2's resources.
/// </summary>
public HashSet<string> CrossTenantAllowedPairs { get; set; } = [];
/// <summary>
/// Tenants with admin access to all resources.
/// </summary>
public HashSet<string> AdminTenants { get; set; } = [];
/// <summary>
/// Maximum number of violations to store in memory.
/// </summary>
public int MaxStoredViolations { get; set; } = 1000;
/// <summary>
/// Whether to throw exceptions on violations (vs returning result).
/// </summary>
public bool ThrowOnViolation { get; set; }
}
/// <summary>
/// Metrics for tenant isolation.
/// </summary>
internal static class TenantIsolationMetrics
{
// In a real implementation, these would emit to metrics system
private static long _violationCount;
public static void RecordViolation(string requestTenant, string resourceTenant, string resourceType)
{
Interlocked.Increment(ref _violationCount);
// In production: emit to Prometheus/StatsD/etc.
}
public static long GetViolationCount() => _violationCount;
}

View File

@@ -0,0 +1,329 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Default implementation of webhook security service using HMAC-SHA256.
/// </summary>
public sealed class DefaultWebhookSecurityService : IWebhookSecurityService
{
private const string SignaturePrefix = "v1";
private const int TimestampToleranceSeconds = 300; // 5 minutes
private readonly WebhookSecurityOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultWebhookSecurityService> _logger;
// In-memory storage for channel secrets (in production, use persistent storage)
private readonly ConcurrentDictionary<string, ChannelSecurityConfig> _channelConfigs = new();
public DefaultWebhookSecurityService(
IOptions<WebhookSecurityOptions> options,
TimeProvider timeProvider,
ILogger<DefaultWebhookSecurityService> logger)
{
_options = options?.Value ?? new WebhookSecurityOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string SignPayload(string tenantId, string channelId, ReadOnlySpan<byte> payload, DateTimeOffset timestamp)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
var config = GetOrCreateConfig(tenantId, channelId);
var timestampUnix = timestamp.ToUnixTimeSeconds();
// Create signed payload: timestamp.payload
var signedData = CreateSignedData(timestampUnix, payload);
using var hmac = new HMACSHA256(config.SecretBytes);
var signature = hmac.ComputeHash(signedData);
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
// Format: v1=timestamp,signature
return $"{SignaturePrefix}={timestampUnix},{signatureHex}";
}
public bool VerifySignature(string tenantId, string channelId, ReadOnlySpan<byte> payload, string signatureHeader)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
if (string.IsNullOrWhiteSpace(signatureHeader))
{
_logger.LogWarning("Missing signature header for webhook callback");
return false;
}
// Parse header: v1=timestamp,signature
if (!signatureHeader.StartsWith($"{SignaturePrefix}=", StringComparison.Ordinal))
{
_logger.LogWarning("Invalid signature prefix in header");
return false;
}
var parts = signatureHeader[(SignaturePrefix.Length + 1)..].Split(',');
if (parts.Length != 2)
{
_logger.LogWarning("Invalid signature format in header");
return false;
}
if (!long.TryParse(parts[0], out var timestampUnix))
{
_logger.LogWarning("Invalid timestamp in signature header");
return false;
}
// Check timestamp is within tolerance
var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
if (Math.Abs(now - timestampUnix) > TimestampToleranceSeconds)
{
_logger.LogWarning(
"Signature timestamp {Timestamp} is outside tolerance window (now: {Now})",
timestampUnix, now);
return false;
}
byte[] providedSignature;
try
{
providedSignature = Convert.FromHexString(parts[1]);
}
catch (FormatException)
{
_logger.LogWarning("Invalid signature hex encoding");
return false;
}
var config = GetOrCreateConfig(tenantId, channelId);
var signedData = CreateSignedData(timestampUnix, payload);
using var hmac = new HMACSHA256(config.SecretBytes);
var expectedSignature = hmac.ComputeHash(signedData);
// Also check previous secret if within rotation window
if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature))
{
if (config.PreviousSecretBytes is not null &&
config.PreviousSecretExpiresAt.HasValue &&
_timeProvider.GetUtcNow() < config.PreviousSecretExpiresAt.Value)
{
using var hmacPrev = new HMACSHA256(config.PreviousSecretBytes);
var prevSignature = hmacPrev.ComputeHash(signedData);
return CryptographicOperations.FixedTimeEquals(prevSignature, providedSignature);
}
return false;
}
return true;
}
public IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
ArgumentNullException.ThrowIfNull(ipAddress);
var config = GetOrCreateConfig(tenantId, channelId);
if (config.IpAllowlist.Count == 0)
{
// No allowlist configured - allow all
return IpValidationResult.Allow(hasAllowlist: false);
}
foreach (var entry in config.IpAllowlist)
{
if (IsIpInRange(ipAddress, entry.CidrOrIp))
{
return IpValidationResult.Allow(entry.CidrOrIp, hasAllowlist: true);
}
}
_logger.LogWarning(
"IP {IpAddress} not in allowlist for channel {ChannelId}",
ipAddress, channelId);
return IpValidationResult.Deny($"IP {ipAddress} not in allowlist");
}
public string GetMaskedSecret(string tenantId, string channelId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
var config = GetOrCreateConfig(tenantId, channelId);
var secret = config.Secret;
if (secret.Length <= 8)
{
return "****";
}
return $"{secret[..4]}...{secret[^4..]}";
}
public Task<WebhookSecretRotationResult> RotateSecretAsync(
string tenantId,
string channelId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
var key = GetConfigKey(tenantId, channelId);
var now = _timeProvider.GetUtcNow();
var newSecret = GenerateSecret();
var result = _channelConfigs.AddOrUpdate(
key,
_ => new ChannelSecurityConfig(newSecret),
(_, existing) =>
{
return new ChannelSecurityConfig(newSecret)
{
PreviousSecret = existing.Secret,
PreviousSecretBytes = existing.SecretBytes,
PreviousSecretExpiresAt = now.Add(_options.SecretRotationGracePeriod),
IpAllowlist = existing.IpAllowlist
};
});
_logger.LogInformation(
"Rotated webhook secret for channel {ChannelId}, old secret valid until {ExpiresAt}",
channelId, result.PreviousSecretExpiresAt);
return Task.FromResult(new WebhookSecretRotationResult
{
Success = true,
NewSecret = newSecret,
ActiveAt = now,
OldSecretExpiresAt = result.PreviousSecretExpiresAt
});
}
private ChannelSecurityConfig GetOrCreateConfig(string tenantId, string channelId)
{
var key = GetConfigKey(tenantId, channelId);
return _channelConfigs.GetOrAdd(key, _ => new ChannelSecurityConfig(GenerateSecret()));
}
private static string GetConfigKey(string tenantId, string channelId)
=> $"{tenantId}:{channelId}";
private static string GenerateSecret()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
private static byte[] CreateSignedData(long timestamp, ReadOnlySpan<byte> payload)
{
var timestampBytes = Encoding.UTF8.GetBytes(timestamp.ToString());
var result = new byte[timestampBytes.Length + 1 + payload.Length];
timestampBytes.CopyTo(result, 0);
result[timestampBytes.Length] = (byte)'.';
payload.CopyTo(result.AsSpan(timestampBytes.Length + 1));
return result;
}
private static bool IsIpInRange(IPAddress ip, string cidrOrIp)
{
if (cidrOrIp.Contains('/'))
{
// CIDR notation
var parts = cidrOrIp.Split('/');
if (!IPAddress.TryParse(parts[0], out var networkAddress) ||
!int.TryParse(parts[1], out var prefixLength))
{
return false;
}
return IsInSubnet(ip, networkAddress, prefixLength);
}
else
{
// Single IP
return IPAddress.TryParse(cidrOrIp, out var singleIp) && ip.Equals(singleIp);
}
}
private static bool IsInSubnet(IPAddress ip, IPAddress network, int prefixLength)
{
var ipBytes = ip.GetAddressBytes();
var networkBytes = network.GetAddressBytes();
if (ipBytes.Length != networkBytes.Length)
{
return false;
}
var fullBytes = prefixLength / 8;
var remainingBits = prefixLength % 8;
for (var i = 0; i < fullBytes; i++)
{
if (ipBytes[i] != networkBytes[i])
{
return false;
}
}
if (remainingBits > 0 && fullBytes < ipBytes.Length)
{
var mask = (byte)(0xFF << (8 - remainingBits));
if ((ipBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask))
{
return false;
}
}
return true;
}
private sealed class ChannelSecurityConfig
{
public ChannelSecurityConfig(string secret)
{
Secret = secret;
SecretBytes = Encoding.UTF8.GetBytes(secret);
}
public string Secret { get; }
public byte[] SecretBytes { get; }
public string? PreviousSecret { get; init; }
public byte[]? PreviousSecretBytes { get; init; }
public DateTimeOffset? PreviousSecretExpiresAt { get; init; }
public List<IpAllowlistEntry> IpAllowlist { get; init; } = [];
}
}
/// <summary>
/// Configuration options for webhook security.
/// </summary>
public sealed class WebhookSecurityOptions
{
/// <summary>
/// Grace period during which both old and new secrets are valid after rotation.
/// </summary>
public TimeSpan SecretRotationGracePeriod { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Whether to enforce IP allowlists when configured.
/// </summary>
public bool EnforceIpAllowlist { get; set; } = true;
/// <summary>
/// Timestamp tolerance for signature verification (in seconds).
/// </summary>
public int TimestampToleranceSeconds { get; set; } = 300;
}

View File

@@ -0,0 +1,292 @@
using System.Buffers.Text;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// HMAC-SHA256 based implementation of acknowledgement token service.
/// </summary>
public sealed class HmacAckTokenService : IAckTokenService, IDisposable
{
private const int CurrentVersion = 1;
private const string TokenPrefix = "soa1"; // StellaOps Ack v1
private readonly AckTokenOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HmacAckTokenService> _logger;
private readonly HMACSHA256 _hmac;
private bool _disposed;
public HmacAckTokenService(
IOptions<AckTokenOptions> options,
TimeProvider timeProvider,
ILogger<HmacAckTokenService> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(_options.SigningKey))
{
throw new InvalidOperationException("AckTokenOptions.SigningKey must be configured.");
}
// Derive key using HKDF for proper key derivation
var keyBytes = Encoding.UTF8.GetBytes(_options.SigningKey);
var derivedKey = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
keyBytes,
32, // 256 bits
info: Encoding.UTF8.GetBytes("StellaOps.AckToken.v1"));
_hmac = new HMACSHA256(derivedKey);
}
public AckToken CreateToken(
string tenantId,
string deliveryId,
string action,
TimeSpan? expiration = null,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
ArgumentException.ThrowIfNullOrWhiteSpace(action);
var tokenId = Guid.NewGuid().ToString("N");
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(expiration ?? _options.DefaultExpiration);
var payload = new AckTokenPayload
{
Version = CurrentVersion,
TokenId = tokenId,
TenantId = tenantId,
DeliveryId = deliveryId,
Action = action,
IssuedAt = now.ToUnixTimeSeconds(),
ExpiresAt = expiresAt.ToUnixTimeSeconds(),
Metadata = metadata?.ToDictionary(k => k.Key, k => k.Value) ?? new Dictionary<string, string>()
};
var payloadJson = JsonSerializer.Serialize(payload, AckTokenJsonContext.Default.AckTokenPayload);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
// Sign the payload
var signature = _hmac.ComputeHash(payloadBytes);
// Combine: prefix.payload.signature (all base64url)
var payloadB64 = Base64UrlEncode(payloadBytes);
var signatureB64 = Base64UrlEncode(signature);
var tokenString = $"{TokenPrefix}.{payloadB64}.{signatureB64}";
_logger.LogDebug(
"Created ack token {TokenId} for delivery {DeliveryId} expiring at {ExpiresAt}",
tokenId, deliveryId, expiresAt);
return new AckToken
{
TokenId = tokenId,
TenantId = tenantId,
DeliveryId = deliveryId,
Action = action,
IssuedAt = now,
ExpiresAt = expiresAt,
Metadata = metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
TokenString = tokenString
};
}
public AckTokenVerification VerifyToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Token is empty");
}
var parts = token.Split('.');
if (parts.Length != 3)
{
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid token structure");
}
var prefix = parts[0];
var payloadB64 = parts[1];
var signatureB64 = parts[2];
// Check version prefix
if (prefix != TokenPrefix)
{
return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Unknown prefix: {prefix}");
}
// Decode payload
byte[] payloadBytes;
try
{
payloadBytes = Base64UrlDecode(payloadB64);
}
catch (FormatException)
{
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid payload encoding");
}
// Verify signature
byte[] providedSignature;
try
{
providedSignature = Base64UrlDecode(signatureB64);
}
catch (FormatException)
{
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid signature encoding");
}
var expectedSignature = _hmac.ComputeHash(payloadBytes);
if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature))
{
_logger.LogWarning("Invalid signature for ack token");
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidSignature);
}
// Parse payload
AckTokenPayload payload;
try
{
payload = JsonSerializer.Deserialize(payloadBytes, AckTokenJsonContext.Default.AckTokenPayload)
?? throw new JsonException("Null payload");
}
catch (JsonException ex)
{
return AckTokenVerification.Fail(AckTokenFailureReason.MalformedPayload, ex.Message);
}
// Check version
if (payload.Version != CurrentVersion)
{
return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Version {payload.Version} not supported");
}
// Check expiration
var now = _timeProvider.GetUtcNow();
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
if (now > expiresAt)
{
return AckTokenVerification.Fail(AckTokenFailureReason.Expired, $"Token expired at {expiresAt}");
}
var ackToken = new AckToken
{
TokenId = payload.TokenId,
TenantId = payload.TenantId,
DeliveryId = payload.DeliveryId,
Action = payload.Action,
IssuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAt),
ExpiresAt = expiresAt,
Metadata = payload.Metadata.ToImmutableDictionary(),
TokenString = token
};
return AckTokenVerification.Success(ackToken);
}
public string CreateAckUrl(AckToken token)
{
ArgumentNullException.ThrowIfNull(token);
if (string.IsNullOrWhiteSpace(_options.BaseUrl))
{
throw new InvalidOperationException("AckTokenOptions.BaseUrl must be configured.");
}
var baseUrl = _options.BaseUrl.TrimEnd('/');
return $"{baseUrl}/api/v1/ack/{Uri.EscapeDataString(token.TokenString)}";
}
public void Dispose()
{
if (!_disposed)
{
_hmac.Dispose();
_disposed = true;
}
}
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
private static byte[] Base64UrlDecode(string input)
{
var padded = input
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
/// <summary>
/// Internal payload structure for serialization.
/// </summary>
internal sealed class AckTokenPayload
{
public int Version { get; set; }
public string TokenId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string DeliveryId { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public long IssuedAt { get; set; }
public long ExpiresAt { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
}
}
/// <summary>
/// Configuration options for ack token service.
/// </summary>
public sealed class AckTokenOptions
{
/// <summary>
/// The signing key for HMAC. Should be at least 32 characters.
/// In production, this should come from KMS/Key Vault.
/// </summary>
public string SigningKey { get; set; } = string.Empty;
/// <summary>
/// Base URL for generating acknowledgement URLs.
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// Default token expiration if not specified.
/// </summary>
public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromDays(7);
/// <summary>
/// Maximum allowed token expiration.
/// </summary>
public TimeSpan MaxExpiration { get; set; } = TimeSpan.FromDays(30);
}
/// <summary>
/// JSON serialization context for AOT compatibility.
/// </summary>
[System.Text.Json.Serialization.JsonSerializable(typeof(HmacAckTokenService.AckTokenPayload))]
internal partial class AckTokenJsonContext : System.Text.Json.Serialization.JsonSerializerContext
{
}

View File

@@ -0,0 +1,141 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Service for creating and verifying signed acknowledgement tokens.
/// </summary>
public interface IAckTokenService
{
/// <summary>
/// Creates a signed acknowledgement token for a notification.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="deliveryId">The delivery ID being acknowledged.</param>
/// <param name="action">The action being acknowledged (e.g., "ack", "resolve", "escalate").</param>
/// <param name="expiration">Optional expiration time. Defaults to 7 days.</param>
/// <param name="metadata">Optional metadata to embed in the token.</param>
/// <returns>The signed token.</returns>
AckToken CreateToken(
string tenantId,
string deliveryId,
string action,
TimeSpan? expiration = null,
IReadOnlyDictionary<string, string>? metadata = null);
/// <summary>
/// Verifies a signed acknowledgement token.
/// </summary>
/// <param name="token">The token string to verify.</param>
/// <returns>The verification result.</returns>
AckTokenVerification VerifyToken(string token);
/// <summary>
/// Creates a full acknowledgement URL with the signed token.
/// </summary>
/// <param name="token">The token to embed.</param>
/// <returns>The full URL.</returns>
string CreateAckUrl(AckToken token);
}
/// <summary>
/// A signed acknowledgement token.
/// </summary>
public sealed record AckToken
{
/// <summary>
/// The unique token identifier.
/// </summary>
public required string TokenId { get; init; }
/// <summary>
/// The tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The delivery ID being acknowledged.
/// </summary>
public required string DeliveryId { get; init; }
/// <summary>
/// The action being acknowledged.
/// </summary>
public required string Action { get; init; }
/// <summary>
/// When the token was issued.
/// </summary>
public required DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Optional embedded metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
/// <summary>
/// The signed token string (base64url encoded).
/// </summary>
public required string TokenString { get; init; }
}
/// <summary>
/// Result of token verification.
/// </summary>
public sealed record AckTokenVerification
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The parsed token if valid, null otherwise.
/// </summary>
public AckToken? Token { get; init; }
/// <summary>
/// The failure reason if invalid.
/// </summary>
public AckTokenFailureReason? FailureReason { get; init; }
/// <summary>
/// Additional failure details.
/// </summary>
public string? FailureDetails { get; init; }
public static AckTokenVerification Success(AckToken token)
=> new() { IsValid = true, Token = token };
public static AckTokenVerification Fail(AckTokenFailureReason reason, string? details = null)
=> new() { IsValid = false, FailureReason = reason, FailureDetails = details };
}
/// <summary>
/// Reasons for token verification failure.
/// </summary>
public enum AckTokenFailureReason
{
/// <summary>Token format is invalid.</summary>
InvalidFormat,
/// <summary>Token signature is invalid.</summary>
InvalidSignature,
/// <summary>Token has expired.</summary>
Expired,
/// <summary>Token has been revoked.</summary>
Revoked,
/// <summary>Token payload is malformed.</summary>
MalformedPayload,
/// <summary>Token version is unsupported.</summary>
UnsupportedVersion
}

View File

@@ -0,0 +1,177 @@
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Service for sanitizing HTML content in notification templates.
/// </summary>
public interface IHtmlSanitizer
{
/// <summary>
/// Sanitizes HTML content, removing potentially dangerous elements and attributes.
/// </summary>
/// <param name="html">The HTML content to sanitize.</param>
/// <param name="options">Optional sanitization options.</param>
/// <returns>The sanitized HTML.</returns>
string Sanitize(string html, HtmlSanitizeOptions? options = null);
/// <summary>
/// Validates HTML content and returns any security issues found.
/// </summary>
/// <param name="html">The HTML content to validate.</param>
/// <returns>Validation result with any issues found.</returns>
HtmlValidationResult Validate(string html);
/// <summary>
/// Strips all HTML tags, leaving only text content.
/// </summary>
/// <param name="html">The HTML content.</param>
/// <returns>Plain text content.</returns>
string StripHtml(string html);
}
/// <summary>
/// Options for HTML sanitization.
/// </summary>
public sealed class HtmlSanitizeOptions
{
/// <summary>
/// Additional tags to allow beyond the default set.
/// </summary>
public IReadOnlySet<string>? AdditionalAllowedTags { get; init; }
/// <summary>
/// Additional attributes to allow beyond the default set.
/// </summary>
public IReadOnlySet<string>? AdditionalAllowedAttributes { get; init; }
/// <summary>
/// Whether to allow data: URLs in src attributes. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Whether to allow external URLs. Default: true.
/// </summary>
public bool AllowExternalUrls { get; init; } = true;
/// <summary>
/// Maximum allowed depth of nested elements. Default: 50.
/// </summary>
public int MaxNestingDepth { get; init; } = 50;
/// <summary>
/// Maximum content length. Default: 1MB.
/// </summary>
public int MaxContentLength { get; init; } = 1024 * 1024;
}
/// <summary>
/// Result of HTML validation.
/// </summary>
public sealed record HtmlValidationResult
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlSecurityIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlContentStats? Stats { get; init; }
public static HtmlValidationResult Safe(HtmlContentStats? stats = null)
=> new() { IsSafe = true, Issues = [], Stats = stats };
public static HtmlValidationResult Unsafe(IReadOnlyList<HtmlSecurityIssue> issues, HtmlContentStats? stats = null)
=> new() { IsSafe = false, Issues = issues, Stats = stats };
}
/// <summary>
/// A security issue found in HTML content.
/// </summary>
public sealed record HtmlSecurityIssue
{
/// <summary>
/// The type of security issue.
/// </summary>
public required HtmlSecurityIssueType Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The problematic element or attribute name.
/// </summary>
public string? ElementName { get; init; }
/// <summary>
/// The problematic attribute name.
/// </summary>
public string? AttributeName { get; init; }
/// <summary>
/// Approximate location in the content.
/// </summary>
public int? Position { get; init; }
}
/// <summary>
/// Types of HTML security issues.
/// </summary>
public enum HtmlSecurityIssueType
{
/// <summary>Script element or inline script.</summary>
ScriptInjection,
/// <summary>Event handler attribute (onclick, onerror, etc.).</summary>
EventHandler,
/// <summary>Dangerous URL scheme (javascript:, data:, etc.).</summary>
DangerousUrl,
/// <summary>Potentially dangerous element (iframe, object, embed, etc.).</summary>
DangerousElement,
/// <summary>Style-based attack (expression, behavior, etc.).</summary>
StyleInjection,
/// <summary>Form-based attack (action hijacking).</summary>
FormHijacking,
/// <summary>Content exceeds size limits.</summary>
ContentTooLarge,
/// <summary>Excessive nesting depth.</summary>
ExcessiveNesting,
/// <summary>Malformed HTML that could be used to bypass filters.</summary>
MalformedHtml
}
/// <summary>
/// Statistics about HTML content.
/// </summary>
public sealed record HtmlContentStats
{
/// <summary>Total character count.</summary>
public int CharacterCount { get; init; }
/// <summary>Number of HTML elements.</summary>
public int ElementCount { get; init; }
/// <summary>Maximum nesting depth.</summary>
public int MaxDepth { get; init; }
/// <summary>Number of links.</summary>
public int LinkCount { get; init; }
/// <summary>Number of images.</summary>
public int ImageCount { get; init; }
}

View File

@@ -0,0 +1,190 @@
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Service for validating tenant isolation across operations.
/// </summary>
public interface ITenantIsolationValidator
{
/// <summary>
/// Validates that a resource belongs to the specified tenant.
/// </summary>
/// <param name="requestTenantId">The tenant ID from the request.</param>
/// <param name="resourceTenantId">The tenant ID of the resource being accessed.</param>
/// <param name="resourceType">The type of resource being accessed.</param>
/// <param name="resourceId">The ID of the resource being accessed.</param>
/// <returns>Validation result.</returns>
TenantIsolationResult ValidateAccess(
string requestTenantId,
string resourceTenantId,
string resourceType,
string resourceId);
/// <summary>
/// Validates a batch of resources belong to the specified tenant.
/// </summary>
/// <param name="requestTenantId">The tenant ID from the request.</param>
/// <param name="resources">The resources to validate.</param>
/// <returns>Validation result for each resource.</returns>
IReadOnlyList<TenantIsolationResult> ValidateBatch(
string requestTenantId,
IEnumerable<TenantResource> resources);
/// <summary>
/// Sanitizes a tenant ID for safe use.
/// </summary>
/// <param name="tenantId">The tenant ID to sanitize.</param>
/// <returns>The sanitized tenant ID or null if invalid.</returns>
string? SanitizeTenantId(string? tenantId);
/// <summary>
/// Validates tenant ID format.
/// </summary>
/// <param name="tenantId">The tenant ID to validate.</param>
/// <returns>True if valid format.</returns>
bool IsValidTenantIdFormat(string? tenantId);
/// <summary>
/// Registers a tenant isolation violation for monitoring.
/// </summary>
/// <param name="violation">The violation details.</param>
void RecordViolation(TenantIsolationViolation violation);
/// <summary>
/// Gets recent violations for monitoring purposes.
/// </summary>
/// <param name="limit">Maximum number of violations to return.</param>
/// <returns>Recent violations.</returns>
IReadOnlyList<TenantIsolationViolation> GetRecentViolations(int limit = 100);
}
/// <summary>
/// A resource with tenant information.
/// </summary>
public sealed record TenantResource
{
/// <summary>
/// The tenant ID of the resource.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The type of resource.
/// </summary>
public required string ResourceType { get; init; }
/// <summary>
/// The resource ID.
/// </summary>
public required string ResourceId { get; init; }
}
/// <summary>
/// Result of tenant isolation validation.
/// </summary>
public sealed record TenantIsolationResult
{
/// <summary>
/// Whether access is allowed.
/// </summary>
public required bool IsAllowed { get; init; }
/// <summary>
/// The request tenant ID.
/// </summary>
public required string RequestTenantId { get; init; }
/// <summary>
/// The resource tenant ID.
/// </summary>
public required string ResourceTenantId { get; init; }
/// <summary>
/// The resource type.
/// </summary>
public string? ResourceType { get; init; }
/// <summary>
/// The resource ID.
/// </summary>
public string? ResourceId { get; init; }
/// <summary>
/// Rejection reason if not allowed.
/// </summary>
public string? RejectionReason { get; init; }
public static TenantIsolationResult Allow(string requestTenantId, string resourceTenantId)
=> new()
{
IsAllowed = true,
RequestTenantId = requestTenantId,
ResourceTenantId = resourceTenantId
};
public static TenantIsolationResult Deny(
string requestTenantId,
string resourceTenantId,
string reason,
string? resourceType = null,
string? resourceId = null)
=> new()
{
IsAllowed = false,
RequestTenantId = requestTenantId,
ResourceTenantId = resourceTenantId,
RejectionReason = reason,
ResourceType = resourceType,
ResourceId = resourceId
};
}
/// <summary>
/// Record of a tenant isolation violation.
/// </summary>
public sealed record TenantIsolationViolation
{
/// <summary>
/// When the violation occurred.
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// The request tenant ID.
/// </summary>
public required string RequestTenantId { get; init; }
/// <summary>
/// The resource tenant ID.
/// </summary>
public required string ResourceTenantId { get; init; }
/// <summary>
/// The type of resource accessed.
/// </summary>
public required string ResourceType { get; init; }
/// <summary>
/// The resource ID accessed.
/// </summary>
public required string ResourceId { get; init; }
/// <summary>
/// The operation being performed.
/// </summary>
public string? Operation { get; init; }
/// <summary>
/// Source IP address of the request.
/// </summary>
public string? SourceIp { get; init; }
/// <summary>
/// User agent of the request.
/// </summary>
public string? UserAgent { get; init; }
/// <summary>
/// Additional context about the violation.
/// </summary>
public IReadOnlyDictionary<string, string>? Context { get; init; }
}

View File

@@ -0,0 +1,147 @@
using System.Net;
namespace StellaOps.Notifier.Worker.Security;
/// <summary>
/// Service for webhook security including HMAC signing and IP validation.
/// </summary>
public interface IWebhookSecurityService
{
/// <summary>
/// Signs a webhook payload and returns the signature header value.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="payload">The payload bytes to sign.</param>
/// <param name="timestamp">The timestamp to include in signature.</param>
/// <returns>The signature header value.</returns>
string SignPayload(string tenantId, string channelId, ReadOnlySpan<byte> payload, DateTimeOffset timestamp);
/// <summary>
/// Verifies an incoming webhook callback signature.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="payload">The payload bytes.</param>
/// <param name="signatureHeader">The signature header value.</param>
/// <returns>True if signature is valid.</returns>
bool VerifySignature(string tenantId, string channelId, ReadOnlySpan<byte> payload, string signatureHeader);
/// <summary>
/// Validates if an IP address is allowed for a channel.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="ipAddress">The IP address to check.</param>
/// <returns>Validation result.</returns>
IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress);
/// <summary>
/// Gets the current webhook secret for a channel (for configuration display).
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <returns>A masked version of the secret.</returns>
string GetMaskedSecret(string tenantId, string channelId);
/// <summary>
/// Rotates the webhook secret for a channel.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The new secret.</returns>
Task<WebhookSecretRotationResult> RotateSecretAsync(
string tenantId,
string channelId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of IP validation.
/// </summary>
public sealed record IpValidationResult
{
/// <summary>
/// Whether the IP is allowed.
/// </summary>
public required bool IsAllowed { get; init; }
/// <summary>
/// The reason for rejection if not allowed.
/// </summary>
public string? RejectionReason { get; init; }
/// <summary>
/// The matched allowlist entry if allowed.
/// </summary>
public string? MatchedEntry { get; init; }
/// <summary>
/// Whether an allowlist is configured for this channel.
/// </summary>
public bool HasAllowlist { get; init; }
public static IpValidationResult Allow(string? matchedEntry = null, bool hasAllowlist = false)
=> new() { IsAllowed = true, MatchedEntry = matchedEntry, HasAllowlist = hasAllowlist };
public static IpValidationResult Deny(string reason, bool hasAllowlist = true)
=> new() { IsAllowed = false, RejectionReason = reason, HasAllowlist = hasAllowlist };
}
/// <summary>
/// Result of secret rotation.
/// </summary>
public sealed record WebhookSecretRotationResult
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only available immediately after rotation).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// Error message if rotation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
}
/// <summary>
/// Configuration for an IP allowlist entry.
/// </summary>
public sealed record IpAllowlistEntry
{
/// <summary>
/// The CIDR notation or single IP address.
/// </summary>
public required string CidrOrIp { get; init; }
/// <summary>
/// Optional description for this entry.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// When this entry was added.
/// </summary>
public DateTimeOffset AddedAt { get; init; }
/// <summary>
/// Who added this entry.
/// </summary>
public string? AddedBy { get; init; }
}

View File

@@ -4,14 +4,16 @@ using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
using StellaOps.Notify.Storage.Mongo.Tenancy;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyChannelRepository : INotifyChannelRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private readonly ITenantContext _tenantContext;
public NotifyChannelRepository(NotifyMongoContext context)
public NotifyChannelRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
{
if (context is null)
{
@@ -19,23 +21,34 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
_tenantContext = tenantContext ?? NullTenantContext.Instance;
}
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
_tenantContext.ValidateTenant(channel.TenantId);
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId));
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(channel.TenantId, channel.ChannelId)),
Builders<BsonDocument>.Filter.Eq("tenantId", channel.TenantId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId))
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and explicit tenantId check
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document);
@@ -43,28 +56,30 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository
public async Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId));
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -5,14 +5,16 @@ using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
using StellaOps.Notify.Storage.Mongo.Tenancy;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyRuleRepository : INotifyRuleRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private readonly ITenantContext _tenantContext;
public NotifyRuleRepository(NotifyMongoContext context)
public NotifyRuleRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
{
if (context is null)
{
@@ -20,23 +22,34 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
_tenantContext = tenantContext ?? NullTenantContext.Instance;
}
public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rule);
_tenantContext.ValidateTenant(rule.TenantId);
var document = NotifyRuleDocumentMapper.ToBsonDocument(rule);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(rule.TenantId, rule.RuleId));
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(rule.TenantId, rule.RuleId)),
Builders<BsonDocument>.Filter.Eq("tenantId", rule.TenantId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId))
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and explicit tenantId check
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyRuleDocumentMapper.FromBsonDocument(document);
@@ -44,17 +57,27 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
public async Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyRuleDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId));
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
@@ -62,12 +85,4 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -4,14 +4,16 @@ using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
using StellaOps.Notify.Storage.Mongo.Tenancy;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
private readonly ITenantContext _tenantContext;
public NotifyTemplateRepository(NotifyMongoContext context)
public NotifyTemplateRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
{
if (context is null)
{
@@ -19,23 +21,34 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
_tenantContext = tenantContext ?? NullTenantContext.Instance;
}
public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
_tenantContext.ValidateTenant(template.TenantId);
var document = NotifyTemplateDocumentMapper.ToBsonDocument(template);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(template.TenantId, template.TemplateId));
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(template.TenantId, template.TemplateId)),
Builders<BsonDocument>.Filter.Eq("tenantId", template.TenantId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId))
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and explicit tenantId check
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyTemplateDocumentMapper.FromBsonDocument(document);
@@ -43,28 +56,30 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
_tenantContext.ValidateTenant(tenantId);
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyTemplateDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId));
_tenantContext.ValidateTenant(tenantId);
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
var filter = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,145 @@
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
/// <summary>
/// Provides tenant context for RLS-like tenant isolation in storage operations.
/// </summary>
public interface ITenantContext
{
/// <summary>
/// Gets the current authenticated tenant ID, or null if not authenticated.
/// </summary>
string? CurrentTenantId { get; }
/// <summary>
/// Returns true if the current context has a valid tenant.
/// </summary>
bool HasTenant { get; }
/// <summary>
/// Validates that the requested tenant matches the current context.
/// Throws <see cref="TenantMismatchException"/> if validation fails.
/// </summary>
/// <param name="requestedTenantId">The tenant ID being requested.</param>
/// <exception cref="TenantMismatchException">Thrown when tenants don't match.</exception>
void ValidateTenant(string requestedTenantId);
/// <summary>
/// Returns true if the current context allows access to the specified tenant.
/// Admin tenants may access other tenants.
/// </summary>
bool CanAccessTenant(string targetTenantId);
}
/// <summary>
/// Exception thrown when a tenant isolation violation is detected.
/// </summary>
public sealed class TenantMismatchException : InvalidOperationException
{
public string RequestedTenantId { get; }
public string? CurrentTenantId { get; }
public TenantMismatchException(string requestedTenantId, string? currentTenantId)
: base($"Tenant isolation violation: requested tenant '{requestedTenantId}' does not match current tenant '{currentTenantId ?? "(none)"}'")
{
RequestedTenantId = requestedTenantId;
CurrentTenantId = currentTenantId;
}
}
/// <summary>
/// Default implementation that uses AsyncLocal to track tenant context.
/// </summary>
public sealed class DefaultTenantContext : ITenantContext
{
private static readonly AsyncLocal<string?> _currentTenant = new();
private readonly HashSet<string> _adminTenants;
public DefaultTenantContext(IEnumerable<string>? adminTenants = null)
{
_adminTenants = adminTenants?.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "admin", "system" };
}
public string? CurrentTenantId
{
get => _currentTenant.Value;
set => _currentTenant.Value = value;
}
public bool HasTenant => !string.IsNullOrWhiteSpace(_currentTenant.Value);
public void ValidateTenant(string requestedTenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(requestedTenantId);
if (!CanAccessTenant(requestedTenantId))
{
throw new TenantMismatchException(requestedTenantId, CurrentTenantId);
}
}
public bool CanAccessTenant(string targetTenantId)
{
if (string.IsNullOrWhiteSpace(targetTenantId))
return false;
// No current tenant means no access
if (!HasTenant)
return false;
// Same tenant always allowed
if (string.Equals(CurrentTenantId, targetTenantId, StringComparison.OrdinalIgnoreCase))
return true;
// Admin tenants can access other tenants
if (_adminTenants.Contains(CurrentTenantId!))
return true;
return false;
}
/// <summary>
/// Sets the current tenant context. Returns a disposable to restore previous value.
/// </summary>
public IDisposable SetTenant(string tenantId)
{
var previous = _currentTenant.Value;
_currentTenant.Value = tenantId;
return new TenantScope(previous);
}
private sealed class TenantScope : IDisposable
{
private readonly string? _previousTenant;
private bool _disposed;
public TenantScope(string? previousTenant) => _previousTenant = previousTenant;
public void Dispose()
{
if (!_disposed)
{
_currentTenant.Value = _previousTenant;
_disposed = true;
}
}
}
}
/// <summary>
/// Null implementation for testing or contexts without tenant isolation.
/// </summary>
public sealed class NullTenantContext : ITenantContext
{
public static readonly NullTenantContext Instance = new();
public string? CurrentTenantId => null;
public bool HasTenant => false;
public void ValidateTenant(string requestedTenantId)
{
// No-op - allows all access
}
public bool CanAccessTenant(string targetTenantId) => true;
}

View File

@@ -0,0 +1,109 @@
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
/// <summary>
/// Base class for tenant-aware MongoDB repositories with RLS-like filtering.
/// </summary>
public abstract class TenantAwareRepository
{
private readonly ITenantContext _tenantContext;
protected TenantAwareRepository(ITenantContext? tenantContext = null)
{
_tenantContext = tenantContext ?? NullTenantContext.Instance;
}
/// <summary>
/// Gets the tenant context for validation.
/// </summary>
protected ITenantContext TenantContext => _tenantContext;
/// <summary>
/// Validates that the requested tenant is accessible from the current context.
/// </summary>
/// <param name="requestedTenantId">The tenant ID being requested.</param>
protected void ValidateTenantAccess(string requestedTenantId)
{
_tenantContext.ValidateTenant(requestedTenantId);
}
/// <summary>
/// Creates a filter that includes both ID and explicit tenantId check (dual-filter pattern).
/// This provides RLS-like defense-in-depth.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="documentId">The full document ID (typically tenant-scoped).</param>
/// <returns>A filter requiring both ID match and tenantId match.</returns>
protected static FilterDefinition<BsonDocument> CreateTenantSafeIdFilter(
string tenantId,
string documentId)
{
return Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("_id", documentId),
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
);
}
/// <summary>
/// Wraps a filter with an explicit tenantId check.
/// </summary>
/// <param name="tenantId">The tenant ID to scope the query to.</param>
/// <param name="baseFilter">The base filter to wrap.</param>
/// <returns>A filter that includes the tenantId check.</returns>
protected static FilterDefinition<BsonDocument> WithTenantScope(
string tenantId,
FilterDefinition<BsonDocument> baseFilter)
{
return Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
baseFilter
);
}
/// <summary>
/// Creates a filter for listing documents within a tenant.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="includeDeleted">Whether to include soft-deleted documents.</param>
/// <returns>A filter for the tenant's documents.</returns>
protected static FilterDefinition<BsonDocument> CreateTenantListFilter(
string tenantId,
bool includeDeleted = false)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId);
if (!includeDeleted)
{
filter = Builders<BsonDocument>.Filter.And(
filter,
Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)
)
);
}
return filter;
}
/// <summary>
/// Creates a sort definition for common ordering patterns.
/// </summary>
/// <param name="sortBy">The field to sort by.</param>
/// <param name="ascending">True for ascending, false for descending.</param>
/// <returns>A sort definition.</returns>
protected static SortDefinition<BsonDocument> CreateSort(string sortBy, bool ascending = true)
{
return ascending
? Builders<BsonDocument>.Sort.Ascending(sortBy)
: Builders<BsonDocument>.Sort.Descending(sortBy);
}
/// <summary>
/// Creates a document ID using the tenant-scoped format.
/// </summary>
protected static string CreateDocumentId(string tenantId, string resourceId)
=> TenantScopedId.Create(tenantId, resourceId);
}

View File

@@ -0,0 +1,86 @@
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
/// <summary>
/// Helper for constructing tenant-scoped document IDs with consistent format.
/// </summary>
public static class TenantScopedId
{
private const char Separator = ':';
/// <summary>
/// Creates a tenant-scoped ID in the format "{tenantId}:{resourceId}".
/// </summary>
/// <param name="tenantId">The tenant ID (required).</param>
/// <param name="resourceId">The resource ID (required).</param>
/// <returns>A composite ID string.</returns>
/// <exception cref="ArgumentException">Thrown if either parameter is null or whitespace.</exception>
public static string Create(string tenantId, string resourceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceId);
// Validate no separator in tenant or resource IDs to prevent injection
if (tenantId.Contains(Separator))
throw new ArgumentException($"Tenant ID cannot contain '{Separator}'", nameof(tenantId));
if (resourceId.Contains(Separator))
throw new ArgumentException($"Resource ID cannot contain '{Separator}'", nameof(resourceId));
return string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = Separator;
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}
/// <summary>
/// Parses a tenant-scoped ID into its components.
/// </summary>
/// <param name="scopedId">The composite ID to parse.</param>
/// <param name="tenantId">Output: the extracted tenant ID.</param>
/// <param name="resourceId">Output: the extracted resource ID.</param>
/// <returns>True if parsing succeeded, false otherwise.</returns>
public static bool TryParse(string scopedId, out string tenantId, out string resourceId)
{
tenantId = string.Empty;
resourceId = string.Empty;
if (string.IsNullOrWhiteSpace(scopedId))
return false;
var separatorIndex = scopedId.IndexOf(Separator);
if (separatorIndex <= 0 || separatorIndex >= scopedId.Length - 1)
return false;
tenantId = scopedId[..separatorIndex];
resourceId = scopedId[(separatorIndex + 1)..];
return !string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(resourceId);
}
/// <summary>
/// Extracts the tenant ID from a tenant-scoped ID.
/// </summary>
/// <param name="scopedId">The composite ID.</param>
/// <returns>The tenant ID, or null if parsing failed.</returns>
public static string? ExtractTenantId(string scopedId)
{
return TryParse(scopedId, out var tenantId, out _) ? tenantId : null;
}
/// <summary>
/// Validates that a scoped ID belongs to the expected tenant.
/// </summary>
/// <param name="scopedId">The composite ID to validate.</param>
/// <param name="expectedTenantId">The expected tenant ID.</param>
/// <returns>True if the ID belongs to the expected tenant.</returns>
public static bool BelongsToTenant(string scopedId, string expectedTenantId)
{
if (string.IsNullOrWhiteSpace(scopedId) || string.IsNullOrWhiteSpace(expectedTenantId))
return false;
var extractedTenant = ExtractTenantId(scopedId);
return string.Equals(extractedTenant, expectedTenantId, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -7,12 +7,13 @@ namespace StellaOps.Scanner.Worker.Determinism;
/// </summary>
public sealed class DeterminismContext
{
public DeterminismContext(bool fixedClock, DateTimeOffset fixedInstantUtc, int? rngSeed, bool filterLogs)
public DeterminismContext(bool fixedClock, DateTimeOffset fixedInstantUtc, int? rngSeed, bool filterLogs, int? concurrencyLimit)
{
FixedClock = fixedClock;
FixedInstantUtc = fixedInstantUtc.ToUniversalTime();
RngSeed = rngSeed;
FilterLogs = filterLogs;
ConcurrencyLimit = concurrencyLimit;
}
public bool FixedClock { get; }
@@ -22,4 +23,6 @@ public sealed class DeterminismContext
public int? RngSeed { get; }
public bool FilterLogs { get; }
public int? ConcurrencyLimit { get; }
}

View File

@@ -42,6 +42,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
private readonly ICryptoHash _hash;
private readonly IRubyPackageInventoryStore _rubyPackageStore;
private readonly Determinism.DeterminismContext _determinism;
private readonly string _componentVersion;
public SurfaceManifestStageExecutor(
@@ -51,7 +52,8 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
ScannerWorkerMetrics metrics,
ILogger<SurfaceManifestStageExecutor> logger,
ICryptoHash hash,
IRubyPackageInventoryStore rubyPackageStore)
IRubyPackageInventoryStore rubyPackageStore,
Determinism.DeterminismContext determinism)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache));
@@ -60,6 +62,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
_rubyPackageStore = rubyPackageStore ?? throw new ArgumentNullException(nameof(rubyPackageStore));
_determinism = determinism ?? throw new ArgumentNullException(nameof(determinism));
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
}
@@ -221,9 +224,56 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}));
}
var determinismPayload = BuildDeterminismPayload(context, payloads);
if (determinismPayload is not null)
{
payloads.Add(determinismPayload);
}
return payloads;
}
private SurfaceManifestPayload? BuildDeterminismPayload(ScanJobContext context, IEnumerable<SurfaceManifestPayload> payloads)
{
var pins = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (context.Lease.Metadata.TryGetValue("determinism.feed", out var feed) && !string.IsNullOrWhiteSpace(feed))
{
pins["feed"] = feed;
}
if (context.Lease.Metadata.TryGetValue("determinism.policy", out var policy) && !string.IsNullOrWhiteSpace(policy))
{
pins["policy"] = policy;
}
var artifactHashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var payload in payloads)
{
var digest = ComputeDigest(payload.Content.Span);
artifactHashes[payload.Kind] = digest;
}
var report = new
{
fixedClock = _determinism.FixedClock,
fixedInstantUtc = _determinism.FixedInstantUtc,
rngSeed = _determinism.RngSeed,
filterLogs = _determinism.FilterLogs,
concurrencyLimit = _determinism.ConcurrencyLimit,
pins = pins,
artifacts = artifactHashes
};
var json = JsonSerializer.Serialize(report, JsonOptions);
return new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "determinism.json",
MediaType: "application/json",
Content: Encoding.UTF8.GetBytes(json),
View: "replay");
}
private async Task PersistRubyPackagesAsync(ScanJobContext context, CancellationToken cancellationToken)
{
if (!context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results))

View File

@@ -58,7 +58,8 @@ builder.Services.AddSingleton(new DeterminismContext(
workerOptions.Determinism.FixedClock,
workerOptions.Determinism.FixedInstantUtc,
workerOptions.Determinism.RngSeed,
workerOptions.Determinism.FilterLogs));
workerOptions.Determinism.FilterLogs,
workerOptions.Determinism.ConcurrencyLimit));
builder.Services.AddSingleton<IDeterministicRandomProvider>(_ => new DeterministicRandomProvider(workerOptions.Determinism.RngSeed));
builder.Services.AddScannerCache(builder.Configuration);
builder.Services.AddSurfaceEnvironment(options =>

View File

@@ -645,7 +645,7 @@ internal static class NodePackageCollector
packageSha256: packageSha256,
isYarnPnp: yarnPnpPresent);
AttachEntrypoints(package, root, relativeDirectory);
AttachEntrypoints(context, package, root, relativeDirectory);
return package;
}

View File

@@ -4,15 +4,21 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
internal static class RubyObservationBuilder
{
private const string SchemaVersion = "stellaops.ruby.observation@1";
public static RubyObservationDocument Build(
IReadOnlyList<RubyPackage> packages,
RubyLockData lockData,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities,
RubyBundlerConfig bundlerConfig,
string? bundledWith)
{
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(lockData);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(bundlerConfig);
var packageItems = packages
.OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase)
@@ -20,6 +26,9 @@ internal static class RubyObservationBuilder
.Select(CreatePackage)
.ToImmutableArray();
var entrypoints = BuildEntrypoints(runtimeGraph, packages);
var dependencyItems = BuildDependencyEdges(lockData);
var runtimeItems = packages
.Select(package => CreateRuntimeEdge(package, runtimeGraph))
.Where(static edge => edge is not null)
@@ -27,6 +36,8 @@ internal static class RubyObservationBuilder
.OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var environment = BuildEnvironment(lockData, bundlerConfig, capabilities, bundledWith);
var capabilitySummary = new RubyObservationCapabilitySummary(
capabilities.UsesExec,
capabilities.UsesNetwork,
@@ -39,7 +50,134 @@ internal static class RubyObservationBuilder
? null
: bundledWith.Trim();
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary, normalizedBundler);
return new RubyObservationDocument(
SchemaVersion,
packageItems,
entrypoints,
dependencyItems,
runtimeItems,
environment,
capabilitySummary,
normalizedBundler);
}
private static ImmutableArray<RubyObservationEntrypoint> BuildEntrypoints(
RubyRuntimeGraph runtimeGraph,
IReadOnlyList<RubyPackage> packages)
{
var entrypoints = new List<RubyObservationEntrypoint>();
var packageNames = packages.Select(static p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var entryFile in runtimeGraph.GetEntrypointFiles())
{
var type = InferEntrypointType(entryFile);
var requiredGems = runtimeGraph.GetRequiredGems(entryFile)
.Where(gem => packageNames.Contains(gem))
.OrderBy(static gem => gem, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
entrypoints.Add(new RubyObservationEntrypoint(entryFile, type, requiredGems));
}
return entrypoints
.OrderBy(static e => e.Path, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static string InferEntrypointType(string path)
{
var fileName = Path.GetFileName(path);
if (fileName.Equals("config.ru", StringComparison.OrdinalIgnoreCase))
{
return "rack";
}
if (fileName.Equals("Rakefile", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".rake", StringComparison.OrdinalIgnoreCase))
{
return "rake";
}
if (path.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("\\bin\\", StringComparison.OrdinalIgnoreCase))
{
return "executable";
}
if (fileName.Equals("Gemfile", StringComparison.OrdinalIgnoreCase))
{
return "gemfile";
}
return "script";
}
private static RubyObservationEnvironment BuildEnvironment(
RubyLockData lockData,
RubyBundlerConfig bundlerConfig,
RubyCapabilities capabilities,
string? bundledWith)
{
var bundlePaths = bundlerConfig.BundlePaths
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var gemfiles = bundlerConfig.Gemfiles
.Select(static p => p.Replace('\\', '/'))
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var lockFiles = lockData.Entries
.Select(static e => e.LockFileRelativePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var frameworks = DetectFrameworks(capabilities)
.OrderBy(static f => f, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new RubyObservationEnvironment(
RubyVersion: null,
BundlerVersion: string.IsNullOrWhiteSpace(bundledWith) ? null : bundledWith.Trim(),
bundlePaths,
gemfiles,
lockFiles,
frameworks);
}
private static IEnumerable<string> DetectFrameworks(RubyCapabilities capabilities)
{
if (capabilities.HasJobSchedulers)
{
foreach (var scheduler in capabilities.JobSchedulers)
{
yield return scheduler;
}
}
}
private static ImmutableArray<RubyObservationDependencyEdge> BuildDependencyEdges(RubyLockData lockData)
{
var edges = new List<RubyObservationDependencyEdge>();
foreach (var entry in lockData.Entries)
{
var fromPackage = $"pkg:gem/{entry.Name}@{entry.Version}";
foreach (var dep in entry.Dependencies)
{
edges.Add(new RubyObservationDependencyEdge(
fromPackage,
dep.DependencyName,
dep.VersionConstraint));
}
}
return edges
.OrderBy(static edge => edge.FromPackage, StringComparer.OrdinalIgnoreCase)
.ThenBy(static edge => edge.ToPackage, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static RubyObservationPackage CreatePackage(RubyPackage package)

View File

@@ -2,9 +2,17 @@ using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
/// <summary>
/// AOC-compliant observation document for Ruby project analysis.
/// Contains components, entrypoints, dependency edges, and environment profiles.
/// </summary>
internal sealed record RubyObservationDocument(
string Schema,
ImmutableArray<RubyObservationPackage> Packages,
ImmutableArray<RubyObservationEntrypoint> Entrypoints,
ImmutableArray<RubyObservationDependencyEdge> DependencyEdges,
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
RubyObservationEnvironment Environment,
RubyObservationCapabilitySummary Capabilities,
string? BundledWith);
@@ -18,6 +26,19 @@ internal sealed record RubyObservationPackage(
string? Artifact,
ImmutableArray<string> Groups);
/// <summary>
/// Entrypoint detected in the Ruby project (Rakefile, bin scripts, config.ru, etc).
/// </summary>
internal sealed record RubyObservationEntrypoint(
string Path,
string Type,
ImmutableArray<string> RequiredGems);
internal sealed record RubyObservationDependencyEdge(
string FromPackage,
string ToPackage,
string? VersionConstraint);
internal sealed record RubyObservationRuntimeEdge(
string Package,
bool UsedByEntrypoint,
@@ -25,6 +46,17 @@ internal sealed record RubyObservationRuntimeEdge(
ImmutableArray<string> Entrypoints,
ImmutableArray<string> Reasons);
/// <summary>
/// Environment profile with Ruby version, Bundler settings, and paths.
/// </summary>
internal sealed record RubyObservationEnvironment(
string? RubyVersion,
string? BundlerVersion,
ImmutableArray<string> BundlePaths,
ImmutableArray<string> Gemfiles,
ImmutableArray<string> LockFiles,
ImmutableArray<string> Frameworks);
internal sealed record RubyObservationCapabilitySummary(
bool UsesExec,
bool UsesNetwork,

View File

@@ -17,8 +17,12 @@ internal static class RubyObservationSerializer
{
writer.WriteStartObject();
writer.WriteString("$schema", document.Schema);
WritePackages(writer, document.Packages);
WriteEntrypoints(writer, document.Entrypoints);
WriteDependencyEdges(writer, document.DependencyEdges);
WriteRuntimeEdges(writer, document.RuntimeEdges);
WriteEnvironment(writer, document.Environment);
WriteCapabilities(writer, document.Capabilities);
WriteBundledWith(writer, document.BundledWith);
@@ -72,6 +76,46 @@ internal static class RubyObservationSerializer
writer.WriteEndArray();
}
private static void WriteEntrypoints(Utf8JsonWriter writer, ImmutableArray<RubyObservationEntrypoint> entrypoints)
{
writer.WritePropertyName("entrypoints");
writer.WriteStartArray();
foreach (var entrypoint in entrypoints)
{
writer.WriteStartObject();
writer.WriteString("path", entrypoint.Path);
writer.WriteString("type", entrypoint.Type);
if (entrypoint.RequiredGems.Length > 0)
{
WriteStringArray(writer, "requiredGems", entrypoint.RequiredGems);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteDependencyEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationDependencyEdge> dependencyEdges)
{
writer.WritePropertyName("dependencyEdges");
writer.WriteStartArray();
foreach (var edge in dependencyEdges)
{
writer.WriteStartObject();
writer.WriteString("from", edge.FromPackage);
writer.WriteString("to", edge.ToPackage);
if (!string.IsNullOrWhiteSpace(edge.VersionConstraint))
{
writer.WriteString("constraint", edge.VersionConstraint);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationRuntimeEdge> runtimeEdges)
{
writer.WritePropertyName("runtimeEdges");
@@ -90,6 +134,44 @@ internal static class RubyObservationSerializer
writer.WriteEndArray();
}
private static void WriteEnvironment(Utf8JsonWriter writer, RubyObservationEnvironment environment)
{
writer.WritePropertyName("environment");
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(environment.RubyVersion))
{
writer.WriteString("rubyVersion", environment.RubyVersion);
}
if (!string.IsNullOrWhiteSpace(environment.BundlerVersion))
{
writer.WriteString("bundlerVersion", environment.BundlerVersion);
}
if (environment.BundlePaths.Length > 0)
{
WriteStringArray(writer, "bundlePaths", environment.BundlePaths);
}
if (environment.Gemfiles.Length > 0)
{
WriteStringArray(writer, "gemfiles", environment.Gemfiles);
}
if (environment.LockFiles.Length > 0)
{
WriteStringArray(writer, "lockfiles", environment.LockFiles);
}
if (environment.Frameworks.Length > 0)
{
WriteStringArray(writer, "frameworks", environment.Frameworks);
}
writer.WriteEndObject();
}
private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary)
{
writer.WritePropertyName("capabilities");

View File

@@ -21,6 +21,8 @@ internal static class RubyLockCollector
"coverage"
};
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
private const int MaxDiscoveryDepth = 3;
private static readonly IReadOnlyCollection<string> DefaultGroups = new[] { "default" };
@@ -61,6 +63,7 @@ internal static class RubyLockCollector
spec.Source,
spec.Platform,
groups,
spec.Dependencies,
relativeLockPath));
}
}
@@ -186,6 +189,20 @@ internal static class RubyLockCollector
TryAdd(candidate);
}
// Also discover lock files in container layers
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
{
foreach (var name in LockFileNames)
{
TryAdd(Path.Combine(layerRoot, name));
}
foreach (var candidate in EnumerateLockFiles(layerRoot))
{
TryAdd(candidate);
}
}
return discovered
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -294,4 +311,53 @@ internal static class RubyLockCollector
Path.GetFullPath(manifestDirectory),
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
/// <summary>
/// Enumerates OCI container layer roots for Ruby project discovery.
/// Looks for layers/, .layers/, layer/ directories containing layer subdirectories.
/// </summary>
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
{
foreach (var candidate in LayerRootCandidates)
{
var root = Path.Combine(workspaceRoot, candidate);
if (!Directory.Exists(root))
{
continue;
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(root);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (directories is null)
{
continue;
}
foreach (var layerDirectory in directories)
{
// Check for fs/ subdirectory (extracted layer filesystem)
var fsDirectory = Path.Combine(layerDirectory, "fs");
if (Directory.Exists(fsDirectory))
{
yield return fsDirectory;
}
else
{
yield return layerDirectory;
}
}
}
}
}

View File

@@ -6,4 +6,5 @@ internal sealed record RubyLockEntry(
string Source,
string? Platform,
IReadOnlyCollection<string> Groups,
IReadOnlyList<RubyDependencyEdge> Dependencies,
string LockFileRelativePath);

View File

@@ -15,6 +15,7 @@ internal static class RubyLockParser
}
private static readonly Regex SpecLineRegex = new(@"^\s{4}(?<name>[^\s]+)\s\((?<version>[^)]+)\)", RegexOptions.Compiled);
private static readonly Regex DependencyLineRegex = new(@"^\s{6}(?<name>[^\s]+)(?:\s\((?<constraint>[^)]+)\))?", RegexOptions.Compiled);
public static RubyLockParserResult Parse(string contents)
{
@@ -23,13 +24,14 @@ internal static class RubyLockParser
return new RubyLockParserResult(Array.Empty<RubyLockParserEntry>(), string.Empty);
}
var entries = new List<RubyLockParserEntry>();
var specBuilders = new List<SpecBuilder>();
var section = RubyLockSection.None;
var bundledWith = string.Empty;
var inSpecs = false;
string? currentRemote = null;
string? currentRevision = null;
string? currentPath = null;
SpecBuilder? currentSpec = null;
using var reader = new StringReader(contents);
string? line;
@@ -47,6 +49,7 @@ internal static class RubyLockParser
currentRemote = null;
currentRevision = null;
currentPath = null;
currentSpec = null;
if (section == RubyLockSection.Gem)
{
@@ -76,13 +79,15 @@ internal static class RubyLockParser
ref currentRemote,
ref currentRevision,
ref currentPath,
entries);
ref currentSpec,
specBuilders);
break;
default:
break;
}
}
var entries = specBuilders.Select(static builder => builder.Build()).ToArray();
return new RubyLockParserResult(entries, bundledWith);
}
@@ -93,7 +98,8 @@ internal static class RubyLockParser
ref string? currentRemote,
ref string? currentRevision,
ref string? currentPath,
List<RubyLockParserEntry> entries)
ref SpecBuilder? currentSpec,
List<SpecBuilder> specBuilders)
{
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
{
@@ -130,15 +136,33 @@ internal static class RubyLockParser
return;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
// Check for nested dependency line (6 spaces indent)
if (line.Length > 6 && line.StartsWith(" ") && !char.IsWhiteSpace(line[6]))
{
if (currentSpec is not null)
{
var depMatch = DependencyLineRegex.Match(line);
if (depMatch.Success)
{
var depName = depMatch.Groups["name"].Value.Trim();
var constraint = depMatch.Groups["constraint"].Success
? depMatch.Groups["constraint"].Value.Trim()
: null;
if (!string.IsNullOrEmpty(depName))
{
currentSpec.Dependencies.Add(new RubyDependencyEdge(depName, constraint));
}
}
}
return;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
// Top-level spec line (4 spaces indent)
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
// Nested dependency entry under a spec.
return;
}
@@ -151,7 +175,30 @@ internal static class RubyLockParser
var (version, platform) = ParseVersion(match.Groups["version"].Value);
var source = ResolveSource(section, currentRemote, currentRevision, currentPath);
entries.Add(new RubyLockParserEntry(name, version, source, platform));
currentSpec = new SpecBuilder(name, version, source, platform);
specBuilders.Add(currentSpec);
}
private sealed class SpecBuilder
{
public SpecBuilder(string name, string version, string source, string? platform)
{
Name = name;
Version = version;
Source = source;
Platform = platform;
}
public string Name { get; }
public string Version { get; }
public string Source { get; }
public string? Platform { get; }
public List<RubyDependencyEdge> Dependencies { get; } = new();
public RubyLockParserEntry Build()
{
return new RubyLockParserEntry(Name, Version, Source, Platform, Dependencies.ToArray());
}
}
private static RubyLockSection ParseSection(string value)
@@ -213,6 +260,15 @@ internal static class RubyLockParser
}
}
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);
internal sealed record RubyLockParserEntry(
string Name,
string Version,
string Source,
string? Platform,
IReadOnlyList<RubyDependencyEdge> Dependencies);
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockParserEntry> Entries, string BundledWith);
internal sealed record RubyDependencyEdge(string DependencyName, string? VersionConstraint);
internal sealed record RubyLockParserResult(
IReadOnlyList<RubyLockParserEntry> Entries,
string BundledWith);

View File

@@ -374,6 +374,38 @@ internal sealed class RubyRuntimeGraph
return false;
}
/// <summary>
/// Gets all entrypoint files across all gem usages.
/// </summary>
public IEnumerable<string> GetEntrypointFiles()
{
return _usages.Values
.Where(static usage => usage.HasEntrypoints)
.SelectMany(static usage => usage.Entrypoints)
.Distinct(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the gems required by a specific file.
/// </summary>
public IEnumerable<string> GetRequiredGems(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
yield break;
}
var normalizedPath = filePath.Replace('\\', '/');
foreach (var (gemName, usage) in _usages)
{
if (usage.ReferencingFiles.Any(f => f.Equals(normalizedPath, StringComparison.OrdinalIgnoreCase)))
{
yield return gemName;
}
}
}
private static IEnumerable<string> EnumerateCandidateKeys(string name)
{
if (string.IsNullOrWhiteSpace(name))

View File

@@ -8,6 +8,8 @@ internal static class RubyVendorArtifactCollector
Path.Combine(".bundle", "cache")
};
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
private static readonly string[] DirectoryBlockList =
{
".git",
@@ -65,6 +67,14 @@ internal static class RubyVendorArtifactCollector
TryAdd(Path.Combine(bundlePath, "cache"));
}
// Also check container layers for vendor directories and gems
foreach (var layerRoot in EnumerateLayerRoots(context.RootPath))
{
TryAdd(Path.Combine(layerRoot, "vendor", "cache"));
TryAdd(Path.Combine(layerRoot, "vendor", "bundle"));
TryAdd(Path.Combine(layerRoot, ".bundle", "cache"));
}
var artifacts = new List<RubyVendorArtifact>();
foreach (var root in roots.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))
{
@@ -261,6 +271,55 @@ internal static class RubyVendorArtifactCollector
return path + Path.DirectorySeparatorChar;
}
/// <summary>
/// Enumerates OCI container layer roots for Ruby vendor artifact discovery.
/// Looks for layers/, .layers/, layer/ directories containing layer subdirectories.
/// </summary>
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
{
foreach (var candidate in LayerRootCandidates)
{
var root = Path.Combine(workspaceRoot, candidate);
if (!Directory.Exists(root))
{
continue;
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(root);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
if (directories is null)
{
continue;
}
foreach (var layerDirectory in directories)
{
// Check for fs/ subdirectory (extracted layer filesystem)
var fsDirectory = Path.Combine(layerDirectory, "fs");
if (Directory.Exists(fsDirectory))
{
yield return fsDirectory;
}
else
{
yield return layerDirectory;
}
}
}
}
}
internal sealed record RubyVendorArtifact(

View File

@@ -0,0 +1,307 @@
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Provides the Ruby runtime shim that captures runtime events via TracePoint into NDJSON.
/// This shim is written to disk alongside the analyzer to be invoked by the worker/CLI.
/// </summary>
internal static class RubyRuntimeShim
{
private const string ShimFileName = "trace-shim.rb";
public static string FileName => ShimFileName;
public static async Task<string> WriteAsync(string directory, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, ShimFileName);
await File.WriteAllTextAsync(path, ShimSource, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
return path;
}
// NOTE: This shim is intentionally self-contained, offline, and deterministic.
// Uses Ruby's TracePoint API for runtime introspection with append-only evidence collection.
private const string ShimSource = """
# frozen_string_literal: true
# Ruby runtime trace shim (offline, deterministic)
# Captures require, load, and method call events via TracePoint.
# Emits NDJSON to ruby-runtime.ndjson for evidence collection.
require 'json'
require 'digest/sha2'
require 'time'
module StellaTracer
EVENTS = []
MUTEX = Mutex.new
CWD = Dir.pwd.tr('\\', '/')
ENTRYPOINT_ENV = 'STELLA_RUBY_ENTRYPOINT'
OUTPUT_FILE = 'ruby-runtime.ndjson'
# Patterns for redacting sensitive data
REDACT_PATTERNS = [
/password/i,
/secret/i,
/api[_-]?key/i,
/auth[_-]?token/i,
/bearer/i,
/credential/i,
/private[_-]?key/i
].freeze
# Gems known to have security-relevant capabilities
CAPABILITY_GEMS = {
exec: %w[open3 open4 shellwords pty childprocess posix-spawn].freeze,
net: %w[net/http net/https net/ftp socket httparty faraday rest-client typhoeus patron curb excon httpclient].freeze,
serialize: %w[yaml json marshal oj msgpack ox multi_json yajl].freeze,
scheduler: %w[rufus-scheduler clockwork sidekiq resque delayed_job good_job que karafka sucker_punch shoryuken].freeze,
ffi: %w[ffi fiddle].freeze
}.freeze
class << self
def now_iso
Time.now.utc.iso8601(3)
end
def sha256_hex(value)
Digest::SHA256.hexdigest(value.to_s)
end
def relative_path(path)
candidate = path.to_s.tr('\\', '/')
return candidate if candidate.empty?
# Strip file:// prefix if present
candidate = candidate.sub(%r{^file://}, '')
# Make absolute if relative
unless candidate.start_with?('/') || candidate.match?(/^[A-Za-z]:/)
candidate = File.join(CWD, candidate)
end
# Make relative to CWD
if candidate.start_with?(CWD)
offset = CWD.end_with?('/') ? CWD.length : CWD.length + 1
candidate = candidate[offset..]
end
candidate&.sub(%r{^\./}, '')&.sub(%r{^/+}, '') || '.'
end
def normalize_feature(path)
rel = relative_path(path)
{
normalized: rel,
path_sha256: sha256_hex(rel)
}
end
def redact_value(value)
str = value.to_s
REDACT_PATTERNS.any? { |pat| str.match?(pat) } ? '[REDACTED]' : str
end
def detect_capability(feature_name)
CAPABILITY_GEMS.each do |cap, gems|
return cap if gems.any? { |g| feature_name.include?(g) }
end
nil
end
def add_event(evt)
MUTEX.synchronize { EVENTS << evt }
end
def record_require(feature, path, success)
normalized = normalize_feature(path || feature)
capability = detect_capability(feature)
event = {
type: 'ruby.require',
ts: now_iso,
feature: feature,
module: normalized,
success: success
}
event[:capability] = capability if capability
add_event(event)
end
def record_load(path, wrap)
normalized = normalize_feature(path)
add_event({
type: 'ruby.load',
ts: now_iso,
module: normalized,
wrap: wrap
})
end
def record_method_call(klass, method_id, location)
return if location.nil?
path = relative_path(location.path)
add_event({
type: 'ruby.method.call',
ts: now_iso,
class: redact_value(klass.to_s),
method: method_id.to_s,
location: {
path: path,
line: location.lineno,
path_sha256: sha256_hex(path)
}
})
end
def record_error(message, location = nil)
event = {
type: 'ruby.runtime.error',
ts: now_iso,
message: redact_value(message)
}
if location
event[:location] = {
path: relative_path(location),
path_sha256: sha256_hex(relative_path(location))
}
end
add_event(event)
end
def flush
MUTEX.synchronize do
sorted = EVENTS.sort_by { |e| [e[:ts].to_s, e[:type].to_s] }
File.open(OUTPUT_FILE, 'w') do |f|
sorted.each { |e| f.puts(JSON.generate(e)) }
end
end
rescue => e
warn "stella-tracer: failed to write trace: #{e.message}"
end
def enabled_capabilities
caps = Set.new
$LOADED_FEATURES.each do |feature|
cap = detect_capability(feature)
caps << cap if cap
end
caps.to_a.sort
end
end
end
# Track loaded features at startup
$stella_initial_features = $LOADED_FEATURES.dup
# Hook require
module Kernel
alias_method :stella_original_require, :require
alias_method :stella_original_require_relative, :require_relative
alias_method :stella_original_load, :load
def require(feature)
success = false
result = stella_original_require(feature)
success = result
result
rescue LoadError => e
StellaTracer.record_error("LoadError: #{e.message}", feature)
raise
ensure
path = $LOADED_FEATURES.find { |f| f.include?(feature.to_s.gsub(/\.rb$/, '')) }
StellaTracer.record_require(feature.to_s, path, success)
end
def require_relative(feature)
# Resolve the path relative to the caller
caller_path = caller_locations(1, 1)&.first&.path || __FILE__
dir = File.dirname(caller_path)
absolute = File.expand_path(feature, dir)
require(absolute)
end
def load(path, wrap = false)
result = stella_original_load(path, wrap)
StellaTracer.record_load(path.to_s, wrap)
result
rescue => e
StellaTracer.record_error("LoadError: #{e.message}", path)
raise
end
end
# TracePoint for method calls (optional, configurable)
$stella_method_trace = nil
def stella_enable_method_trace(filter_classes: nil)
$stella_method_trace = TracePoint.new(:call) do |tp|
next if tp.path&.start_with?('<internal')
next if tp.defined_class.to_s.start_with?('StellaTracer')
if filter_classes.nil? || filter_classes.any? { |c| tp.defined_class.to_s.include?(c) }
StellaTracer.record_method_call(tp.defined_class, tp.method_id, tp)
end
end
$stella_method_trace.enable
end
def stella_disable_method_trace
$stella_method_trace&.disable
$stella_method_trace = nil
end
# Ensure flush on exit
at_exit do
# Record final capability snapshot
caps = StellaTracer.enabled_capabilities
StellaTracer.add_event({
type: 'ruby.runtime.end',
ts: StellaTracer.now_iso,
loaded_features_count: $LOADED_FEATURES.length - $stella_initial_features.length,
capabilities: caps
})
StellaTracer.flush
end
# Main execution
entrypoint = ENV[StellaTracer::ENTRYPOINT_ENV]
if entrypoint.nil? || entrypoint.empty?
StellaTracer.record_error('STELLA_RUBY_ENTRYPOINT not set')
exit 1
end
unless File.exist?(entrypoint)
StellaTracer.record_error("Entrypoint not found: #{entrypoint}")
exit 1
end
StellaTracer.add_event({
type: 'ruby.runtime.start',
ts: StellaTracer.now_iso,
module: StellaTracer.normalize_feature(entrypoint),
reason: 'shim-start',
ruby_version: RUBY_VERSION,
ruby_platform: RUBY_PLATFORM
})
# Optionally enable method tracing for specific classes
trace_classes = ENV['STELLA_RUBY_TRACE_CLASSES']&.split(',')&.map(&:strip)
stella_enable_method_trace(filter_classes: trace_classes) if trace_classes && !trace_classes.empty?
begin
load entrypoint
rescue => e
StellaTracer.record_error("#{e.class}: #{e.message}", entrypoint)
raise
end
""";
}

View File

@@ -0,0 +1,268 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Reads and parses Ruby runtime trace NDJSON output.
/// </summary>
internal static class RubyRuntimeTraceReader
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
/// <summary>
/// Reads runtime trace events from an NDJSON file.
/// </summary>
public static async Task<RubyRuntimeTrace> ReadAsync(string path, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return RubyRuntimeTrace.Empty;
}
var events = new List<RubyRuntimeEvent>();
var requires = new List<RubyRequireEvent>();
var loads = new List<RubyLoadEvent>();
var methodCalls = new List<RubyMethodCallEvent>();
var errors = new List<RubyRuntimeErrorEvent>();
string? rubyVersion = null;
string? rubyPlatform = null;
string[]? finalCapabilities = null;
int? loadedFeaturesCount = null;
await foreach (var line in File.ReadLinesAsync(path, cancellationToken).ConfigureAwait(false))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp))
{
continue;
}
var type = typeProp.GetString();
var timestamp = root.TryGetProperty("ts", out var tsProp) ? tsProp.GetString() : null;
switch (type)
{
case "ruby.runtime.start":
rubyVersion = root.TryGetProperty("ruby_version", out var vProp) ? vProp.GetString() : null;
rubyPlatform = root.TryGetProperty("ruby_platform", out var pProp) ? pProp.GetString() : null;
break;
case "ruby.runtime.end":
loadedFeaturesCount = root.TryGetProperty("loaded_features_count", out var fcProp)
? fcProp.GetInt32()
: null;
if (root.TryGetProperty("capabilities", out var capsProp) && capsProp.ValueKind == JsonValueKind.Array)
{
finalCapabilities = capsProp.EnumerateArray()
.Select(e => e.GetString())
.Where(s => s is not null)
.Cast<string>()
.ToArray();
}
break;
case "ruby.require":
var reqFeature = root.TryGetProperty("feature", out var fProp) ? fProp.GetString() : null;
var reqSuccess = root.TryGetProperty("success", out var sProp) && sProp.GetBoolean();
var reqCapability = root.TryGetProperty("capability", out var cProp) ? cProp.GetString() : null;
var reqModule = ParseModuleRef(root);
if (reqFeature is not null)
{
requires.Add(new RubyRequireEvent(
timestamp,
reqFeature,
reqModule?.Normalized,
reqModule?.PathSha256,
reqSuccess,
reqCapability));
}
break;
case "ruby.load":
var loadModule = ParseModuleRef(root);
var wrap = root.TryGetProperty("wrap", out var wProp) && wProp.GetBoolean();
if (loadModule is not null)
{
loads.Add(new RubyLoadEvent(
timestamp,
loadModule.Normalized,
loadModule.PathSha256,
wrap));
}
break;
case "ruby.method.call":
var className = root.TryGetProperty("class", out var clsProp) ? clsProp.GetString() : null;
var methodName = root.TryGetProperty("method", out var mtdProp) ? mtdProp.GetString() : null;
var location = ParseLocation(root);
if (className is not null && methodName is not null)
{
methodCalls.Add(new RubyMethodCallEvent(
timestamp,
className,
methodName,
location?.Path,
location?.Line));
}
break;
case "ruby.runtime.error":
var errorMsg = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null;
var errorLocation = root.TryGetProperty("location", out var locProp) ? ParseLocationDirect(locProp) : null;
if (errorMsg is not null)
{
errors.Add(new RubyRuntimeErrorEvent(timestamp, errorMsg, errorLocation?.Path));
}
break;
}
events.Add(new RubyRuntimeEvent(type ?? "unknown", timestamp));
}
catch (JsonException)
{
// Skip malformed lines
}
}
return new RubyRuntimeTrace(
events.ToArray(),
requires.ToArray(),
loads.ToArray(),
methodCalls.ToArray(),
errors.ToArray(),
rubyVersion,
rubyPlatform,
finalCapabilities ?? [],
loadedFeaturesCount);
}
private static ModuleRef? ParseModuleRef(JsonElement root)
{
if (!root.TryGetProperty("module", out var moduleProp) || moduleProp.ValueKind != JsonValueKind.Object)
{
return null;
}
var normalized = moduleProp.TryGetProperty("normalized", out var nProp) ? nProp.GetString() : null;
var sha256 = moduleProp.TryGetProperty("path_sha256", out var sProp) ? sProp.GetString() : null;
return normalized is not null ? new ModuleRef(normalized, sha256) : null;
}
private static LocationRef? ParseLocation(JsonElement root)
{
if (!root.TryGetProperty("location", out var locProp) || locProp.ValueKind != JsonValueKind.Object)
{
return null;
}
return ParseLocationDirect(locProp);
}
private static LocationRef? ParseLocationDirect(JsonElement locProp)
{
if (locProp.ValueKind != JsonValueKind.Object)
{
return null;
}
var path = locProp.TryGetProperty("path", out var pProp) ? pProp.GetString() : null;
var line = locProp.TryGetProperty("line", out var lProp) ? lProp.GetInt32() : (int?)null;
return path is not null ? new LocationRef(path, line) : null;
}
private sealed record ModuleRef(string Normalized, string? PathSha256);
private sealed record LocationRef(string Path, int? Line);
}
/// <summary>
/// Represents a complete Ruby runtime trace.
/// </summary>
internal sealed record RubyRuntimeTrace(
RubyRuntimeEvent[] Events,
RubyRequireEvent[] Requires,
RubyLoadEvent[] Loads,
RubyMethodCallEvent[] MethodCalls,
RubyRuntimeErrorEvent[] Errors,
string? RubyVersion,
string? RubyPlatform,
string[] Capabilities,
int? LoadedFeaturesCount)
{
public static RubyRuntimeTrace Empty { get; } = new(
[],
[],
[],
[],
[],
null,
null,
[],
null);
public bool IsEmpty => Events.Length == 0;
}
/// <summary>
/// Base runtime event with type and timestamp.
/// </summary>
internal sealed record RubyRuntimeEvent(string Type, string? Timestamp);
/// <summary>
/// A require event capturing a gem/file being loaded.
/// </summary>
internal sealed record RubyRequireEvent(
string? Timestamp,
string Feature,
string? NormalizedPath,
string? PathSha256,
bool Success,
string? Capability);
/// <summary>
/// A load event for explicit file loads.
/// </summary>
internal sealed record RubyLoadEvent(
string? Timestamp,
string NormalizedPath,
string? PathSha256,
bool Wrap);
/// <summary>
/// A method call event from TracePoint.
/// </summary>
internal sealed record RubyMethodCallEvent(
string? Timestamp,
string ClassName,
string MethodName,
string? Path,
int? Line);
/// <summary>
/// A runtime error event.
/// </summary>
internal sealed record RubyRuntimeErrorEvent(
string? Timestamp,
string Message,
string? Path);

View File

@@ -0,0 +1,164 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.Lang;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
/// <summary>
/// Optional harness that executes the emitted Ruby runtime shim when an entrypoint is provided via environment variable.
/// This keeps runtime capture opt-in and offline-friendly.
/// </summary>
internal static class RubyRuntimeTraceRunner
{
private const string EntrypointEnvVar = "STELLA_RUBY_ENTRYPOINT";
private const string BinaryEnvVar = "STELLA_RUBY_BINARY";
private const string TraceClassesEnvVar = "STELLA_RUBY_TRACE_CLASSES";
private const string RuntimeFileName = "ruby-runtime.ndjson";
private const int DefaultTimeoutMs = 60_000; // 1 minute default timeout
public static async Task<bool> TryExecuteAsync(
LanguageAnalyzerContext context,
ILogger? logger,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var entrypoint = Environment.GetEnvironmentVariable(EntrypointEnvVar);
if (string.IsNullOrWhiteSpace(entrypoint))
{
logger?.LogDebug("Ruby runtime trace skipped: {EnvVar} not set", EntrypointEnvVar);
return false;
}
var entrypointPath = Path.GetFullPath(Path.Combine(context.RootPath, entrypoint));
if (!File.Exists(entrypointPath))
{
logger?.LogWarning("Ruby runtime trace skipped: entrypoint '{Entrypoint}' missing", entrypointPath);
return false;
}
var shimPath = Path.Combine(context.RootPath, RubyRuntimeShim.FileName);
if (!File.Exists(shimPath))
{
await RubyRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
}
var binary = Environment.GetEnvironmentVariable(BinaryEnvVar);
if (string.IsNullOrWhiteSpace(binary))
{
binary = "ruby";
}
var startInfo = new ProcessStartInfo
{
FileName = binary,
WorkingDirectory = context.RootPath,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
// Ruby arguments for sandboxed execution
// -W0: Suppress warnings
// -T: Taint mode (restrict dangerous operations) - optional, may not be available in all Ruby versions
startInfo.ArgumentList.Add("-W0");
startInfo.ArgumentList.Add(shimPath);
// Pass through the entrypoint
startInfo.Environment[EntrypointEnvVar] = entrypointPath;
// Pass through trace classes filter if set
var traceClasses = Environment.GetEnvironmentVariable(TraceClassesEnvVar);
if (!string.IsNullOrWhiteSpace(traceClasses))
{
startInfo.Environment[TraceClassesEnvVar] = traceClasses;
}
// Sandbox guidance: Set restrictive environment variables
startInfo.Environment["BUNDLE_DISABLE_EXEC_LOAD"] = "1";
startInfo.Environment["BUNDLE_FROZEN"] = "1";
try
{
using var process = Process.Start(startInfo);
if (process is null)
{
logger?.LogWarning("Ruby runtime trace skipped: failed to start 'ruby' process");
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(DefaultTimeoutMs);
try
{
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timeout - kill the process
logger?.LogWarning("Ruby runtime trace timed out after {Timeout}ms", DefaultTimeoutMs);
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Best effort
}
return false;
}
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
logger?.LogWarning(
"Ruby runtime trace failed with exit code {ExitCode}. stderr: {Error}",
process.ExitCode,
Truncate(stderr));
// Still check for output file - partial traces may be useful
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Ruby runtime trace skipped: {Message}", ex.Message);
return false;
}
var runtimePath = Path.Combine(context.RootPath, RuntimeFileName);
if (!File.Exists(runtimePath))
{
logger?.LogWarning(
"Ruby runtime trace finished but did not emit {RuntimeFile}",
RuntimeFileName);
return false;
}
logger?.LogDebug("Ruby runtime trace completed: {RuntimeFile}", runtimePath);
return true;
}
/// <summary>
/// Gets the path to the expected runtime trace output file.
/// </summary>
public static string GetOutputPath(string rootPath) => Path.Combine(rootPath, RuntimeFileName);
/// <summary>
/// Checks if a runtime trace output exists for the given root path.
/// </summary>
public static bool OutputExists(string rootPath) => File.Exists(GetOutputPath(rootPath));
private static string Truncate(string? value, int maxLength = 400)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value.Length <= maxLength ? value : value[..maxLength];
}
}

View File

@@ -30,6 +30,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false);
var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false);
var bundlerConfig = RubyBundlerConfig.Load(context.RootPath);
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
{
@@ -50,7 +51,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
if (packages.Count > 0)
{
EmitObservation(context, writer, packages, runtimeGraph, capabilities, lockData.BundledWith);
EmitObservation(context, writer, packages, lockData, runtimeGraph, capabilities, bundlerConfig, lockData.BundledWith);
}
}
@@ -86,23 +87,28 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
IReadOnlyList<RubyPackage> packages,
RubyLockData lockData,
RubyRuntimeGraph runtimeGraph,
RubyCapabilities capabilities,
RubyBundlerConfig bundlerConfig,
string? bundledWith)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(packages);
ArgumentNullException.ThrowIfNull(lockData);
ArgumentNullException.ThrowIfNull(runtimeGraph);
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(bundlerConfig);
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities, bundledWith);
var observationDocument = RubyObservationBuilder.Build(packages, lockData, runtimeGraph, capabilities, bundlerConfig, bundledWith);
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
var observationMetadata = BuildObservationMetadata(
packages.Count,
observationDocument.DependencyEdges.Length,
observationDocument.RuntimeEdges.Length,
observationDocument.Capabilities,
observationDocument.BundledWith);
@@ -132,11 +138,13 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
private static IEnumerable<KeyValuePair<string, string?>> BuildObservationMetadata(
int packageCount,
int dependencyEdgeCount,
int runtimeEdgeCount,
RubyObservationCapabilitySummary capabilities,
string? bundledWith)
{
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.dependency_edges", dependencyEdgeCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture));
yield return new KeyValuePair<string, string?>("ruby.observation.capability.exec", capabilities.UsesExec ? "true" : "false");
yield return new KeyValuePair<string, string?>("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false");

View File

@@ -6,3 +6,8 @@
| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. |
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
| `SCANNER-ANALYZERS-RUBY-28-001` | DONE (2025-11-27) | Added OCI container layer support (layers/, .layers/, layer/) to RubyLockCollector and RubyVendorArtifactCollector for VFS/container workspace discovery. Existing implementation already covered Gemfile/lock, vendor/bundle, .gem archives, .bundle/config, Rack configs, and framework fingerprints. |
| `SCANNER-ANALYZERS-RUBY-28-002` | DONE (2025-11-27) | Enhanced RubyLockParser to capture gem dependency edges with version constraints from Gemfile.lock; added RubyDependencyEdge type; updated RubyLockEntry, RubyObservationDocument, observation builder and serializer to produce dependencyEdges with from/to/constraint fields. PURLs and resolver traces now included. |
| `SCANNER-ANALYZERS-RUBY-28-003` | DONE (2025-11-27) | AOC-compliant observations integration: added schema field, RubyObservationEntrypoint and RubyObservationEnvironment types; builder generates entrypoints (path/type/requiredGems) and environment profiles (bundlePaths/gemfiles/lockfiles/frameworks); RubyRuntimeGraph provides GetEntrypointFiles/GetRequiredGems; bundlerConfig wired through analyzer for complete observation coverage. |
| `SCANNER-ANALYZERS-RUBY-28-004` | DONE (2025-11-27) | Fixtures/benchmarks for Ruby analyzer: created cli-app fixture with Thor/TTY-Prompt CLI gems, updated expected.json golden files for simple-app and complex-app with dependency edges format, added CliWorkspaceProducesDeterministicOutputAsync test; all 4 determinism tests pass. |
| `SCANNER-ANALYZERS-RUBY-28-005` | DONE (2025-11-27) | Runtime capture (tracepoint) hooks: created Internal/Runtime/ with RubyRuntimeShim.cs (trace-shim.rb using TracePoint for require/load events, capability detection, sensitive data redaction), RubyRuntimeTraceRunner.cs (opt-in harness via STELLA_RUBY_ENTRYPOINT env var, sandbox guidance), and RubyRuntimeTraceReader.cs (NDJSON parser for trace events). |

View File

@@ -0,0 +1,24 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.ruby",
"displayName": "StellaOps Ruby Analyzer",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Ruby.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Ruby.RubyAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"ruby",
"rubygems",
"bundler"
],
"metadata": {
"org.stellaops.analyzer.language": "ruby",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.runtime-capture": "optional"
}
}

View File

@@ -0,0 +1,10 @@
source "https://rubygems.org"
ruby "3.2.0"
gem "thor", "~> 1.3"
gem "tty-prompt", "~> 0.23"
group :development do
gem "bundler", "~> 2.5"
end

View File

@@ -0,0 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
bundler (2.5.3)
pastel (0.8.0)
tty-color (~> 0.5)
thor (1.3.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
pastel (~> 0.8)
tty-reader (~> 0.8)
tty-reader (0.9.0)
tty-cursor (~> 0.7)
tty-screen (~> 0.8)
wisper (~> 2.0)
tty-screen (0.8.2)
wisper (2.0.1)
PLATFORMS
ruby
DEPENDENCIES
bundler (~> 2.5)
thor (~> 1.3)
tty-prompt (~> 0.23)
BUNDLED WITH
2.5.3

View File

@@ -0,0 +1,226 @@
[
{
"analyzerId": "ruby",
"componentKey": "observation::ruby",
"name": "Ruby Observation Summary",
"type": "ruby-observation",
"usedByEntrypoint": false,
"metadata": {
"ruby.observation.bundler_version": "2.5.3",
"ruby.observation.capability.exec": "false",
"ruby.observation.capability.net": "false",
"ruby.observation.capability.schedulers": "0",
"ruby.observation.capability.serialization": "false",
"ruby.observation.dependency_edges": "6",
"ruby.observation.packages": "9",
"ruby.observation.runtime_edges": "0"
},
"evidence": [
{
"kind": "derived",
"source": "ruby.observation",
"locator": "document",
"value": "{\u0022$schema\u0022:\u0022stellaops.ruby.observation@1\u0022,\u0022packages\u0022:[{\u0022name\u0022:\u0022bundler\u0022,\u0022version\u0022:\u00222.5.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022]},{\u0022name\u0022:\u0022pastel\u0022,\u0022version\u0022:\u00220.8.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022thor\u0022,\u0022version\u0022:\u00221.3.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-color\u0022,\u0022version\u0022:\u00220.6.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-cursor\u0022,\u0022version\u0022:\u00220.7.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-prompt\u0022,\u0022version\u0022:\u00220.23.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-reader\u0022,\u0022version\u0022:\u00220.9.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-screen\u0022,\u0022version\u0022:\u00220.8.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022wisper\u0022,\u0022version\u0022:\u00222.0.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022entrypoints\u0022:[],\u0022dependencyEdges\u0022:[{\u0022from\u0022:\u0022pkg:gem/pastel@0.8.0\u0022,\u0022to\u0022:\u0022tty-color\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.5\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-prompt@0.23.1\u0022,\u0022to\u0022:\u0022pastel\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-prompt@0.23.1\u0022,\u0022to\u0022:\u0022tty-reader\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022tty-cursor\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.7\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022tty-screen\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022wisper\u0022,\u0022constraint\u0022:\u0022~\\u003E 2.0\u0022}],\u0022runtimeEdges\u0022:[],\u0022environment\u0022:{\u0022bundlerVersion\u0022:\u00222.5.3\u0022,\u0022lockfiles\u0022:[\u0022Gemfile.lock\u0022]},\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:false,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]},\u0022bundledWith\u0022:\u00222.5.3\u0022}",
"sha256": "sha256:5ec8b45dc480086cefbee03575845d57fb9fe4a0b000b109af46af5f2fe3f05d"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/bundler@2.5.3",
"purl": "pkg:gem/bundler@2.5.3",
"name": "bundler",
"version": "2.5.3",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "development",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/pastel@0.8.0",
"purl": "pkg:gem/pastel@0.8.0",
"name": "pastel",
"version": "0.8.0",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/thor@1.3.0",
"purl": "pkg:gem/thor@1.3.0",
"name": "thor",
"version": "1.3.0",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/tty-color@0.6.0",
"purl": "pkg:gem/tty-color@0.6.0",
"name": "tty-color",
"version": "0.6.0",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/tty-cursor@0.7.1",
"purl": "pkg:gem/tty-cursor@0.7.1",
"name": "tty-cursor",
"version": "0.7.1",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/tty-prompt@0.23.1",
"purl": "pkg:gem/tty-prompt@0.23.1",
"name": "tty-prompt",
"version": "0.23.1",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/tty-reader@0.9.0",
"purl": "pkg:gem/tty-reader@0.9.0",
"name": "tty-reader",
"version": "0.9.0",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/tty-screen@0.8.2",
"purl": "pkg:gem/tty-screen@0.8.2",
"name": "tty-screen",
"version": "0.8.2",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
},
{
"analyzerId": "ruby",
"componentKey": "purl::pkg:gem/wisper@2.0.1",
"purl": "pkg:gem/wisper@2.0.1",
"name": "wisper",
"version": "2.0.1",
"type": "gem",
"usedByEntrypoint": false,
"metadata": {
"declaredOnly": "true",
"groups": "default",
"lockfile": "Gemfile.lock",
"source": "https://rubygems.org/"
},
"evidence": [
{
"kind": "file",
"source": "Gemfile.lock",
"locator": "Gemfile.lock"
}
]
}
]

View File

@@ -12,6 +12,7 @@
"ruby.observation.capability.scheduler_list": "clockwork;sidekiq",
"ruby.observation.capability.schedulers": "2",
"ruby.observation.capability.serialization": "false",
"ruby.observation.dependency_edges": "4",
"ruby.observation.packages": "6",
"ruby.observation.runtime_edges": "5"
},
@@ -20,8 +21,8 @@
"kind": "derived",
"source": "ruby.observation",
"locator": "document",
"value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022clockwork\u0022,\u0022version\u0022:\u00223.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022ops\u0022]},{\u0022name\u0022:\u0022pagy\u0022,\u0022version\u0022:\u00226.5.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022tools\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.1.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/custom-bundle/cache/sidekiq-7.2.1.gem\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sinatra\u0022,\u0022version\u0022:\u00223.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/sinatra-3.1.0.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022clockwork\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022pagy\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022,\u0022config/environment.rb\u0022],\u0022entrypoints\u0022:[\u0022config/environment.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sinatra\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.5.3\u0022}",
"sha256": "sha256:beaefa12ec1f49e62343781ffa949ec3fa006f0452cf8a342a9a12be3cda1d82"
"value": "{\u0022$schema\u0022:\u0022stellaops.ruby.observation@1\u0022,\u0022packages\u0022:[{\u0022name\u0022:\u0022clockwork\u0022,\u0022version\u0022:\u00223.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022ops\u0022]},{\u0022name\u0022:\u0022pagy\u0022,\u0022version\u0022:\u00226.5.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022tools\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.1.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/custom-bundle/cache/sidekiq-7.2.1.gem\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sinatra\u0022,\u0022version\u0022:\u00223.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/sinatra-3.1.0.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]}],\u0022entrypoints\u0022:[{\u0022path\u0022:\u0022config/environment.rb\u0022,\u0022type\u0022:\u0022script\u0022,\u0022requiredGems\u0022:[\u0022pagy\u0022]}],\u0022dependencyEdges\u0022:[{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022coderay\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.1\u0022},{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022method_source\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sidekiq@7.2.1\u0022,\u0022to\u0022:\u0022rack\u0022,\u0022constraint\u0022:\u0022~\\u003E 2.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sinatra@3.1.0\u0022,\u0022to\u0022:\u0022rack\u0022,\u0022constraint\u0022:\u0022~\\u003E 3.0\u0022}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022clockwork\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022pagy\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022,\u0022config/environment.rb\u0022],\u0022entrypoints\u0022:[\u0022config/environment.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sinatra\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022environment\u0022:{\u0022bundlerVersion\u0022:\u00222.5.3\u0022,\u0022bundlePaths\u0022:[\u0022/mnt/e/dev/git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/bin/Debug/net10.0/Fixtures/lang/ruby/complex-app/vendor/custom-bundle\u0022],\u0022gemfiles\u0022:[\u0022/mnt/e/dev/git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/bin/Debug/net10.0/Fixtures/lang/ruby/complex-app/Gemfile\u0022],\u0022lockfiles\u0022:[\u0022Gemfile.lock\u0022],\u0022frameworks\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.5.3\u0022}",
"sha256": "sha256:58c8c02011baf8711e584a4b8e33effe7292a92af69cd6eaad6c3fd869ea93e0"
}
]
},

View File

@@ -86,4 +86,18 @@ public sealed class RubyLanguageAnalyzerTests
analyzers,
TestContext.Current.CancellationToken);
}
[Fact]
public async Task CliWorkspaceProducesDeterministicOutputAsync()
{
var fixturePath = TestPaths.ResolveFixture("lang", "ruby", "cli-app");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
TestContext.Current.CancellationToken);
}
}

View File

@@ -20,6 +20,8 @@
<ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\..\..\..\tests\shared\OpenSslLegacyShim.cs" />
<Compile Remove="$(MSBuildThisFileDirectory)..\..\..\..\tests\shared\OpenSslAutoInit.cs" />
<Using Remove="StellaOps.Concelier.Testing" />
</ItemGroup>

View File

@@ -49,7 +49,8 @@ public sealed class SurfaceManifestStageExecutorTests
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
new NullRubyPackageInventoryStore());
new NullRubyPackageInventoryStore(),
new DeterminismContext(true, DateTimeOffset.Parse("2024-01-01T00:00:00Z"), 1337, true, 1));
var context = CreateContext();
@@ -86,7 +87,8 @@ public sealed class SurfaceManifestStageExecutorTests
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
new NullRubyPackageInventoryStore());
new NullRubyPackageInventoryStore(),
new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null));
var context = CreateContext();
PopulateAnalysis(context);
@@ -121,6 +123,48 @@ public sealed class SurfaceManifestStageExecutorTests
Assert.Contains(payloadMetrics, m => Equals("layer.fragments", m["surface.kind"]));
}
[Fact]
public async Task ExecuteAsync_EmitsDeterminismPayload()
{
var metrics = new ScannerWorkerMetrics();
var publisher = new TestSurfaceManifestPublisher("tenant-a");
var cache = new RecordingSurfaceCache();
var environment = new TestSurfaceEnvironment("tenant-a");
var hash = CreateCryptoHash();
var determinism = new DeterminismContext(
fixedClock: true,
fixedInstantUtc: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
rngSeed: 42,
filterLogs: true,
concurrencyLimit: 1);
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
new NullRubyPackageInventoryStore(),
determinism);
var context = CreateContext();
context.Lease.Metadata["determinism.feed"] = "feed-001";
context.Lease.Metadata["determinism.policy"] = "rev-77";
await executor.ExecuteAsync(context, CancellationToken.None);
var determinismPayload = publisher.LastRequest!.Payloads.Single(p => p.Kind == "determinism.json");
var json = JsonDocument.Parse(determinismPayload.Content.Span);
Assert.True(json.RootElement.GetProperty("fixedClock").GetBoolean());
Assert.Equal(42, json.RootElement.GetProperty("rngSeed").GetInt32());
Assert.Equal(1, json.RootElement.GetProperty("concurrencyLimit").GetInt32());
Assert.Equal("feed-001", json.RootElement.GetProperty("pins").GetProperty("feed").GetString());
Assert.Equal("rev-77", json.RootElement.GetProperty("pins").GetProperty("policy").GetString());
Assert.True(json.RootElement.GetProperty("artifacts").EnumerateObject().Any());
}
[Fact]
public async Task ExecuteAsync_IncludesEntropyPayloads_WhenPresent()
{
@@ -137,7 +181,8 @@ public sealed class SurfaceManifestStageExecutorTests
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
new NullRubyPackageInventoryStore());
new NullRubyPackageInventoryStore(),
new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null));
var context = CreateContext();
@@ -298,7 +343,8 @@ public sealed class SurfaceManifestStageExecutorTests
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
new NullRubyPackageInventoryStore());
new NullRubyPackageInventoryStore(),
new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null));
var context = CreateContext();
var observationBytes = Encoding.UTF8.GetBytes("{\"entrypoints\":[\"mod.ts\"]}");

View File

@@ -4,7 +4,7 @@
| --- | --- | --- |
| SDKGEN-62-001 | DONE (2025-11-24) | Toolchain pinned: OpenAPI Generator CLI 7.4.0 + JDK 21, determinism rules in TOOLCHAIN.md/toolchain.lock.yaml. |
| SDKGEN-62-002 | DONE (2025-11-24) | Shared post-process now copies auth/retry/pagination/telemetry helpers for TS/Python/Go/Java, wires TS/Python exports, and adds smoke tests. |
| SDKGEN-63-001 | DOING (2025-11-24) | Added TS generator config/script, fixture spec, smoke test (green with vendored JDK/JAR); packaging templates and typed error/helper exports now copied via postprocess. Spec hash guard writes `.oas.sha256` and optionally enforces `STELLA_OAS_EXPECTED_SHA256`; waiting on frozen OpenAPI to publish alpha. |
| SDKGEN-63-002 | DOING (2025-11-24) | Python generator scaffold added (config, script, smoke test, reuse ping fixture) with spec hash guard + `.oas.sha256`; awaiting frozen OpenAPI to emit alpha. |
| SDKGEN-63-001 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate TS alpha; scaffold + smoke + hash guard ready. |
| SDKGEN-63-002 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate Python alpha; scaffold + smoke + hash guard ready. |
| SDKGEN-63-003 | BLOCKED (2025-11-26) | Go generator scaffold ready; blocked on frozen aggregate OAS digest to emit alpha. |
| SDKGEN-63-004 | BLOCKED (2025-11-26) | Java generator scaffold ready; blocked on frozen aggregate OAS digest to emit alpha. |

View File

@@ -57,6 +57,41 @@ export const routes: Routes = [
(m) => m.GraphExplorerComponent
),
},
{
path: 'evidence/:advisoryId',
loadComponent: () =>
import('./features/evidence/evidence-page.component').then(
(m) => m.EvidencePageComponent
),
},
{
path: 'sources',
loadComponent: () =>
import('./features/sources/aoc-dashboard.component').then(
(m) => m.AocDashboardComponent
),
},
{
path: 'sources/violations/:code',
loadComponent: () =>
import('./features/sources/violation-detail.component').then(
(m) => m.ViolationDetailComponent
),
},
{
path: 'releases',
loadComponent: () =>
import('./features/releases/release-flow.component').then(
(m) => m.ReleaseFlowComponent
),
},
{
path: 'releases/:releaseId',
loadComponent: () =>
import('./features/releases/release-flow.component').then(
(m) => m.ReleaseFlowComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>

View File

@@ -0,0 +1,364 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
AocDashboardSummary,
AocPassFailSummary,
AocViolationCode,
IngestThroughput,
AocSource,
AocCheckResult,
VerificationRequest,
ViolationDetail,
TimeSeriesPoint,
} from './aoc.models';
/**
* Injection token for AOC API client.
*/
export const AOC_API = new InjectionToken<AocApi>('AOC_API');
/**
* AOC API interface.
*/
export interface AocApi {
getDashboardSummary(): Observable<AocDashboardSummary>;
getViolationDetail(violationId: string): Observable<ViolationDetail>;
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]>;
startVerification(): Observable<VerificationRequest>;
getVerificationStatus(requestId: string): Observable<VerificationRequest>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
function generateHistory(days: number, baseValue: number, variance: number): TimeSeriesPoint[] {
const points: TimeSeriesPoint[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
points.push({
timestamp: date.toISOString(),
value: baseValue + Math.floor(Math.random() * variance * 2) - variance,
});
}
return points;
}
const mockPassFailSummary: AocPassFailSummary = {
period: 'last_24h',
totalChecks: 1247,
passed: 1198,
failed: 32,
pending: 12,
skipped: 5,
passRate: 0.961,
trend: 'improving',
history: generateHistory(7, 96, 3),
};
const mockViolationCodes: AocViolationCode[] = [
{
code: 'AOC-001',
name: 'Missing Provenance',
severity: 'critical',
description: 'Document lacks required provenance attestation',
count: 12,
lastSeen: '2025-11-27T09:45:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-001',
},
{
code: 'AOC-002',
name: 'Invalid Signature',
severity: 'critical',
description: 'Document signature verification failed',
count: 8,
lastSeen: '2025-11-27T08:30:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-002',
},
{
code: 'AOC-010',
name: 'Schema Mismatch',
severity: 'high',
description: 'Document does not conform to expected schema version',
count: 5,
lastSeen: '2025-11-27T07:15:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-010',
},
{
code: 'AOC-015',
name: 'Timestamp Drift',
severity: 'medium',
description: 'Document timestamp exceeds allowed drift threshold',
count: 4,
lastSeen: '2025-11-27T06:00:00Z',
},
{
code: 'AOC-020',
name: 'Metadata Incomplete',
severity: 'low',
description: 'Optional metadata fields are missing',
count: 3,
lastSeen: '2025-11-26T22:30:00Z',
},
];
const mockThroughput: IngestThroughput[] = [
{
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
documentsIngested: 15420,
bytesIngested: 2_450_000_000,
documentsPerMinute: 10.7,
bytesPerMinute: 1_701_388,
period: 'last_24h',
},
{
tenantId: 'tenant-002',
tenantName: 'TechStart Inc',
documentsIngested: 8932,
bytesIngested: 1_120_000_000,
documentsPerMinute: 6.2,
bytesPerMinute: 777_777,
period: 'last_24h',
},
{
tenantId: 'tenant-003',
tenantName: 'DataFlow Ltd',
documentsIngested: 5678,
bytesIngested: 890_000_000,
documentsPerMinute: 3.9,
bytesPerMinute: 618_055,
period: 'last_24h',
},
{
tenantId: 'tenant-004',
tenantName: 'SecureOps',
documentsIngested: 3421,
bytesIngested: 456_000_000,
documentsPerMinute: 2.4,
bytesPerMinute: 316_666,
period: 'last_24h',
},
];
const mockSources: AocSource[] = [
{
sourceId: 'src-001',
name: 'Production Registry',
type: 'registry',
status: 'passed',
lastCheck: '2025-11-27T10:00:00Z',
checkCount: 523,
passRate: 0.98,
recentViolations: [],
},
{
sourceId: 'src-002',
name: 'GitHub Actions Pipeline',
type: 'pipeline',
status: 'failed',
lastCheck: '2025-11-27T09:45:00Z',
checkCount: 412,
passRate: 0.92,
recentViolations: [mockViolationCodes[0], mockViolationCodes[1]],
},
{
sourceId: 'src-003',
name: 'Staging Registry',
type: 'registry',
status: 'passed',
lastCheck: '2025-11-27T09:30:00Z',
checkCount: 201,
passRate: 0.995,
recentViolations: [],
},
{
sourceId: 'src-004',
name: 'Manual Upload',
type: 'manual',
status: 'pending',
lastCheck: '2025-11-27T08:00:00Z',
checkCount: 111,
passRate: 0.85,
recentViolations: [mockViolationCodes[2]],
},
];
const mockRecentChecks: AocCheckResult[] = [
{
checkId: 'chk-001',
documentId: 'doc-abc123',
documentType: 'sbom',
status: 'passed',
checkedAt: '2025-11-27T10:00:00Z',
violations: [],
sourceId: 'src-001',
tenantId: 'tenant-001',
},
{
checkId: 'chk-002',
documentId: 'doc-def456',
documentType: 'attestation',
status: 'failed',
checkedAt: '2025-11-27T09:55:00Z',
violations: [mockViolationCodes[0]],
sourceId: 'src-002',
tenantId: 'tenant-001',
},
{
checkId: 'chk-003',
documentId: 'doc-ghi789',
documentType: 'sbom',
status: 'passed',
checkedAt: '2025-11-27T09:50:00Z',
violations: [],
sourceId: 'src-001',
tenantId: 'tenant-002',
},
{
checkId: 'chk-004',
documentId: 'doc-jkl012',
documentType: 'provenance',
status: 'failed',
checkedAt: '2025-11-27T09:45:00Z',
violations: [mockViolationCodes[1]],
sourceId: 'src-002',
tenantId: 'tenant-001',
},
{
checkId: 'chk-005',
documentId: 'doc-mno345',
documentType: 'sbom',
status: 'pending',
checkedAt: '2025-11-27T09:40:00Z',
violations: [],
sourceId: 'src-004',
tenantId: 'tenant-003',
},
];
const mockDashboard: AocDashboardSummary = {
generatedAt: new Date().toISOString(),
passFail: mockPassFailSummary,
recentViolations: mockViolationCodes,
throughputByTenant: mockThroughput,
sources: mockSources,
recentChecks: mockRecentChecks,
};
const mockViolationDetails: ViolationDetail[] = [
{
violationId: 'viol-001',
code: 'AOC-001',
severity: 'critical',
documentId: 'doc-def456',
documentType: 'attestation',
offendingFields: [
{
path: '$.predicate.buildType',
expectedValue: 'https://slsa.dev/provenance/v1',
actualValue: undefined,
reason: 'Required field is missing',
},
{
path: '$.predicate.builder.id',
expectedValue: 'https://github.com/actions/runner',
actualValue: undefined,
reason: 'Builder ID not specified',
},
],
provenance: {
sourceType: 'pipeline',
sourceUri: 'github.com/acme/api-service',
ingestedAt: '2025-11-27T09:55:00Z',
ingestedBy: 'github-actions',
buildId: 'build-12345',
commitSha: 'a1b2c3d4e5f6',
pipelineUrl: 'https://github.com/acme/api-service/actions/runs/12345',
},
detectedAt: '2025-11-27T09:55:00Z',
suggestion: 'Add SLSA provenance attestation to your build pipeline. See https://slsa.dev/spec/v1.0/provenance',
},
{
violationId: 'viol-002',
code: 'AOC-002',
severity: 'critical',
documentId: 'doc-jkl012',
documentType: 'provenance',
offendingFields: [
{
path: '$.signatures[0]',
expectedValue: 'Valid DSSE signature',
actualValue: 'Invalid or expired signature',
reason: 'Signature verification failed: key not found in keyring',
},
],
provenance: {
sourceType: 'pipeline',
sourceUri: 'github.com/acme/worker-service',
ingestedAt: '2025-11-27T09:45:00Z',
ingestedBy: 'github-actions',
buildId: 'build-12346',
commitSha: 'b2c3d4e5f6a7',
pipelineUrl: 'https://github.com/acme/worker-service/actions/runs/12346',
},
detectedAt: '2025-11-27T09:45:00Z',
suggestion: 'Ensure the signing key is registered in your tenant keyring. Run: stella keys add --public-key <key-file>',
},
];
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockAocApi implements AocApi {
getDashboardSummary(): Observable<AocDashboardSummary> {
return of({
...mockDashboard,
generatedAt: new Date().toISOString(),
}).pipe(delay(300));
}
getViolationDetail(violationId: string): Observable<ViolationDetail> {
const detail = mockViolationDetails.find((v) => v.violationId === violationId);
if (!detail) {
throw new Error(`Violation not found: ${violationId}`);
}
return of(detail).pipe(delay(200));
}
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]> {
const details = mockViolationDetails.filter((v) => v.code === code);
return of(details).pipe(delay(250));
}
startVerification(): Observable<VerificationRequest> {
return of({
requestId: `verify-${Date.now()}`,
status: 'queued',
documentsToVerify: 1247,
documentsVerified: 0,
passed: 0,
failed: 0,
cliCommand: 'stella aoc verify --since 24h --output json',
}).pipe(delay(400));
}
getVerificationStatus(requestId: string): Observable<VerificationRequest> {
// Simulate a completed verification
return of({
requestId,
status: 'completed',
startedAt: new Date(Date.now() - 120000).toISOString(),
completedAt: new Date().toISOString(),
documentsToVerify: 1247,
documentsVerified: 1247,
passed: 1198,
failed: 49,
cliCommand: 'stella aoc verify --since 24h --output json',
}).pipe(delay(300));
}
}

View File

@@ -0,0 +1,152 @@
/**
* Attestation of Conformance (AOC) models for UI-AOC-19-001.
* Supports Sources dashboard tiles showing pass/fail, violation codes, and ingest throughput.
*/
// AOC verification status
export type AocVerificationStatus = 'passed' | 'failed' | 'pending' | 'skipped';
// Violation severity levels
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
/**
* AOC violation code with metadata.
*/
export interface AocViolationCode {
readonly code: string;
readonly name: string;
readonly severity: ViolationSeverity;
readonly description: string;
readonly count: number;
readonly lastSeen: string;
readonly documentationUrl?: string;
}
/**
* Per-tenant ingest throughput metrics.
*/
export interface IngestThroughput {
readonly tenantId: string;
readonly tenantName: string;
readonly documentsIngested: number;
readonly bytesIngested: number;
readonly documentsPerMinute: number;
readonly bytesPerMinute: number;
readonly period: string; // e.g., "last_24h", "last_7d"
}
/**
* Time-series data point for charts.
*/
export interface TimeSeriesPoint {
readonly timestamp: string;
readonly value: number;
}
/**
* AOC pass/fail summary for a time period.
*/
export interface AocPassFailSummary {
readonly period: string;
readonly totalChecks: number;
readonly passed: number;
readonly failed: number;
readonly pending: number;
readonly skipped: number;
readonly passRate: number; // 0-1
readonly trend: 'improving' | 'stable' | 'degrading';
readonly history: readonly TimeSeriesPoint[];
}
/**
* Individual AOC check result.
*/
export interface AocCheckResult {
readonly checkId: string;
readonly documentId: string;
readonly documentType: string;
readonly status: AocVerificationStatus;
readonly checkedAt: string;
readonly violations: readonly AocViolationCode[];
readonly sourceId?: string;
readonly tenantId: string;
}
/**
* Source with AOC metrics.
*/
export interface AocSource {
readonly sourceId: string;
readonly name: string;
readonly type: 'registry' | 'repository' | 'pipeline' | 'manual';
readonly status: AocVerificationStatus;
readonly lastCheck: string;
readonly checkCount: number;
readonly passRate: number;
readonly recentViolations: readonly AocViolationCode[];
}
/**
* AOC dashboard summary combining all metrics.
*/
export interface AocDashboardSummary {
readonly generatedAt: string;
readonly passFail: AocPassFailSummary;
readonly recentViolations: readonly AocViolationCode[];
readonly throughputByTenant: readonly IngestThroughput[];
readonly sources: readonly AocSource[];
readonly recentChecks: readonly AocCheckResult[];
}
/**
* Verification request for "Verify last 24h" action.
*/
export interface VerificationRequest {
readonly requestId: string;
readonly status: 'queued' | 'running' | 'completed' | 'failed';
readonly startedAt?: string;
readonly completedAt?: string;
readonly documentsToVerify: number;
readonly documentsVerified: number;
readonly passed: number;
readonly failed: number;
readonly cliCommand?: string; // CLI parity command
}
/**
* Violation detail for drill-down view.
*/
export interface ViolationDetail {
readonly violationId: string;
readonly code: string;
readonly severity: ViolationSeverity;
readonly documentId: string;
readonly documentType: string;
readonly offendingFields: readonly OffendingField[];
readonly provenance: ProvenanceMetadata;
readonly detectedAt: string;
readonly suggestion?: string;
}
/**
* Offending field in a document.
*/
export interface OffendingField {
readonly path: string; // JSON path, e.g., "$.metadata.labels"
readonly expectedValue?: string;
readonly actualValue?: string;
readonly reason: string;
}
/**
* Provenance metadata for a document.
*/
export interface ProvenanceMetadata {
readonly sourceType: string;
readonly sourceUri: string;
readonly ingestedAt: string;
readonly ingestedBy: string;
readonly buildId?: string;
readonly commitSha?: string;
readonly pipelineUrl?: string;
}

View File

@@ -0,0 +1,323 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
EvidenceData,
Linkset,
Observation,
PolicyEvidence,
} from './evidence.models';
export interface EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
getObservation(observationId: string): Observable<Observation>;
getLinkset(linksetId: string): Observable<Linkset>;
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
}
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
// Mock data for development
const MOCK_OBSERVATIONS: Observation[] = [
{
observationId: 'obs-ghsa-001',
tenantId: 'tenant-1',
source: 'ghsa',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Log4j Remote Code Execution (Log4Shell)',
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
'https://logging.apache.org/log4j/2.x/security.html',
],
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
published: '2021-12-10T00:00:00Z',
modified: '2024-01-15T10:30:00Z',
provenance: {
sourceArtifactSha: 'sha256:abc123def456...',
fetchedAt: '2024-11-20T08:00:00Z',
ingestJobId: 'job-ghsa-2024-1120',
},
ingestedAt: '2024-11-20T08:05:00Z',
},
{
observationId: 'obs-nvd-001',
tenantId: 'tenant-1',
source: 'nvd',
advisoryId: 'CVE-2021-44228',
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
],
relationships: [
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
],
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
published: '2021-12-10T10:15:00Z',
modified: '2024-02-20T15:45:00Z',
provenance: {
sourceArtifactSha: 'sha256:def789ghi012...',
fetchedAt: '2024-11-20T08:10:00Z',
ingestJobId: 'job-nvd-2024-1120',
},
ingestedAt: '2024-11-20T08:15:00Z',
},
{
observationId: 'obs-osv-001',
tenantId: 'tenant-1',
source: 'osv',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Remote code injection in Log4j',
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'Maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.3.1' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.4' },
{ fixed: '2.12.2' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.13.0' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
],
published: '2021-12-10T00:00:00Z',
modified: '2023-06-15T09:00:00Z',
provenance: {
sourceArtifactSha: 'sha256:ghi345jkl678...',
fetchedAt: '2024-11-20T08:20:00Z',
ingestJobId: 'job-osv-2024-1120',
},
ingestedAt: '2024-11-20T08:25:00Z',
},
];
const MOCK_LINKSET: Linkset = {
linksetId: 'ls-log4shell-001',
tenantId: 'tenant-1',
advisoryId: 'CVE-2021-44228',
source: 'aggregated',
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
normalized: {
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
},
confidence: 0.95,
conflicts: [
{
field: 'affected.ranges',
reason: 'Different fixed version ranges reported by sources',
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
sourceIds: ['ghsa', 'osv'],
},
{
field: 'weaknesses',
reason: 'Different CWE identifiers reported',
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
sourceIds: ['ghsa', 'nvd'],
},
],
createdAt: '2024-11-20T08:30:00Z',
builtByJobId: 'linkset-build-2024-1120',
provenance: {
observationHashes: [
'sha256:abc123...',
'sha256:def789...',
'sha256:ghi345...',
],
toolVersion: 'concelier-lnm-1.2.0',
policyHash: 'sha256:policy-hash-001',
},
};
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
policyId: 'pol-critical-vuln-001',
policyName: 'Critical Vulnerability Policy',
decision: 'block',
decidedAt: '2024-11-20T08:35:00Z',
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
rules: [
{
ruleId: 'rule-cvss-critical',
ruleName: 'Block Critical CVSS',
passed: false,
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
matchedItems: ['CVE-2021-44228'],
},
{
ruleId: 'rule-known-exploit',
ruleName: 'Known Exploit Check',
passed: false,
reason: 'Active exploitation reported by CISA',
matchedItems: ['KEV-2021-44228'],
},
{
ruleId: 'rule-fix-available',
ruleName: 'Fix Available',
passed: true,
reason: 'Fixed version 2.15.0+ available',
},
],
linksetIds: ['ls-log4shell-001'],
aocChain: [
{
attestationId: 'aoc-obs-ghsa-001',
type: 'observation',
hash: 'sha256:abc123def456...',
timestamp: '2024-11-20T08:05:00Z',
parentHash: undefined,
},
{
attestationId: 'aoc-obs-nvd-001',
type: 'observation',
hash: 'sha256:def789ghi012...',
timestamp: '2024-11-20T08:15:00Z',
parentHash: 'sha256:abc123def456...',
},
{
attestationId: 'aoc-obs-osv-001',
type: 'observation',
hash: 'sha256:ghi345jkl678...',
timestamp: '2024-11-20T08:25:00Z',
parentHash: 'sha256:def789ghi012...',
},
{
attestationId: 'aoc-ls-001',
type: 'linkset',
hash: 'sha256:linkset-hash-001...',
timestamp: '2024-11-20T08:30:00Z',
parentHash: 'sha256:ghi345jkl678...',
},
{
attestationId: 'aoc-policy-001',
type: 'policy',
hash: 'sha256:policy-decision-hash...',
timestamp: '2024-11-20T08:35:00Z',
signer: 'policy-engine-v1',
parentHash: 'sha256:linkset-hash-001...',
},
],
};
@Injectable({ providedIn: 'root' })
export class MockEvidenceApiService implements EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
// Filter observations related to the advisory
const observations = MOCK_OBSERVATIONS.filter(
(o) =>
o.advisoryId === advisoryId ||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
);
const linkset = MOCK_LINKSET;
const policyEvidence = MOCK_POLICY_EVIDENCE;
const data: EvidenceData = {
advisoryId,
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
observations,
linkset,
policyEvidence,
hasConflicts: linkset.conflicts.length > 0,
conflictCount: linkset.conflicts.length,
};
return of(data).pipe(delay(300));
}
getObservation(observationId: string): Observable<Observation> {
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
if (!observation) {
throw new Error(`Observation not found: ${observationId}`);
}
return of(observation).pipe(delay(100));
}
getLinkset(linksetId: string): Observable<Linkset> {
if (linksetId === MOCK_LINKSET.linksetId) {
return of(MOCK_LINKSET).pipe(delay(100));
}
throw new Error(`Linkset not found: ${linksetId}`);
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
}
return of(null).pipe(delay(100));
}
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
let data: object;
if (type === 'observation') {
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
} else {
data = MOCK_LINKSET;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
return of(blob).pipe(delay(100));
}
}

View File

@@ -0,0 +1,189 @@
/**
* Link-Not-Merge Evidence Models
* Based on docs/modules/concelier/link-not-merge-schema.md
*/
// Severity from advisory sources
export interface AdvisorySeverity {
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
readonly score: number;
readonly vector?: string;
}
// Affected package information
export interface AffectedPackage {
readonly purl: string;
readonly package?: string;
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly ecosystem?: string;
readonly cpe?: readonly string[];
}
export interface VersionRange {
readonly type: string;
readonly events: readonly VersionEvent[];
}
export interface VersionEvent {
readonly introduced?: string;
readonly fixed?: string;
readonly last_affected?: string;
}
// Relationship between advisories
export interface AdvisoryRelationship {
readonly type: string;
readonly source: string;
readonly target: string;
readonly provenance?: string;
}
// Provenance tracking
export interface ObservationProvenance {
readonly sourceArtifactSha: string;
readonly fetchedAt: string;
readonly ingestJobId?: string;
readonly signature?: Record<string, unknown>;
}
// Raw observation from a single source
export interface Observation {
readonly observationId: string;
readonly tenantId: string;
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
readonly advisoryId: string;
readonly title?: string;
readonly summary?: string;
readonly severities: readonly AdvisorySeverity[];
readonly affected: readonly AffectedPackage[];
readonly references?: readonly string[];
readonly scopes?: readonly string[];
readonly relationships?: readonly AdvisoryRelationship[];
readonly weaknesses?: readonly string[];
readonly published?: string;
readonly modified?: string;
readonly provenance: ObservationProvenance;
readonly ingestedAt: string;
}
// Conflict when sources disagree
export interface LinksetConflict {
readonly field: string;
readonly reason: string;
readonly values?: readonly string[];
readonly sourceIds?: readonly string[];
}
// Linkset provenance
export interface LinksetProvenance {
readonly observationHashes: readonly string[];
readonly toolVersion?: string;
readonly policyHash?: string;
}
// Normalized linkset aggregating multiple observations
export interface Linkset {
readonly linksetId: string;
readonly tenantId: string;
readonly advisoryId: string;
readonly source: string;
readonly observations: readonly string[]; // observation IDs
readonly normalized?: {
readonly purls?: readonly string[];
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly severities?: readonly AdvisorySeverity[];
};
readonly confidence?: number; // 0-1
readonly conflicts: readonly LinksetConflict[];
readonly createdAt: string;
readonly builtByJobId?: string;
readonly provenance?: LinksetProvenance;
}
// Policy decision result
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
// Policy decision with evidence
export interface PolicyEvidence {
readonly policyId: string;
readonly policyName: string;
readonly decision: PolicyDecision;
readonly decidedAt: string;
readonly reason?: string;
readonly rules: readonly PolicyRuleResult[];
readonly linksetIds: readonly string[];
readonly aocChain?: AocChainEntry[];
}
export interface PolicyRuleResult {
readonly ruleId: string;
readonly ruleName: string;
readonly passed: boolean;
readonly reason?: string;
readonly matchedItems?: readonly string[];
}
// AOC (Attestation of Compliance) chain entry
export interface AocChainEntry {
readonly attestationId: string;
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
readonly hash: string;
readonly timestamp: string;
readonly signer?: string;
readonly parentHash?: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
readonly title?: string;
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
// Source metadata for display
export interface SourceInfo {
readonly sourceId: string;
readonly name: string;
readonly icon?: string;
readonly url?: string;
readonly lastUpdated?: string;
}
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',
name: 'GitHub Security Advisories',
icon: 'github',
url: 'https://github.com/advisories',
},
nvd: {
sourceId: 'nvd',
name: 'National Vulnerability Database',
icon: 'database',
url: 'https://nvd.nist.gov',
},
'cert-bund': {
sourceId: 'cert-bund',
name: 'CERT-Bund',
icon: 'shield',
url: 'https://www.cert-bund.de',
},
osv: {
sourceId: 'osv',
name: 'Open Source Vulnerabilities',
icon: 'box',
url: 'https://osv.dev',
},
cve: {
sourceId: 'cve',
name: 'CVE Program',
icon: 'alert-triangle',
url: 'https://cve.mitre.org',
},
};

View File

@@ -0,0 +1,373 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Release,
ReleaseArtifact,
PolicyEvaluation,
PolicyGateResult,
DeterminismGateDetails,
RemediationHint,
DeterminismFeatureFlags,
PolicyGateStatus,
} from './release.models';
/**
* Injection token for Release API client.
*/
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
/**
* Release API interface.
*/
export interface ReleaseApi {
getRelease(releaseId: string): Observable<Release>;
listReleases(): Observable<readonly Release[]>;
publishRelease(releaseId: string): Observable<Release>;
cancelRelease(releaseId: string): Observable<Release>;
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const determinismPassingGate: PolicyGateResult = {
gateId: 'gate-det-001',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'passed',
message: 'Merkle root consistent. All fragment attestations verified.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-abc123?tab=determinism',
details: {
merkleRoot: 'sha256:a1b2c3d4e5f6...',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const determinismFailingGate: PolicyGateResult = {
gateId: 'gate-det-002',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'failed',
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-def456?tab=determinism',
details: {
merkleRoot: 'sha256:f1e2d3c4b5a6...',
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: [
'sha256:layer3digest...',
'sha256:layer5digest...',
],
},
},
remediation: {
gateType: 'determinism',
severity: 'critical',
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
steps: [
{
action: 'rebuild',
title: 'Rebuild with deterministic toolchain',
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
command: 'stella scan --deterministic --sign --push',
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
automated: false,
},
{
action: 'provide-provenance',
title: 'Provide provenance attestation',
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
documentationUrl: 'https://docs.stellaops.io/provenance',
automated: false,
},
{
action: 'sign-artifact',
title: 'Re-sign with valid key',
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
command: 'stella sign --artifact sha256:...',
automated: true,
},
{
action: 'request-exception',
title: 'Request policy exception',
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
automated: true,
},
],
estimatedEffort: '15-30 minutes',
exceptionAllowed: true,
},
};
const vulnerabilityPassingGate: PolicyGateResult = {
gateId: 'gate-vuln-001',
gateType: 'vulnerability',
name: 'Vulnerability Scan',
status: 'passed',
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const entropyWarningGate: PolicyGateResult = {
gateId: 'gate-ent-001',
gateType: 'entropy',
name: 'Entropy Analysis',
status: 'warning',
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
remediation: {
gateType: 'entropy',
severity: 'medium',
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
steps: [
{
action: 'provide-provenance',
title: 'Provide source provenance',
description: 'Attach build provenance or source mappings for high-entropy binaries.',
automated: false,
},
],
estimatedEffort: '10 minutes',
exceptionAllowed: true,
},
};
const licensePassingGate: PolicyGateResult = {
gateId: 'gate-lic-001',
gateType: 'license',
name: 'License Compliance',
status: 'passed',
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const signaturePassingGate: PolicyGateResult = {
gateId: 'gate-sig-001',
gateType: 'signature',
name: 'Signature Verification',
status: 'passed',
message: 'Image signature verified against tenant keyring.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
};
const signatureFailingGate: PolicyGateResult = {
gateId: 'gate-sig-002',
gateType: 'signature',
name: 'Signature Verification',
status: 'failed',
message: 'No valid signature found. Image must be signed before release.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
remediation: {
gateType: 'signature',
severity: 'critical',
summary: 'The image is not signed or the signature cannot be verified.',
steps: [
{
action: 'sign-artifact',
title: 'Sign the image',
description: 'Sign the image using your tenant signing key.',
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
automated: true,
},
],
estimatedEffort: '2 minutes',
exceptionAllowed: false,
},
};
// Artifacts with policy evaluations
const passingArtifact: ReleaseArtifact = {
artifactId: 'art-001',
name: 'api-service',
tag: 'v1.2.3',
digest: 'sha256:abc123def456789012345678901234567890abcdef',
size: 245_000_000,
createdAt: '2025-11-27T08:00:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-001',
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
evaluatedAt: '2025-11-27T10:15:00Z',
overallStatus: 'passed',
gates: [
determinismPassingGate,
vulnerabilityPassingGate,
entropyWarningGate,
licensePassingGate,
signaturePassingGate,
],
blockingGates: [],
canPublish: true,
determinismDetails: {
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
merkleRootConsistent: true,
contentHash: 'sha256:content1234567890abcdef',
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const failingArtifact: ReleaseArtifact = {
artifactId: 'art-002',
name: 'worker-service',
tag: 'v1.2.3',
digest: 'sha256:def456abc789012345678901234567890fedcba98',
size: 312_000_000,
createdAt: '2025-11-27T07:45:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-002',
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
evaluatedAt: '2025-11-27T09:30:00Z',
overallStatus: 'failed',
gates: [
determinismFailingGate,
vulnerabilityPassingGate,
licensePassingGate,
signatureFailingGate,
],
blockingGates: ['gate-det-002', 'gate-sig-002'],
canPublish: false,
determinismDetails: {
merkleRoot: 'sha256:f1e2d3c4b5a67890',
merkleRootConsistent: false,
contentHash: 'sha256:content9876543210',
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
},
},
};
// Release fixtures
const passingRelease: Release = {
releaseId: 'rel-001',
name: 'Platform v1.2.3',
version: '1.2.3',
status: 'pending_approval',
createdAt: '2025-11-27T08:30:00Z',
createdBy: 'deploy-bot',
artifacts: [passingArtifact],
targetEnvironment: 'production',
notes: 'Feature release with API improvements and bug fixes.',
approvals: [
{
approvalId: 'apr-001',
approver: 'security-team',
decision: 'approved',
comment: 'Security review passed.',
decidedAt: '2025-11-27T09:00:00Z',
},
{
approvalId: 'apr-002',
approver: 'release-manager',
decision: 'pending',
},
],
};
const blockedRelease: Release = {
releaseId: 'rel-002',
name: 'Platform v1.2.4-rc1',
version: '1.2.4-rc1',
status: 'blocked',
createdAt: '2025-11-27T07:00:00Z',
createdBy: 'deploy-bot',
artifacts: [failingArtifact],
targetEnvironment: 'staging',
notes: 'Release candidate blocked due to policy gate failures.',
};
const mixedRelease: Release = {
releaseId: 'rel-003',
name: 'Platform v1.2.5',
version: '1.2.5',
status: 'blocked',
createdAt: '2025-11-27T06:00:00Z',
createdBy: 'ci-pipeline',
artifacts: [passingArtifact, failingArtifact],
targetEnvironment: 'production',
notes: 'Multi-artifact release with mixed policy results.',
};
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
const mockFeatureFlags: DeterminismFeatureFlags = {
enabled: true,
blockOnFailure: true,
warnOnly: false,
bypassRoles: ['security-admin', 'release-manager'],
requireApprovalForBypass: true,
};
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReleaseApi implements ReleaseApi {
getRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of(release).pipe(delay(200));
}
listReleases(): Observable<readonly Release[]> {
return of(mockReleases).pipe(delay(300));
}
publishRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
// Simulate publish (would update status in real implementation)
return of({
...release,
status: 'published',
publishedAt: new Date().toISOString(),
} as Release).pipe(delay(500));
}
cancelRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of({
...release,
status: 'cancelled',
} as Release).pipe(delay(300));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return of(mockFeatureFlags).pipe(delay(100));
}
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
}
}

View File

@@ -0,0 +1,161 @@
/**
* Release and Policy Gate models for UI-POLICY-DET-01.
* Supports determinism-gated release flows with remediation hints.
*/
// Policy gate evaluation status
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
// Types of policy gates
export type PolicyGateType =
| 'determinism'
| 'vulnerability'
| 'license'
| 'entropy'
| 'signature'
| 'sbom-completeness'
| 'custom';
// Remediation action types
export type RemediationActionType =
| 'rebuild'
| 'provide-provenance'
| 'sign-artifact'
| 'update-dependency'
| 'request-exception'
| 'manual-review';
/**
* A single remediation step with optional automation support.
*/
export interface RemediationStep {
readonly action: RemediationActionType;
readonly title: string;
readonly description: string;
readonly command?: string; // Optional CLI command to run
readonly documentationUrl?: string;
readonly automated: boolean; // Can be triggered from UI
}
/**
* Remediation hints for a failed policy gate.
*/
export interface RemediationHint {
readonly gateType: PolicyGateType;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly summary: string;
readonly steps: readonly RemediationStep[];
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
readonly exceptionAllowed: boolean;
}
/**
* Individual policy gate evaluation result.
*/
export interface PolicyGateResult {
readonly gateId: string;
readonly gateType: PolicyGateType;
readonly name: string;
readonly status: PolicyGateStatus;
readonly message: string;
readonly evaluatedAt: string;
readonly blockingPublish: boolean;
readonly evidence?: {
readonly type: string;
readonly url?: string;
readonly details?: Record<string, unknown>;
};
readonly remediation?: RemediationHint;
}
/**
* Determinism-specific gate details.
*/
export interface DeterminismGateDetails {
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly contentHash?: string;
readonly compositionManifestUri?: string;
readonly fragmentCount?: number;
readonly verifiedFragments?: number;
readonly failedFragments?: readonly string[]; // Layer digests that failed
}
/**
* Overall policy evaluation for a release artifact.
*/
export interface PolicyEvaluation {
readonly evaluationId: string;
readonly artifactDigest: string;
readonly evaluatedAt: string;
readonly overallStatus: PolicyGateStatus;
readonly gates: readonly PolicyGateResult[];
readonly blockingGates: readonly string[]; // Gate IDs that block publish
readonly canPublish: boolean;
readonly determinismDetails?: DeterminismGateDetails;
}
/**
* Release artifact with policy evaluation.
*/
export interface ReleaseArtifact {
readonly artifactId: string;
readonly name: string;
readonly tag: string;
readonly digest: string;
readonly size: number;
readonly createdAt: string;
readonly registry: string;
readonly policyEvaluation?: PolicyEvaluation;
}
/**
* Release workflow status.
*/
export type ReleaseStatus =
| 'draft'
| 'pending_approval'
| 'approved'
| 'publishing'
| 'published'
| 'blocked'
| 'cancelled';
/**
* Release with multiple artifacts and policy gates.
*/
export interface Release {
readonly releaseId: string;
readonly name: string;
readonly version: string;
readonly status: ReleaseStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly artifacts: readonly ReleaseArtifact[];
readonly targetEnvironment: string;
readonly notes?: string;
readonly approvals?: readonly ReleaseApproval[];
readonly publishedAt?: string;
}
/**
* Release approval record.
*/
export interface ReleaseApproval {
readonly approvalId: string;
readonly approver: string;
readonly decision: 'approved' | 'rejected' | 'pending';
readonly comment?: string;
readonly decidedAt?: string;
}
/**
* Feature flag configuration for determinism blocking.
*/
export interface DeterminismFeatureFlags {
readonly enabled: boolean;
readonly blockOnFailure: boolean;
readonly warnOnly: boolean;
readonly bypassRoles?: readonly string[];
readonly requireApprovalForBypass: boolean;
}

View File

@@ -9,9 +9,94 @@ export interface ScanAttestationStatus {
readonly statusMessage?: string;
}
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
export interface FragmentAttestation {
readonly layerDigest: string;
readonly fragmentSha256: string;
readonly dsseEnvelopeSha256: string;
readonly dsseStatus: 'verified' | 'pending' | 'failed';
readonly verifiedAt?: string;
}
export interface CompositionManifest {
readonly compositionUri: string;
readonly merkleRoot: string;
readonly fragmentCount: number;
readonly fragments: readonly FragmentAttestation[];
readonly createdAt: string;
}
export interface DeterminismEvidence {
readonly status: DeterminismStatus;
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly compositionManifest?: CompositionManifest;
readonly contentHash?: string;
readonly verifiedAt?: string;
readonly failureReason?: string;
readonly stellaProperties?: {
readonly 'stellaops:stella.contentHash'?: string;
readonly 'stellaops:composition.manifest'?: string;
readonly 'stellaops:merkle.root'?: string;
};
}
// Entropy analysis models based on docs/modules/scanner/entropy.md
export interface EntropyWindow {
readonly offset: number;
readonly length: number;
readonly entropy: number; // 0-8 bits/byte
}
export interface EntropyFile {
readonly path: string;
readonly size: number;
readonly opaqueBytes: number;
readonly opaqueRatio: number; // 0-1
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
readonly windows: readonly EntropyWindow[];
}
export interface EntropyLayerSummary {
readonly digest: string;
readonly opaqueBytes: number;
readonly totalBytes: number;
readonly opaqueRatio: number; // 0-1
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
}
export interface EntropyReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layerDigest?: string;
readonly files: readonly EntropyFile[];
}
export interface EntropyLayerSummaryReport {
readonly schema: string;
readonly generatedAt: string;
readonly imageDigest: string;
readonly layers: readonly EntropyLayerSummary[];
readonly imageOpaqueRatio: number; // 0-1
readonly entropyPenalty: number; // 0-0.3
}
export interface EntropyEvidence {
readonly report?: EntropyReport;
readonly layerSummary?: EntropyLayerSummaryReport;
readonly downloadUrl?: string; // URL to entropy.report.json
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
readonly completedAt: string;
readonly attestation?: ScanAttestationStatus;
readonly determinism?: DeterminismEvidence;
readonly entropy?: EntropyEvidence;
}

View File

@@ -0,0 +1,125 @@
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
import {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
/**
* User info from authentication.
*/
export interface AuthUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly tenantId: string;
readonly tenantName: string;
readonly roles: readonly string[];
readonly scopes: readonly StellaOpsScope[];
}
/**
* Injection token for Auth service.
*/
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
/**
* Auth service interface.
*/
export interface AuthService {
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
readonly user: ReturnType<typeof signal<AuthUser | null>>;
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
hasScope(scope: StellaOpsScope): boolean;
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
canViewGraph(): boolean;
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
}
// ============================================================================
// Mock Auth Service
// ============================================================================
const MOCK_USER: AuthUser = {
id: 'user-001',
email: 'developer@example.com',
name: 'Developer User',
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
roles: ['developer', 'security-analyst'],
scopes: [
// Graph permissions
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
// Release permissions
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
],
};
@Injectable({ providedIn: 'root' })
export class MockAuthService implements AuthService {
readonly isAuthenticated = signal(true);
readonly user = signal<AuthUser | null>(MOCK_USER);
readonly scopes = computed(() => {
const u = this.user();
return u?.scopes ?? [];
});
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
}
// Re-export scopes for convenience
export { StellaOpsScopes, ScopeGroups } from './scopes';
export type { StellaOpsScope } from './scopes';

View File

@@ -0,0 +1,16 @@
export {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
ScopeLabels,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
export {
AuthUser,
AuthService,
AUTH_SERVICE,
MockAuthService,
} from './auth.service';

View File

@@ -0,0 +1,166 @@
/**
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
*
* This is a stub implementation to unblock Graph Explorer development.
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
*
* @see docs/modules/platform/architecture-overview.md
*/
/**
* All available StellaOps OAuth2 scopes.
*/
export const StellaOpsScopes = {
// Graph scopes
GRAPH_READ: 'graph:read',
GRAPH_WRITE: 'graph:write',
GRAPH_ADMIN: 'graph:admin',
GRAPH_EXPORT: 'graph:export',
GRAPH_SIMULATE: 'graph:simulate',
// SBOM scopes
SBOM_READ: 'sbom:read',
SBOM_WRITE: 'sbom:write',
SBOM_ATTEST: 'sbom:attest',
// Scanner scopes
SCANNER_READ: 'scanner:read',
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
// Policy scopes
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
RELEASE_PUBLISH: 'release:publish',
RELEASE_BYPASS: 'release:bypass',
// AOC scopes
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
} as const;
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
/**
* Scope groupings for common use cases.
*/
export const ScopeGroups = {
GRAPH_VIEWER: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.POLICY_READ,
] as const,
GRAPH_EDITOR: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.SBOM_WRITE,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
GRAPH_ADMIN: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_ADMIN,
StellaOpsScopes.GRAPH_EXPORT,
StellaOpsScopes.GRAPH_SIMULATE,
] as const,
RELEASE_MANAGER: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
SECURITY_ADMIN: [
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
StellaOpsScopes.EXCEPTION_APPROVE,
StellaOpsScopes.RELEASE_BYPASS,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
} as const;
/**
* Human-readable labels for scopes.
*/
export const ScopeLabels: Record<StellaOpsScope, string> = {
'graph:read': 'View Graph',
'graph:write': 'Edit Graph',
'graph:admin': 'Administer Graph',
'graph:export': 'Export Graph Data',
'graph:simulate': 'Run Graph Simulations',
'sbom:read': 'View SBOMs',
'sbom:write': 'Create/Edit SBOMs',
'sbom:attest': 'Attest SBOMs',
'scanner:read': 'View Scan Results',
'scanner:write': 'Configure Scanner',
'scanner:scan': 'Trigger Scans',
'policy:read': 'View Policies',
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
};
/**
* Check if a set of scopes includes a required scope.
*/
export function hasScope(
userScopes: readonly string[],
requiredScope: StellaOpsScope
): boolean {
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
}
/**
* Check if a set of scopes includes all required scopes.
*/
export function hasAllScopes(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.every((scope) => userScopes.includes(scope));
}
/**
* Check if a set of scopes includes any of the required scopes.
*/
export function hasAnyScope(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.some((scope) => userScopes.includes(scope));
}

View File

@@ -0,0 +1,200 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EvidenceData } from '../../core/api/evidence.models';
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
import { EvidencePanelComponent } from './evidence-panel.component';
@Component({
selector: 'app-evidence-page',
standalone: true,
imports: [CommonModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
],
template: `
<div class="evidence-page">
@if (loading()) {
<div class="evidence-page__loading">
<div class="spinner" aria-label="Loading evidence"></div>
<p>Loading evidence for {{ advisoryId() }}...</p>
</div>
} @else if (error()) {
<div class="evidence-page__error" role="alert">
<h2>Error Loading Evidence</h2>
<p>{{ error() }}</p>
<button type="button" (click)="reload()">Retry</button>
</div>
} @else if (evidenceData()) {
<app-evidence-panel
[advisoryId]="advisoryId()"
[evidenceData]="evidenceData()"
(close)="onClose()"
(downloadDocument)="onDownload($event)"
/>
} @else {
<div class="evidence-page__empty">
<h2>No Advisory ID</h2>
<p>Please provide an advisory ID to view evidence.</p>
</div>
}
</div>
`,
styles: [`
.evidence-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
background: #f3f4f6;
}
.evidence-page__loading,
.evidence-page__error,
.evidence-page__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}
.evidence-page__loading .spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.evidence-page__loading p {
margin-top: 1rem;
color: #6b7280;
}
.evidence-page__error {
border: 1px solid #fca5a5;
background: #fef2f2;
}
.evidence-page__error h2 {
color: #dc2626;
margin: 0 0 0.5rem;
}
.evidence-page__error p {
color: #991b1b;
margin: 0 0 1rem;
}
.evidence-page__error button {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: #fff;
color: #dc2626;
cursor: pointer;
}
.evidence-page__error button:hover {
background: #fee2e2;
}
.evidence-page__empty h2 {
color: #374151;
margin: 0 0 0.5rem;
}
.evidence-page__empty p {
color: #6b7280;
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly evidenceApi = inject(EVIDENCE_API);
readonly advisoryId = signal<string>('');
readonly evidenceData = signal<EvidenceData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor() {
// React to route param changes
effect(() => {
const params = this.route.snapshot.paramMap;
const id = params.get('advisoryId');
if (id) {
this.advisoryId.set(id);
this.loadEvidence(id);
}
}, { allowSignalWrites: true });
}
private loadEvidence(advisoryId: string): void {
this.loading.set(true);
this.error.set(null);
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
next: (data) => {
this.evidenceData.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load evidence');
this.loading.set(false);
},
});
}
reload(): void {
const id = this.advisoryId();
if (id) {
this.loadEvidence(id);
}
}
onClose(): void {
this.router.navigate(['/vulnerabilities']);
}
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${event.type}-${event.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: (err) => {
console.error('Download failed:', err);
},
});
}
}

View File

@@ -0,0 +1,591 @@
<div class="evidence-panel" role="dialog" aria-labelledby="evidence-panel-title">
<!-- Header -->
<header class="evidence-panel__header">
<div class="evidence-panel__title-row">
<h2 id="evidence-panel-title" class="evidence-panel__title">
Evidence: {{ advisoryId() }}
</h2>
<button
type="button"
class="evidence-panel__close"
(click)="onClose()"
aria-label="Close evidence panel"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Policy Decision Summary -->
@if (policyEvidence(); as policy) {
<div class="evidence-panel__decision-summary" [class]="policyDecisionClass()">
<span class="decision-badge">{{ policyDecisionLabel() }}</span>
<span class="decision-policy">{{ policy.policyName }}</span>
@if (policy.reason) {
<span class="decision-reason">{{ policy.reason }}</span>
}
</div>
}
<!-- Conflict Banner -->
@if (hasConflicts()) {
<div
class="evidence-panel__conflict-banner"
role="alert"
aria-live="polite"
>
<span class="conflict-icon" aria-hidden="true">!</span>
<span class="conflict-text">
{{ conflictCount() }} conflict(s) detected between sources
</span>
<button
type="button"
class="conflict-toggle"
(click)="toggleConflictDetails()"
[attr.aria-expanded]="showConflictDetails()"
>
{{ showConflictDetails() ? 'Hide details' : 'Show details' }}
</button>
</div>
@if (showConflictDetails() && linkset(); as ls) {
<div class="evidence-panel__conflict-details">
@for (conflict of ls.conflicts; track trackByConflictField($index, conflict)) {
<div class="conflict-item">
<span class="conflict-field">{{ conflict.field }}</span>
<span class="conflict-reason">{{ conflict.reason }}</span>
@if (conflict.values && conflict.values.length > 0) {
<ul class="conflict-values">
@for (value of conflict.values; track value) {
<li>{{ value }}</li>
}
</ul>
}
</div>
}
</div>
}
}
</header>
<!-- Tab Navigation -->
<nav class="evidence-panel__tabs" role="tablist" aria-label="Evidence sections">
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('observations')"
[attr.aria-selected]="isActiveTab('observations')"
(click)="setActiveTab('observations')"
>
Observations ({{ observations().length }})
</button>
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('linkset')"
[attr.aria-selected]="isActiveTab('linkset')"
(click)="setActiveTab('linkset')"
[disabled]="!linkset()"
>
Linkset
</button>
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('policy')"
[attr.aria-selected]="isActiveTab('policy')"
(click)="setActiveTab('policy')"
[disabled]="!policyEvidence()"
>
Policy
</button>
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('aoc')"
[attr.aria-selected]="isActiveTab('aoc')"
(click)="setActiveTab('aoc')"
[disabled]="aocChain().length === 0"
>
AOC Chain
</button>
</nav>
<!-- Tab Content -->
<div class="evidence-panel__content">
<!-- Observations Tab -->
@if (isActiveTab('observations')) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="Observations"
>
<!-- View Toggle -->
<div class="evidence-panel__view-toggle">
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'side-by-side'"
(click)="setObservationView('side-by-side')"
aria-label="Side by side view"
>
Side by Side
</button>
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'stacked'"
(click)="setObservationView('stacked')"
aria-label="Stacked view"
>
Stacked
</button>
</div>
<!-- Observations Grid -->
<div
class="observations-grid"
[class.side-by-side]="observationView() === 'side-by-side'"
[class.stacked]="observationView() === 'stacked'"
>
@for (obs of observations(); track trackByObservationId($index, obs)) {
<article
class="observation-card"
[class.expanded]="isObservationExpanded(obs.observationId)"
>
<!-- Observation Header -->
<header class="observation-card__header">
<div class="observation-card__source">
<span
class="source-icon"
[attr.aria-label]="getSourceInfo(obs.source).name"
>
{{ getSourceInfo(obs.source).name.charAt(0) }}
</span>
<span class="source-name">{{ getSourceInfo(obs.source).name }}</span>
</div>
<button
type="button"
class="observation-card__download"
(click)="onDownloadObservation(obs.observationId)"
aria-label="Download raw observation document"
title="Download raw JSON"
>
<span aria-hidden="true">&#8595;</span>
</button>
</header>
<!-- Observation Body -->
<div class="observation-card__body">
<h4 class="observation-card__title">
{{ obs.title ?? obs.advisoryId }}
</h4>
@if (obs.summary) {
<p class="observation-card__summary">{{ obs.summary }}</p>
}
<!-- Severities -->
@if (obs.severities.length > 0) {
<div class="observation-card__severities">
@for (sev of obs.severities; track sev.system) {
<span
class="severity-badge"
[class]="getSeverityClass(sev.score)"
>
{{ sev.system.toUpperCase() }}: {{ sev.score }}
({{ getSeverityLabel(sev.score) }})
</span>
}
</div>
}
<!-- Affected Packages -->
@if (obs.affected.length > 0) {
<div class="observation-card__affected">
<strong>Affected:</strong>
<ul>
@for (pkg of obs.affected; track pkg.purl) {
<li>
<code class="purl">{{ pkg.purl }}</code>
@if (pkg.ecosystem) {
<span class="ecosystem">({{ pkg.ecosystem }})</span>
}
</li>
}
</ul>
</div>
}
<!-- Expandable Details -->
<button
type="button"
class="observation-card__expand"
(click)="toggleObservationExpanded(obs.observationId)"
[attr.aria-expanded]="isObservationExpanded(obs.observationId)"
>
{{ isObservationExpanded(obs.observationId) ? 'Show less' : 'Show more' }}
</button>
@if (isObservationExpanded(obs.observationId)) {
<div class="observation-card__details">
<!-- Weaknesses -->
@if (obs.weaknesses && obs.weaknesses.length > 0) {
<div class="detail-section">
<strong>Weaknesses:</strong>
<span class="weakness-list">
{{ obs.weaknesses.join(', ') }}
</span>
</div>
}
<!-- References -->
@if (obs.references && obs.references.length > 0) {
<div class="detail-section">
<strong>References:</strong>
<ul class="reference-list">
@for (ref of obs.references; track ref) {
<li>
<a
[href]="ref"
target="_blank"
rel="noopener noreferrer"
>
{{ ref }}
</a>
</li>
}
</ul>
</div>
}
<!-- Provenance -->
<div class="detail-section">
<strong>Provenance:</strong>
<dl class="provenance-list">
<dt>Source Artifact SHA:</dt>
<dd>
<code>{{ truncateHash(obs.provenance.sourceArtifactSha, 20) }}</code>
</dd>
<dt>Fetched At:</dt>
<dd>{{ formatDate(obs.provenance.fetchedAt) }}</dd>
@if (obs.provenance.ingestJobId) {
<dt>Ingest Job:</dt>
<dd>
<code>{{ obs.provenance.ingestJobId }}</code>
</dd>
}
</dl>
</div>
<!-- Timestamps -->
<div class="detail-section">
<strong>Timestamps:</strong>
<dl class="timestamp-list">
<dt>Published:</dt>
<dd>{{ formatDate(obs.published) }}</dd>
<dt>Modified:</dt>
<dd>{{ formatDate(obs.modified) }}</dd>
<dt>Ingested:</dt>
<dd>{{ formatDate(obs.ingestedAt) }}</dd>
</dl>
</div>
</div>
}
</div>
</article>
}
</div>
</section>
}
<!-- Linkset Tab -->
@if (isActiveTab('linkset') && linkset(); as ls) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="Linkset"
>
<div class="linkset-panel">
<header class="linkset-panel__header">
<h3>Linkset: {{ ls.linksetId }}</h3>
<button
type="button"
class="linkset-panel__download"
(click)="onDownloadLinkset(ls.linksetId)"
aria-label="Download raw linkset document"
>
Download Raw JSON
</button>
</header>
<div class="linkset-panel__meta">
<dl>
<dt>Advisory ID:</dt>
<dd>{{ ls.advisoryId }}</dd>
<dt>Source:</dt>
<dd>{{ ls.source }}</dd>
<dt>Confidence:</dt>
<dd>
@if (ls.confidence !== undefined) {
<span
class="confidence-badge"
[class.high]="ls.confidence >= 0.9"
[class.medium]="ls.confidence >= 0.7 && ls.confidence < 0.9"
[class.low]="ls.confidence < 0.7"
>
{{ (ls.confidence * 100).toFixed(0) }}%
</span>
} @else {
N/A
}
</dd>
<dt>Created:</dt>
<dd>{{ formatDate(ls.createdAt) }}</dd>
@if (ls.builtByJobId) {
<dt>Built By Job:</dt>
<dd>
<code>{{ ls.builtByJobId }}</code>
</dd>
}
</dl>
</div>
<!-- Linked Observations -->
<div class="linkset-panel__observations">
<h4>Linked Observations ({{ ls.observations.length }})</h4>
<ul class="observation-id-list">
@for (obsId of ls.observations; track obsId) {
<li>
<code>{{ obsId }}</code>
</li>
}
</ul>
</div>
<!-- Normalized Data -->
@if (ls.normalized) {
<div class="linkset-panel__normalized">
<h4>Normalized Data</h4>
<dl>
@if (ls.normalized.purls && ls.normalized.purls.length > 0) {
<dt>PURLs:</dt>
<dd>
<ul>
@for (purl of ls.normalized.purls; track purl) {
<li>
<code>{{ purl }}</code>
</li>
}
</ul>
</dd>
}
@if (ls.normalized.severities && ls.normalized.severities.length > 0) {
<dt>Severities:</dt>
<dd>
@for (sev of ls.normalized.severities; track sev.system) {
<span
class="severity-badge"
[class]="getSeverityClass(sev.score)"
>
{{ sev.system.toUpperCase() }}: {{ sev.score }}
</span>
}
</dd>
}
</dl>
</div>
}
<!-- Provenance -->
@if (ls.provenance) {
<div class="linkset-panel__provenance">
<h4>Provenance</h4>
<dl>
@if (ls.provenance.toolVersion) {
<dt>Tool Version:</dt>
<dd>
<code>{{ ls.provenance.toolVersion }}</code>
</dd>
}
@if (ls.provenance.policyHash) {
<dt>Policy Hash:</dt>
<dd>
<code>{{ truncateHash(ls.provenance.policyHash) }}</code>
</dd>
}
@if (ls.provenance.observationHashes.length > 0) {
<dt>Observation Hashes:</dt>
<dd>
<ul>
@for (hash of ls.provenance.observationHashes; track hash) {
<li>
<code>{{ truncateHash(hash, 16) }}</code>
</li>
}
</ul>
</dd>
}
</dl>
</div>
}
</div>
</section>
}
<!-- Policy Tab -->
@if (isActiveTab('policy') && policyEvidence(); as policy) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="Policy"
>
<div class="policy-panel">
<header class="policy-panel__header">
<h3>{{ policy.policyName }}</h3>
<span
class="policy-decision-badge"
[class]="policyDecisionClass()"
>
{{ policyDecisionLabel() }}
</span>
</header>
<div class="policy-panel__meta">
<dl>
<dt>Policy ID:</dt>
<dd>
<code>{{ policy.policyId }}</code>
</dd>
<dt>Decided At:</dt>
<dd>{{ formatDate(policy.decidedAt) }}</dd>
@if (policy.reason) {
<dt>Reason:</dt>
<dd>{{ policy.reason }}</dd>
}
</dl>
</div>
<!-- Policy Rules -->
<div class="policy-panel__rules">
<h4>Rule Results ({{ policy.rules.length }})</h4>
<ul class="rule-list">
@for (rule of policy.rules; track trackByRuleId($index, rule)) {
<li class="rule-item" [class]="getRuleClass(rule.passed)">
<span class="rule-icon" aria-hidden="true">
{{ rule.passed ? '&#10003;' : '&#10007;' }}
</span>
<div class="rule-content">
<span class="rule-name">{{ rule.ruleName }}</span>
<code class="rule-id">{{ rule.ruleId }}</code>
@if (rule.reason) {
<p class="rule-reason">{{ rule.reason }}</p>
}
@if (rule.matchedItems && rule.matchedItems.length > 0) {
<div class="rule-matched">
<strong>Matched:</strong>
<span>{{ rule.matchedItems.join(', ') }}</span>
</div>
}
</div>
</li>
}
</ul>
</div>
<!-- Linked Linksets -->
@if (policy.linksetIds.length > 0) {
<div class="policy-panel__linksets">
<h4>Linked Linksets</h4>
<ul>
@for (lsId of policy.linksetIds; track lsId) {
<li>
<code>{{ lsId }}</code>
</li>
}
</ul>
</div>
}
</div>
</section>
}
<!-- AOC Chain Tab -->
@if (isActiveTab('aoc')) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="AOC Chain"
>
<div class="aoc-panel">
<header class="aoc-panel__header">
<h3>Attestation of Compliance Chain</h3>
<p class="aoc-panel__description">
Cryptographic chain of evidence from raw observations through policy decision.
</p>
</header>
<div class="aoc-chain">
@for (entry of aocChain(); track trackByAocId($index, entry); let i = $index; let last = $last) {
<div class="aoc-entry" [class]="getAocTypeClass(entry.type)">
<div class="aoc-entry__connector">
<span class="aoc-entry__number">{{ i + 1 }}</span>
@if (!last) {
<span class="aoc-entry__line" aria-hidden="true"></span>
}
</div>
<div class="aoc-entry__content">
<header class="aoc-entry__header">
<span class="aoc-type-badge">{{ getAocTypeLabel(entry.type) }}</span>
<button
type="button"
class="aoc-entry__toggle"
(click)="toggleAocEntry(entry.attestationId)"
[attr.aria-expanded]="isAocEntryExpanded(entry.attestationId)"
>
{{ isAocEntryExpanded(entry.attestationId) ? 'Collapse' : 'Expand' }}
</button>
</header>
<div class="aoc-entry__summary">
<code class="aoc-hash">{{ truncateHash(entry.hash, 16) }}</code>
<span class="aoc-timestamp">{{ formatDate(entry.timestamp) }}</span>
</div>
@if (isAocEntryExpanded(entry.attestationId)) {
<div class="aoc-entry__details">
<dl>
<dt>Attestation ID:</dt>
<dd>
<code>{{ entry.attestationId }}</code>
</dd>
<dt>Full Hash:</dt>
<dd>
<code class="full-hash">{{ entry.hash }}</code>
</dd>
@if (entry.parentHash) {
<dt>Parent Hash:</dt>
<dd>
<code class="full-hash">{{ entry.parentHash }}</code>
</dd>
}
@if (entry.signer) {
<dt>Signer:</dt>
<dd>{{ entry.signer }}</dd>
}
</dl>
</div>
}
</div>
</div>
}
</div>
</div>
</section>
}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import {
AocChainEntry,
EvidenceData,
Linkset,
LinksetConflict,
Observation,
PolicyDecision,
PolicyEvidence,
PolicyRuleResult,
SOURCE_INFO,
SourceInfo,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@Component({
selector: 'app-evidence-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './evidence-panel.component.html',
styleUrls: ['./evidence-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePanelComponent {
private readonly evidenceApi = inject(EVIDENCE_API);
// Inputs
readonly advisoryId = input.required<string>();
readonly evidenceData = input<EvidenceData | null>(null);
// Outputs
readonly close = output<void>();
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
// UI State
readonly activeTab = signal<TabId>('observations');
readonly observationView = signal<ObservationView>('side-by-side');
readonly expandedObservation = signal<string | null>(null);
readonly expandedAocEntry = signal<string | null>(null);
readonly showConflictDetails = signal(false);
// Loading/error state
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Computed values
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
readonly aocChain = computed(() => {
const policy = this.policyEvidence();
return policy?.aocChain ?? [];
});
readonly policyDecisionClass = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionClass(decision);
});
readonly policyDecisionLabel = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionLabel(decision);
});
readonly observationSources = computed(() => {
const obs = this.observations();
return obs.map((o) => this.getSourceInfo(o.source));
});
// Tab methods
setActiveTab(tab: TabId): void {
this.activeTab.set(tab);
}
isActiveTab(tab: TabId): boolean {
return this.activeTab() === tab;
}
// Observation view methods
setObservationView(view: ObservationView): void {
this.observationView.set(view);
}
toggleObservationExpanded(observationId: string): void {
const current = this.expandedObservation();
this.expandedObservation.set(current === observationId ? null : observationId);
}
isObservationExpanded(observationId: string): boolean {
return this.expandedObservation() === observationId;
}
// AOC chain methods
toggleAocEntry(attestationId: string): void {
const current = this.expandedAocEntry();
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
}
isAocEntryExpanded(attestationId: string): boolean {
return this.expandedAocEntry() === attestationId;
}
// Conflict methods
toggleConflictDetails(): void {
this.showConflictDetails.update((v) => !v);
}
// Source info helper
getSourceInfo(sourceId: string): SourceInfo {
return (
SOURCE_INFO[sourceId] ?? {
sourceId,
name: sourceId.toUpperCase(),
icon: 'file',
}
);
}
// Decision helpers
getDecisionClass(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'decision-pass';
case 'warn':
return 'decision-warn';
case 'block':
return 'decision-block';
case 'pending':
default:
return 'decision-pending';
}
}
getDecisionLabel(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'Passed';
case 'warn':
return 'Warning';
case 'block':
return 'Blocked';
case 'pending':
default:
return 'Pending';
}
}
// Rule result helpers
getRuleClass(passed: boolean): string {
return passed ? 'rule-passed' : 'rule-failed';
}
getRuleIcon(passed: boolean): string {
return passed ? 'check-circle' : 'x-circle';
}
// AOC chain helpers
getAocTypeLabel(type: AocChainEntry['type']): string {
switch (type) {
case 'observation':
return 'Observation';
case 'linkset':
return 'Linkset';
case 'policy':
return 'Policy Decision';
case 'signature':
return 'Signature';
default:
return type;
}
}
getAocTypeClass(type: AocChainEntry['type']): string {
return `aoc-type-${type}`;
}
// Severity helpers
getSeverityClass(score: number): string {
if (score >= 9.0) return 'severity-critical';
if (score >= 7.0) return 'severity-high';
if (score >= 4.0) return 'severity-medium';
return 'severity-low';
}
getSeverityLabel(score: number): string {
if (score >= 9.0) return 'Critical';
if (score >= 7.0) return 'High';
if (score >= 4.0) return 'Medium';
return 'Low';
}
// Download handlers
onDownloadObservation(observationId: string): void {
this.downloadDocument.emit({ type: 'observation', id: observationId });
}
onDownloadLinkset(linksetId: string): void {
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
}
// Close handler
onClose(): void {
this.close.emit();
}
// Date formatting
formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'N/A';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
// Hash truncation for display
truncateHash(hash: string | undefined, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
// Track by functions for ngFor
trackByObservationId(_: number, obs: Observation): string {
return obs.observationId;
}
trackByAocId(_: number, entry: AocChainEntry): string {
return entry.attestationId;
}
trackByConflictField(_: number, conflict: LinksetConflict): string {
return conflict.field;
}
trackByRuleId(_: number, rule: PolicyRuleResult): string {
return rule.ruleId;
}
}

View File

@@ -0,0 +1,2 @@
export { EvidencePanelComponent } from './evidence-panel.component';
export { EvidencePageComponent } from './evidence-page.component';

View File

@@ -4,6 +4,7 @@ import {
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
@@ -17,6 +18,12 @@ import {
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
AUTH_SERVICE,
AuthService,
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
export interface GraphNode {
readonly id: string;
@@ -74,11 +81,27 @@ type ViewMode = 'hierarchy' | 'flat';
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
private readonly authService = inject(AUTH_SERVICE);
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
readonly canViewGraph = computed(() => this.authService.canViewGraph());
readonly canEditGraph = computed(() => this.authService.canEditGraph());
readonly canExportGraph = computed(() => this.authService.canExportGraph());
readonly canSimulate = computed(() => this.authService.canSimulate());
readonly canCreateException = computed(() =>
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
);
// Current user info
readonly currentUser = computed(() => this.authService.user());
readonly userScopes = computed(() => this.authService.scopes());
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);

View File

@@ -0,0 +1,3 @@
export { ReleaseFlowComponent } from './release-flow.component';
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
export { RemediationHintsComponent } from './remediation-hints.component';

View File

@@ -0,0 +1,328 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
template: `
<div
class="gate-indicator"
[class.gate-indicator--expanded]="expanded()"
[class]="'gate-indicator--' + gate().status"
>
<button
type="button"
class="gate-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
[attr.aria-controls]="'gate-details-' + gate().gateId"
>
<div class="gate-status">
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
@switch (gate().status) {
@case ('passed') { <span>&#10003;</span> }
@case ('failed') { <span>&#10007;</span> }
@case ('warning') { <span>!</span> }
@case ('pending') { <span>&#8987;</span> }
@case ('skipped') { <span>-</span> }
}
</span>
<span class="status-text">{{ getStatusLabel() }}</span>
</div>
<div class="gate-info">
<span class="gate-name">{{ gate().name }}</span>
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
}
@if (gate().blockingPublish) {
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
}
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
<p class="gate-message">{{ gate().message }}</p>
<div class="gate-meta">
<span class="meta-item">
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
</span>
@if (gate().evidence?.url) {
<a
[href]="gate().evidence?.url"
class="evidence-link"
target="_blank"
rel="noopener"
>
View Evidence
</a>
}
</div>
<!-- Determinism-specific info when feature flag shows it -->
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
<div class="feature-flag-info">
@if (featureFlags()?.blockOnFailure) {
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
} @else if (featureFlags()?.warnOnly) {
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.gate-indicator {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #22c55e;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--warning {
border-left: 3px solid #f97316;
}
&--pending {
border-left: 3px solid #eab308;
}
&--skipped {
border-left: 3px solid #64748b;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: bold;
}
.gate-indicator--passed .status-icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gate-indicator--failed .status-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.gate-indicator--warning .status-icon {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.gate-indicator--pending .status-icon {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.gate-indicator--skipped .status-icon {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gate-indicator--passed .status-text { color: #22c55e; }
.gate-indicator--failed .status-text { color: #ef4444; }
.gate-indicator--warning .status-text { color: #f97316; }
.gate-indicator--pending .status-text { color: #eab308; }
.gate-indicator--skipped .status-text { color: #64748b; }
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gate-name {
font-weight: 500;
}
.gate-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
&--determinism {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
}
.blocking-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.gate-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid #334155;
margin-top: 0;
}
.gate-message {
margin: 0.75rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.gate-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: #64748b;
strong {
color: #94a3b8;
}
}
.evidence-link {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.feature-flag-info {
margin-top: 0.75rem;
}
.flag-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--active {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
&--warn {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
readonly gate = input.required<PolicyGateResult>();
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
readonly expanded = signal(false);
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getStatusLabel(): string {
const labels: Record<PolicyGateStatus, string> = {
passed: 'Passed',
failed: 'Failed',
pending: 'Pending',
warning: 'Warning',
skipped: 'Skipped',
};
return labels[this.gate().status] ?? 'Unknown';
}
getStatusIconClass(): string {
return `status-icon--${this.gate().status}`;
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}

View File

@@ -0,0 +1,331 @@
<section class="release-flow">
<!-- Header -->
<header class="release-flow__header">
<div class="header-left">
@if (viewMode() === 'detail') {
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
<span aria-hidden="true">&larr;</span> Releases
</button>
}
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
</div>
<div class="header-right">
@if (isDeterminismEnabled()) {
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
Determinism Gates Active
</span>
} @else {
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
Determinism Gates Disabled
</span>
}
</div>
</header>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading releases...</p>
</div>
}
<!-- List View -->
@if (!loading() && viewMode() === 'list') {
<div class="releases-list">
@for (release of releases(); track trackByReleaseId($index, release)) {
<article
class="release-card"
[class.release-card--blocked]="release.status === 'blocked'"
(click)="selectRelease(release)"
(keydown.enter)="selectRelease(release)"
tabindex="0"
role="button"
[attr.aria-label]="'View release ' + release.name"
>
<div class="release-card__header">
<h2>{{ release.name }}</h2>
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
{{ release.status | titlecase }}
</span>
</div>
<div class="release-card__meta">
<span class="meta-item">
<strong>Version:</strong> {{ release.version }}
</span>
<span class="meta-item">
<strong>Target:</strong> {{ release.targetEnvironment }}
</span>
<span class="meta-item">
<strong>Artifacts:</strong> {{ release.artifacts.length }}
</span>
</div>
<div class="release-card__gates">
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
@if (artifact.policyEvaluation) {
<div class="artifact-gates">
<span class="artifact-name">{{ artifact.name }}:</span>
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
<span
class="gate-pip"
[ngClass]="getStatusClass(gate.status)"
[title]="gate.name + ': ' + gate.status"
></span>
}
</div>
}
}
</div>
@if (release.status === 'blocked') {
<div class="release-card__warning">
<span class="warning-icon" aria-hidden="true">!</span>
Policy gates blocking publish
</div>
}
</article>
} @empty {
<p class="empty-state">No releases found.</p>
}
</div>
}
<!-- Detail View -->
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
<div class="release-detail">
<!-- Release Info -->
<section class="detail-section">
<h2>Release Information</h2>
<dl class="info-grid">
<div>
<dt>Version</dt>
<dd>{{ selectedRelease()?.version }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
{{ selectedRelease()?.status | titlecase }}
</span>
</dd>
</div>
<div>
<dt>Target Environment</dt>
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
</div>
</dl>
@if (selectedRelease()?.notes) {
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
}
</section>
<!-- Determinism Gate Summary -->
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
<section class="detail-section determinism-blocking-banner">
<div class="banner-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="banner-content">
<h3>Determinism Check Failed</h3>
<p>
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
Publishing is blocked until issues are resolved or a bypass is approved.
</p>
</div>
</section>
}
<!-- Artifacts Section -->
<section class="detail-section">
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
<div class="artifacts-tabs" role="tablist">
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
<button
type="button"
role="tab"
class="artifact-tab"
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
(click)="selectArtifact(artifact)"
>
<span class="artifact-tab__name">{{ artifact.name }}</span>
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
@if (!artifact.policyEvaluation?.canPublish) {
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
}
</button>
}
</div>
<!-- Selected Artifact Details -->
@if (selectedArtifact()) {
<div class="artifact-detail" role="tabpanel">
<dl class="artifact-meta">
<div>
<dt>Digest</dt>
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
</div>
<div>
<dt>Size</dt>
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
</div>
<div>
<dt>Registry</dt>
<dd>{{ selectedArtifact()?.registry }}</dd>
</div>
</dl>
<!-- Policy Gates -->
@if (selectedArtifact()?.policyEvaluation) {
<div class="policy-gates">
<h3>Policy Gates</h3>
<div class="gates-list">
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
<app-policy-gate-indicator
[gate]="gate"
[featureFlags]="featureFlags()"
/>
}
</div>
<!-- Determinism Details -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
<div class="determinism-details">
<h4>Determinism Evidence</h4>
<dl>
<div>
<dt>Merkle Root</dt>
<dd>
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
} @else {
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
}
</dd>
</div>
<div>
<dt>Fragment Verification</dt>
<dd>
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
</dd>
</div>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
<div>
<dt>Composition Manifest</dt>
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
</div>
}
</dl>
<!-- Failed Fragments -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
<div class="failed-fragments">
<h5>Failed Fragment Layers</h5>
<ul>
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
<li><code>{{ fragment }}</code></li>
}
</ul>
</div>
}
</div>
}
<!-- Remediation Hints for Failed Gates -->
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
@if (gate.status === 'failed' && gate.remediation) {
<app-remediation-hints [gate]="gate" />
}
}
</div>
}
</div>
}
</section>
<!-- Actions -->
<section class="detail-section actions-section">
<h2>Actions</h2>
<div class="action-buttons">
@if (canPublishSelected()) {
<button
type="button"
class="btn btn--primary"
(click)="publishRelease()"
[disabled]="publishing()"
>
@if (publishing()) {
Publishing...
} @else {
Publish Release
}
</button>
} @else {
<button
type="button"
class="btn btn--primary btn--disabled"
disabled
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
>
Publish Blocked
</button>
@if (canBypass() && determinismBlockingCount() > 0) {
<button
type="button"
class="btn btn--warning"
(click)="openBypassModal()"
>
Request Bypass
</button>
}
}
<button type="button" class="btn btn--secondary" (click)="backToList()">
Back to List
</button>
</div>
</section>
</div>
}
<!-- Bypass Request Modal -->
@if (showBypassModal()) {
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
<div class="modal-content" (click)="$event.stopPropagation()">
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
<p class="modal-description">
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
This request requires approval from a security administrator.
</p>
<label for="bypass-reason">Justification</label>
<textarea
id="bypass-reason"
rows="4"
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
[value]="bypassReason()"
(input)="updateBypassReason($event)"
></textarea>
<div class="modal-actions">
<button
type="button"
class="btn btn--primary"
(click)="submitBypassRequest()"
[disabled]="!bypassReason().trim()"
>
Submit Request
</button>
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
Cancel
</button>
</div>
</div>
</div>
}
</section>

View File

@@ -0,0 +1,661 @@
.release-flow {
display: grid;
gap: 1.5rem;
padding: 1.5rem;
color: #e2e8f0;
background: #0f172a;
min-height: calc(100vh - 120px);
}
// Header
.release-flow__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
h1 {
margin: 0;
font-size: 1.5rem;
}
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.back-button {
background: transparent;
border: 1px solid #334155;
color: #94a3b8;
padding: 0.375rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
&:hover {
background: #1e293b;
color: #e2e8f0;
}
}
.feature-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
&--enabled {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.3);
}
&--disabled {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
border: 1px solid rgba(100, 116, 139, 0.3);
}
}
// Loading
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #94a3b8;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Releases List
.releases-list {
display: grid;
gap: 1rem;
}
.release-card {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover,
&:focus {
background: #1e293b;
border-color: #334155;
outline: none;
}
&--blocked {
border-left: 4px solid #ef4444;
}
}
.release-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
}
.release-status {
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.release-status--draft {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}
.release-status--pending {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.release-status--approved {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.release-status--publishing {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.release-status--published {
background: rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.release-status--blocked {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.release-status--cancelled {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.release-card__meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: #94a3b8;
strong {
color: #cbd5e1;
}
}
.release-card__gates {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.artifact-gates {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #94a3b8;
}
.gate-pip {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status--passed {
background: #22c55e;
}
.status--failed {
background: #ef4444;
}
.status--pending {
background: #eab308;
}
.status--warning {
background: #f97316;
}
.status--skipped {
background: #64748b;
}
.release-card__warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
font-size: 0.875rem;
color: #ef4444;
}
.warning-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: #ef4444;
color: #111827;
border-radius: 50%;
font-weight: bold;
font-size: 0.75rem;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 2rem;
}
// Detail View
.release-detail {
display: grid;
gap: 1.5rem;
}
.detail-section {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: #e2e8f0;
}
h3 {
margin: 1rem 0 0.75rem 0;
font-size: 1rem;
color: #cbd5e1;
}
h4 {
margin: 1rem 0 0.5rem 0;
font-size: 0.875rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
h5 {
margin: 0.75rem 0 0.5rem 0;
font-size: 0.8125rem;
color: #ef4444;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 0;
dt {
font-size: 0.75rem;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.25rem;
}
dd {
margin: 0;
color: #e2e8f0;
}
}
.release-notes {
margin: 1rem 0 0 0;
padding: 0.75rem 1rem;
background: #0f172a;
border-radius: 4px;
color: #94a3b8;
font-style: italic;
}
// Determinism Blocking Banner
.determinism-blocking-banner {
display: flex;
align-items: flex-start;
gap: 1rem;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
.banner-icon {
flex-shrink: 0;
color: #ef4444;
}
.banner-content {
h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: #ef4444;
}
p {
margin: 0;
color: #fca5a5;
font-size: 0.875rem;
}
}
// Artifacts Tabs
.artifacts-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.artifact-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
color: #94a3b8;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #1e293b;
color: #e2e8f0;
}
&--active {
background: #1d4ed8;
border-color: #1d4ed8;
color: #f8fafc;
}
&--blocked:not(.artifact-tab--active) {
border-color: rgba(239, 68, 68, 0.5);
}
}
.artifact-tab__name {
font-weight: 500;
}
.artifact-tab__tag {
font-size: 0.75rem;
opacity: 0.8;
}
.artifact-tab__blocked {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: #ef4444;
color: #111827;
border-radius: 50%;
font-weight: bold;
font-size: 0.625rem;
}
// Artifact Detail
.artifact-detail {
padding: 1rem;
background: #0f172a;
border-radius: 4px;
}
.artifact-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin: 0 0 1rem 0;
dt {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.8125rem;
word-break: break-all;
}
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
color: #94a3b8;
}
}
// Policy Gates
.policy-gates {
margin-top: 1rem;
}
.gates-list {
display: grid;
gap: 0.75rem;
}
// Determinism Details
.determinism-details {
margin-top: 1rem;
padding: 1rem;
background: #111827;
border-radius: 4px;
border: 1px solid #1f2933;
dl {
margin: 0;
display: grid;
gap: 0.75rem;
}
dt {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.125rem;
}
dd {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
color: #94a3b8;
word-break: break-all;
}
}
}
.consistency-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
&--consistent {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
&--inconsistent {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
}
.failed-fragments {
margin-top: 0.75rem;
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
ul {
margin: 0;
padding-left: 1.25rem;
list-style: disc;
li {
margin: 0.25rem 0;
color: #fca5a5;
}
code {
font-size: 0.75rem;
}
}
}
// Actions Section
.actions-section {
background: #0f172a;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.btn {
padding: 0.625rem 1.25rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
border: none;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #1d4ed8;
color: #f8fafc;
&:hover:not(:disabled) {
background: #1e40af;
}
}
&--secondary {
background: #334155;
color: #e2e8f0;
&:hover:not(:disabled) {
background: #475569;
}
}
&--warning {
background: #d97706;
color: #f8fafc;
&:hover:not(:disabled) {
background: #b45309;
}
}
&--disabled {
background: #475569;
color: #94a3b8;
}
}
// Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.5rem;
width: 100%;
max-width: 500px;
h2 {
margin: 0 0 0.75rem 0;
font-size: 1.25rem;
color: #e2e8f0;
}
label {
display: block;
margin: 1rem 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #cbd5e1;
}
textarea {
width: 100%;
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
&:focus {
outline: none;
border-color: #3b82f6;
}
&::placeholder {
color: #64748b;
}
}
}
.modal-description {
margin: 0;
color: #94a3b8;
font-size: 0.875rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
justify-content: flex-end;
}

View File

@@ -0,0 +1,229 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import {
Release,
ReleaseArtifact,
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
import { RemediationHintsComponent } from './remediation-hints.component';
type ViewMode = 'list' | 'detail';
@Component({
selector: 'app-release-flow',
standalone: true,
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
templateUrl: './release-flow.component.html',
styleUrls: ['./release-flow.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseFlowComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly releaseApi = inject(RELEASE_API);
// State
readonly releases = signal<readonly Release[]>([]);
readonly selectedRelease = signal<Release | null>(null);
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
readonly loading = signal(true);
readonly publishing = signal(false);
readonly viewMode = signal<ViewMode>('list');
readonly bypassReason = signal('');
readonly showBypassModal = signal(false);
// Computed values
readonly canPublishSelected = computed(() => {
const release = this.selectedRelease();
if (!release) return false;
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
});
readonly blockingGatesCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
}, 0);
});
readonly determinismBlockingCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
const gates = artifact.policyEvaluation?.gates ?? [];
const deterministicBlocking = gates.filter(
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
);
return count + deterministicBlocking.length;
}, 0);
});
readonly isDeterminismEnabled = computed(() => {
const flags = this.featureFlags();
return flags?.enabled ?? false;
});
readonly canBypass = computed(() => {
const flags = this.featureFlags();
return flags?.bypassRoles && flags.bypassRoles.length > 0;
});
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
this.loading.set(true);
// Load feature flags
this.releaseApi.getFeatureFlags().subscribe({
next: (flags) => this.featureFlags.set(flags),
error: (err) => console.error('Failed to load feature flags:', err),
});
// Load releases
this.releaseApi.listReleases().subscribe({
next: (releases) => {
this.releases.set(releases);
this.loading.set(false);
// Check if we should auto-select from route
const releaseId = this.route.snapshot.paramMap.get('releaseId');
if (releaseId) {
const release = releases.find((r) => r.releaseId === releaseId);
if (release) {
this.selectRelease(release);
}
}
},
error: (err) => {
console.error('Failed to load releases:', err);
this.loading.set(false);
},
});
}
selectRelease(release: Release): void {
this.selectedRelease.set(release);
this.selectedArtifact.set(release.artifacts[0] ?? null);
this.viewMode.set('detail');
}
selectArtifact(artifact: ReleaseArtifact): void {
this.selectedArtifact.set(artifact);
}
backToList(): void {
this.selectedRelease.set(null);
this.selectedArtifact.set(null);
this.viewMode.set('list');
}
publishRelease(): void {
const release = this.selectedRelease();
if (!release || !this.canPublishSelected()) return;
this.publishing.set(true);
this.releaseApi.publishRelease(release.releaseId).subscribe({
next: (updated) => {
// Update the release in the list
this.releases.update((list) =>
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
);
this.selectedRelease.set(updated);
this.publishing.set(false);
},
error: (err) => {
console.error('Publish failed:', err);
this.publishing.set(false);
},
});
}
openBypassModal(): void {
this.bypassReason.set('');
this.showBypassModal.set(true);
}
closeBypassModal(): void {
this.showBypassModal.set(false);
}
submitBypassRequest(): void {
const release = this.selectedRelease();
const reason = this.bypassReason();
if (!release || !reason.trim()) return;
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
next: (result) => {
console.log('Bypass requested:', result.requestId);
this.closeBypassModal();
// In real implementation, would show notification and refresh
},
error: (err) => console.error('Bypass request failed:', err),
});
}
updateBypassReason(event: Event): void {
const target = event.target as HTMLTextAreaElement;
this.bypassReason.set(target.value);
}
getStatusClass(status: PolicyGateStatus): string {
const statusClasses: Record<PolicyGateStatus, string> = {
passed: 'status--passed',
failed: 'status--failed',
pending: 'status--pending',
warning: 'status--warning',
skipped: 'status--skipped',
};
return statusClasses[status] ?? 'status--pending';
}
getReleaseStatusClass(release: Release): string {
const statusClasses: Record<string, string> = {
draft: 'release-status--draft',
pending_approval: 'release-status--pending',
approved: 'release-status--approved',
publishing: 'release-status--publishing',
published: 'release-status--published',
blocked: 'release-status--blocked',
cancelled: 'release-status--cancelled',
};
return statusClasses[release.status] ?? 'release-status--draft';
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
trackByReleaseId(_index: number, release: Release): string {
return release.releaseId;
}
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
return artifact.artifactId;
}
trackByGateId(_index: number, gate: PolicyGateResult): string {
return gate.gateId;
}
}

View File

@@ -0,0 +1,507 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
PolicyGateResult,
RemediationHint,
RemediationStep,
RemediationActionType,
} from '../../core/api/release.models';
@Component({
selector: 'app-remediation-hints',
standalone: true,
imports: [CommonModule],
template: `
<div class="remediation-hints" [class.remediation-hints--collapsed]="!expanded()">
<button
type="button"
class="remediation-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
>
<div class="header-content">
<span class="header-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</span>
<span class="header-title">Remediation Steps</span>
<span class="severity-badge" [class]="'severity-badge--' + hint().severity">
{{ hint().severity | titlecase }}
</span>
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="remediation-content">
<!-- Summary -->
<p class="remediation-summary">{{ hint().summary }}</p>
<!-- Estimated Effort -->
@if (hint().estimatedEffort) {
<div class="effort-indicator">
<span class="effort-icon" aria-hidden="true">&#9200;</span>
<span>Estimated effort: <strong>{{ hint().estimatedEffort }}</strong></span>
</div>
}
<!-- Steps -->
<ol class="remediation-steps">
@for (step of hint().steps; track step.action; let i = $index) {
<li class="step" [class.step--automated]="step.automated">
<div class="step-header">
<span class="step-number">{{ i + 1 }}</span>
<span class="step-title">{{ step.title }}</span>
@if (step.automated) {
<span class="automated-badge" title="Can be triggered from UI">Automated</span>
}
<span class="action-type-icon" [title]="getActionTypeLabel(step.action)">
{{ getActionTypeIcon(step.action) }}
</span>
</div>
<p class="step-description">{{ step.description }}</p>
@if (step.command) {
<div class="step-command">
<code>{{ step.command }}</code>
<button
type="button"
class="copy-button"
(click)="copyCommand(step.command)"
title="Copy command"
aria-label="Copy command to clipboard"
>
@if (copiedCommand() === step.command) {
&#10003;
} @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
}
</button>
</div>
}
@if (step.documentationUrl) {
<a
[href]="step.documentationUrl"
class="docs-link"
target="_blank"
rel="noopener"
>
View documentation &rarr;
</a>
}
@if (step.automated) {
<button
type="button"
class="action-button"
(click)="triggerAction(step)"
>
{{ getActionButtonLabel(step.action) }}
</button>
}
</li>
}
</ol>
<!-- Exception Option -->
@if (hint().exceptionAllowed) {
<div class="exception-option">
<div class="exception-info">
<span class="exception-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</span>
<span>A policy exception can be requested if compensating controls are in place.</span>
</div>
<button
type="button"
class="exception-button"
(click)="requestException()"
>
Request Exception
</button>
</div>
}
</div>
}
</div>
`,
styles: [`
.remediation-hints {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
margin-top: 1rem;
overflow: hidden;
}
.remediation-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.05);
border: none;
border-bottom: 1px solid transparent;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
.remediation-hints:not(.remediation-hints--collapsed) .remediation-header {
border-bottom-color: #334155;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
color: #f97316;
}
.header-title {
font-weight: 500;
}
.severity-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
&--critical {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
&--high {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
&--medium {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
&--low {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.remediation-content {
padding: 1rem;
}
.remediation-summary {
margin: 0 0 1rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.effort-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.1);
border-radius: 4px;
font-size: 0.8125rem;
color: #94a3b8;
strong {
color: #3b82f6;
}
}
.effort-icon {
font-size: 0.875rem;
}
.remediation-steps {
margin: 0;
padding: 0;
list-style: none;
}
.step {
position: relative;
padding: 1rem;
margin-bottom: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
&:last-child {
margin-bottom: 0;
}
&--automated {
border-color: rgba(34, 197, 94, 0.3);
}
}
.step-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: #334155;
color: #e2e8f0;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.step-title {
flex: 1;
font-weight: 500;
color: #e2e8f0;
}
.automated-badge {
padding: 0.125rem 0.5rem;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
}
.action-type-icon {
font-size: 1rem;
}
.step-description {
margin: 0 0 0.75rem 0;
padding-left: calc(22px + 0.75rem);
color: #94a3b8;
font-size: 0.8125rem;
line-height: 1.5;
}
.step-command {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 0;
margin-left: calc(22px + 0.75rem);
padding: 0.5rem 0.75rem;
background: #111827;
border: 1px solid #1f2933;
border-radius: 4px;
code {
flex: 1;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
color: #22c55e;
word-break: break-all;
}
}
.copy-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
&:hover {
color: #e2e8f0;
}
}
.docs-link {
display: inline-block;
margin-left: calc(22px + 0.75rem);
margin-bottom: 0.5rem;
color: #3b82f6;
font-size: 0.8125rem;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.action-button {
margin-left: calc(22px + 0.75rem);
padding: 0.5rem 1rem;
background: #22c55e;
border: none;
border-radius: 4px;
color: #0f172a;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #16a34a;
}
}
.exception-option {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
background: rgba(147, 51, 234, 0.1);
border: 1px solid rgba(147, 51, 234, 0.2);
border-radius: 6px;
}
.exception-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: #a855f7;
font-size: 0.8125rem;
}
.exception-icon {
flex-shrink: 0;
}
.exception-button {
padding: 0.5rem 1rem;
background: #7c3aed;
border: none;
border-radius: 4px;
color: #f8fafc;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #6d28d9;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemediationHintsComponent {
readonly gate = input.required<PolicyGateResult>();
readonly actionTriggered = output<{ gate: PolicyGateResult; step: RemediationStep }>();
readonly exceptionRequested = output<PolicyGateResult>();
readonly expanded = signal(true); // Default expanded for failed gates
readonly copiedCommand = signal<string | null>(null);
readonly hint = computed<RemediationHint>(() => {
return (
this.gate().remediation ?? {
gateType: this.gate().gateType,
severity: 'medium',
summary: 'No specific remediation steps available.',
steps: [],
exceptionAllowed: false,
}
);
});
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getActionTypeIcon(action: RemediationActionType): string {
const icons: Record<RemediationActionType, string> = {
rebuild: '🔨',
'provide-provenance': '📜',
'sign-artifact': '🔐',
'update-dependency': '📦',
'request-exception': '🛡️',
'manual-review': '👁️',
};
return icons[action] ?? '📋';
}
getActionTypeLabel(action: RemediationActionType): string {
const labels: Record<RemediationActionType, string> = {
rebuild: 'Rebuild required',
'provide-provenance': 'Provide provenance',
'sign-artifact': 'Sign artifact',
'update-dependency': 'Update dependency',
'request-exception': 'Request exception',
'manual-review': 'Manual review',
};
return labels[action] ?? action;
}
getActionButtonLabel(action: RemediationActionType): string {
const labels: Record<RemediationActionType, string> = {
rebuild: 'Trigger Rebuild',
'provide-provenance': 'Upload Provenance',
'sign-artifact': 'Sign Now',
'update-dependency': 'Update',
'request-exception': 'Request',
'manual-review': 'Start Review',
};
return labels[action] ?? 'Execute';
}
async copyCommand(command: string): Promise<void> {
try {
await navigator.clipboard.writeText(command);
this.copiedCommand.set(command);
setTimeout(() => this.copiedCommand.set(null), 2000);
} catch (err) {
console.error('Failed to copy command:', err);
}
}
triggerAction(step: RemediationStep): void {
this.actionTriggered.emit({ gate: this.gate(), step });
}
requestException(): void {
this.exceptionRequested.emit(this.gate());
}
}

View File

@@ -0,0 +1,608 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
DeterminismEvidence,
DeterminismStatus,
FragmentAttestation,
} from '../../core/api/scanner.models';
@Component({
selector: 'app-determinism-badge',
standalone: true,
imports: [CommonModule],
template: `
<div class="determinism-badge" [class]="statusClass()">
<!-- Badge Header (always visible) -->
<button
type="button"
class="determinism-badge__header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
aria-controls="determinism-details"
>
<span class="determinism-badge__icon" aria-hidden="true">
@switch (status()) {
@case ('verified') { &#10003; }
@case ('pending') { &#8987; }
@case ('failed') { &#10007; }
@default { ? }
}
</span>
<span class="determinism-badge__label">
Determinism: {{ statusLabel() }}
</span>
<span class="determinism-badge__toggle" aria-hidden="true">
{{ expanded() ? '&#9650;' : '&#9660;' }}
</span>
</button>
<!-- Expanded Details -->
@if (expanded() && evidence()) {
<div
id="determinism-details"
class="determinism-badge__details"
role="region"
aria-label="Determinism evidence details"
>
<!-- Merkle Root Section -->
<section class="details-section">
<h4 class="details-section__title">Merkle Root</h4>
<div class="merkle-root">
@if (evidence()?.merkleRoot) {
<code class="hash-value">{{ evidence()?.merkleRoot }}</code>
<span
class="consistency-badge"
[class.consistent]="evidence()?.merkleRootConsistent"
[class.inconsistent]="!evidence()?.merkleRootConsistent"
>
{{ evidence()?.merkleRootConsistent ? 'Consistent' : 'Inconsistent' }}
</span>
} @else {
<span class="no-data">No Merkle root available</span>
}
</div>
</section>
<!-- Content Hash Section -->
@if (evidence()?.contentHash) {
<section class="details-section">
<h4 class="details-section__title">Content Hash</h4>
<code class="hash-value">{{ evidence()?.contentHash }}</code>
</section>
}
<!-- Composition Manifest Section -->
@if (evidence()?.compositionManifest; as manifest) {
<section class="details-section">
<h4 class="details-section__title">Composition Manifest</h4>
<dl class="manifest-info">
<dt>URI:</dt>
<dd>
<code class="uri-value">{{ manifest.compositionUri }}</code>
</dd>
<dt>Fragment Count:</dt>
<dd>{{ manifest.fragmentCount }}</dd>
<dt>Created:</dt>
<dd>{{ formatDate(manifest.createdAt) }}</dd>
</dl>
<!-- Fragment Attestations -->
@if (manifest.fragments.length > 0) {
<div class="fragments-section">
<h5 class="fragments-title">
Fragment Attestations ({{ manifest.fragments.length }})
</h5>
<button
type="button"
class="fragments-toggle"
(click)="toggleFragments()"
[attr.aria-expanded]="showFragments()"
>
{{ showFragments() ? 'Hide fragments' : 'Show fragments' }}
</button>
@if (showFragments()) {
<ul class="fragments-list">
@for (fragment of manifest.fragments; track fragment.layerDigest) {
<li class="fragment-item" [class]="getFragmentClass(fragment)">
<div class="fragment-header">
<span
class="fragment-status"
[attr.aria-label]="'DSSE status: ' + fragment.dsseStatus"
>
@switch (fragment.dsseStatus) {
@case ('verified') { &#10003; }
@case ('pending') { &#8987; }
@case ('failed') { &#10007; }
}
</span>
<span class="fragment-layer">
Layer: {{ truncateHash(fragment.layerDigest, 16) }}
</span>
</div>
<div class="fragment-details">
<div class="fragment-row">
<span class="fragment-label">Fragment SHA256:</span>
<code class="fragment-hash">{{ truncateHash(fragment.fragmentSha256, 20) }}</code>
</div>
<div class="fragment-row">
<span class="fragment-label">DSSE Envelope:</span>
<code class="fragment-hash">{{ truncateHash(fragment.dsseEnvelopeSha256, 20) }}</code>
</div>
@if (fragment.verifiedAt) {
<div class="fragment-row">
<span class="fragment-label">Verified:</span>
<span class="fragment-date">{{ formatDate(fragment.verifiedAt) }}</span>
</div>
}
</div>
</li>
}
</ul>
}
</div>
}
</section>
}
<!-- Stella Properties Section -->
@if (evidence()?.stellaProperties) {
<section class="details-section">
<h4 class="details-section__title">Stella Properties</h4>
<dl class="stella-props">
@if (evidence()?.stellaProperties?.['stellaops:stella.contentHash']) {
<dt>stellaops:stella.contentHash</dt>
<dd>
<code>{{ truncateHash(evidence()?.stellaProperties?.['stellaops:stella.contentHash'] ?? '', 24) }}</code>
</dd>
}
@if (evidence()?.stellaProperties?.['stellaops:composition.manifest']) {
<dt>stellaops:composition.manifest</dt>
<dd>
<code>{{ evidence()?.stellaProperties?.['stellaops:composition.manifest'] }}</code>
</dd>
}
@if (evidence()?.stellaProperties?.['stellaops:merkle.root']) {
<dt>stellaops:merkle.root</dt>
<dd>
<code>{{ truncateHash(evidence()?.stellaProperties?.['stellaops:merkle.root'] ?? '', 24) }}</code>
</dd>
}
</dl>
</section>
}
<!-- Verification Info -->
@if (evidence()?.verifiedAt) {
<section class="details-section">
<h4 class="details-section__title">Verification</h4>
<p class="verified-at">
Last verified: {{ formatDate(evidence()?.verifiedAt) }}
</p>
</section>
}
<!-- Failure Reason -->
@if (evidence()?.failureReason) {
<section class="details-section details-section--error">
<h4 class="details-section__title">Failure Reason</h4>
<p class="failure-reason">{{ evidence()?.failureReason }}</p>
</section>
}
</div>
}
</div>
`,
styles: [`
.determinism-badge {
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
background: #fff;
&.status-verified {
border-color: #86efac;
}
&.status-pending {
border-color: #fcd34d;
}
&.status-failed {
border-color: #fca5a5;
}
&.status-unknown {
border-color: #d1d5db;
}
}
.determinism-badge__header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
transition: background-color 0.15s;
&:hover {
background: #f9fafb;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.status-verified & {
background: #f0fdf4;
&:hover { background: #dcfce7; }
}
.status-pending & {
background: #fffbeb;
&:hover { background: #fef3c7; }
}
.status-failed & {
background: #fef2f2;
&:hover { background: #fee2e2; }
}
}
.determinism-badge__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
font-size: 0.875rem;
font-weight: 700;
.status-verified & {
background: #22c55e;
color: #fff;
}
.status-pending & {
background: #f59e0b;
color: #fff;
}
.status-failed & {
background: #ef4444;
color: #fff;
}
.status-unknown & {
background: #6b7280;
color: #fff;
}
}
.determinism-badge__label {
flex: 1;
}
.determinism-badge__toggle {
font-size: 0.75rem;
color: #6b7280;
}
.determinism-badge__details {
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.details-section {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
&--error {
background: #fef2f2;
padding: 0.75rem;
border-radius: 6px;
border: 1px solid #fca5a5;
}
&__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.025em;
}
}
.merkle-root {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.hash-value,
.uri-value {
display: inline-block;
padding: 0.375rem 0.5rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.75rem;
word-break: break-all;
}
.consistency-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.consistent {
background: #dcfce7;
color: #15803d;
}
&.inconsistent {
background: #fee2e2;
color: #dc2626;
}
}
.no-data {
font-size: 0.8125rem;
color: #6b7280;
font-style: italic;
}
.manifest-info,
.stella-props {
margin: 0;
font-size: 0.8125rem;
dt {
color: #6b7280;
margin-top: 0.5rem;
&:first-child {
margin-top: 0;
}
}
dd {
margin: 0.25rem 0 0;
color: #111827;
code {
font-size: 0.75rem;
background: #fff;
padding: 0.125rem 0.375rem;
border: 1px solid #e5e7eb;
border-radius: 2px;
}
}
}
.fragments-section {
margin-top: 0.75rem;
}
.fragments-title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
.fragments-toggle {
padding: 0.25rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
background: #fff;
font-size: 0.75rem;
color: #374151;
cursor: pointer;
&:hover {
background: #f3f4f6;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.fragments-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.fragment-item {
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 0.5rem;
background: #fff;
border: 1px solid #e5e7eb;
&:last-child {
margin-bottom: 0;
}
&.fragment-verified {
border-color: #86efac;
}
&.fragment-pending {
border-color: #fcd34d;
}
&.fragment-failed {
border-color: #fca5a5;
}
}
.fragment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.fragment-status {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
.fragment-verified & {
background: #22c55e;
color: #fff;
}
.fragment-pending & {
background: #f59e0b;
color: #fff;
}
.fragment-failed & {
background: #ef4444;
color: #fff;
}
}
.fragment-layer {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
.fragment-details {
padding-left: 1.75rem;
}
.fragment-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
&:last-child {
margin-bottom: 0;
}
}
.fragment-label {
color: #6b7280;
white-space: nowrap;
}
.fragment-hash {
font-family: 'Monaco', 'Consolas', monospace;
background: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
.fragment-date {
color: #374151;
}
.verified-at {
margin: 0;
font-size: 0.8125rem;
color: #374151;
}
.failure-reason {
margin: 0;
font-size: 0.8125rem;
color: #dc2626;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeterminismBadgeComponent {
readonly evidence = input<DeterminismEvidence | null>(null);
readonly expanded = signal(false);
readonly showFragments = signal(false);
readonly status = computed<DeterminismStatus>(() => {
return this.evidence()?.status ?? 'unknown';
});
readonly statusClass = computed(() => {
return `status-${this.status()}`;
});
readonly statusLabel = computed(() => {
switch (this.status()) {
case 'verified':
return 'Verified';
case 'pending':
return 'Pending';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
});
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
toggleFragments(): void {
this.showFragments.update((v) => !v);
}
getFragmentClass(fragment: FragmentAttestation): string {
return `fragment-${fragment.dsseStatus}`;
}
formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'N/A';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
truncateHash(hash: string, length: number): string {
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
}

View File

@@ -0,0 +1,950 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
EntropyEvidence,
EntropyFile,
EntropyLayerSummary,
EntropyWindow,
} from '../../core/api/scanner.models';
type ViewMode = 'summary' | 'layers' | 'files';
@Component({
selector: 'app-entropy-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="entropy-panel">
<!-- Header with Summary -->
<header class="entropy-panel__header">
<div class="entropy-panel__title-row">
<h3 class="entropy-panel__title">Entropy Analysis</h3>
@if (downloadUrl()) {
<a
[href]="downloadUrl()"
class="entropy-panel__download"
download="entropy.report.json"
aria-label="Download entropy report"
>
Download Report
</a>
}
</div>
<!-- Overall Stats -->
@if (layerSummary(); as summary) {
<div class="entropy-panel__stats">
<div class="stat-card" [class]="getPenaltyClass(summary.entropyPenalty)">
<span class="stat-label">Entropy Penalty</span>
<span class="stat-value">{{ (summary.entropyPenalty * 100).toFixed(1) }}%</span>
<span class="stat-hint">max 30%</span>
</div>
<div class="stat-card" [class]="getRatioClass(summary.imageOpaqueRatio)">
<span class="stat-label">Image Opaque Ratio</span>
<span class="stat-value">{{ (summary.imageOpaqueRatio * 100).toFixed(1) }}%</span>
<span class="stat-hint">of total bytes</span>
</div>
<div class="stat-card">
<span class="stat-label">Layers Analyzed</span>
<span class="stat-value">{{ summary.layers.length }}</span>
</div>
</div>
}
</header>
<!-- View Toggle -->
<nav class="entropy-panel__nav" role="tablist">
<button
type="button"
role="tab"
class="nav-tab"
[class.active]="viewMode() === 'summary'"
[attr.aria-selected]="viewMode() === 'summary'"
(click)="setViewMode('summary')"
>
Summary
</button>
<button
type="button"
role="tab"
class="nav-tab"
[class.active]="viewMode() === 'layers'"
[attr.aria-selected]="viewMode() === 'layers'"
(click)="setViewMode('layers')"
>
Layers
</button>
<button
type="button"
role="tab"
class="nav-tab"
[class.active]="viewMode() === 'files'"
[attr.aria-selected]="viewMode() === 'files'"
(click)="setViewMode('files')"
>
Files
</button>
</nav>
<!-- Content -->
<div class="entropy-panel__content">
<!-- Summary View -->
@if (viewMode() === 'summary') {
<section class="summary-view" role="tabpanel">
<!-- Layer Donut Chart -->
@if (layerSummary()?.layers?.length) {
<div class="donut-section">
<h4>Layer Distribution</h4>
<div class="donut-chart" role="img" aria-label="Layer opaque ratio distribution">
<svg viewBox="0 0 100 100" class="donut-svg">
@for (segment of donutSegments(); track segment.digest; let i = $index) {
<circle
class="donut-segment"
[attr.cx]="50"
[attr.cy]="50"
[attr.r]="40"
fill="none"
[attr.stroke]="segment.color"
stroke-width="15"
[attr.stroke-dasharray]="segment.dasharray"
[attr.stroke-dashoffset]="segment.dashoffset"
[attr.transform]="'rotate(-90 50 50)'"
>
<title>{{ segment.label }}: {{ (segment.ratio * 100).toFixed(1) }}% opaque</title>
</circle>
}
<text x="50" y="50" class="donut-center-text" text-anchor="middle" dominant-baseline="middle">
{{ (layerSummary()?.imageOpaqueRatio ?? 0) * 100 | number:'1.0-0' }}%
</text>
<text x="50" y="62" class="donut-center-label" text-anchor="middle">
opaque
</text>
</svg>
</div>
<ul class="donut-legend">
@for (segment of donutSegments(); track segment.digest) {
<li class="legend-item">
<span class="legend-color" [style.background]="segment.color"></span>
<span class="legend-label">{{ truncateHash(segment.digest, 12) }}</span>
<span class="legend-value">{{ (segment.ratio * 100).toFixed(1) }}%</span>
</li>
}
</ul>
</div>
}
<!-- Risk Indicators -->
@if (allIndicators().length > 0) {
<div class="indicators-section">
<h4>Why Risky?</h4>
<div class="risk-chips">
@for (indicator of allIndicators(); track indicator.name) {
<span
class="risk-chip"
[class]="'risk-chip--' + indicator.severity"
[attr.title]="indicator.description"
>
<span class="chip-icon" aria-hidden="true">{{ indicator.icon }}</span>
{{ indicator.name }}
@if (indicator.count > 1) {
<span class="chip-count">({{ indicator.count }})</span>
}
</span>
}
</div>
</div>
}
</section>
}
<!-- Layers View -->
@if (viewMode() === 'layers') {
<section class="layers-view" role="tabpanel">
@if (layerSummary()?.layers?.length) {
<ul class="layer-list">
@for (layer of layerSummary()?.layers ?? []; track layer.digest) {
<li class="layer-item">
<div class="layer-header">
<code class="layer-digest">{{ truncateHash(layer.digest, 20) }}</code>
<span class="layer-ratio" [class]="getRatioClass(layer.opaqueRatio)">
{{ (layer.opaqueRatio * 100).toFixed(1) }}% opaque
</span>
</div>
<div class="layer-bar-container">
<div
class="layer-bar"
[style.width.%]="layer.opaqueRatio * 100"
[class]="getRatioClass(layer.opaqueRatio)"
></div>
</div>
<div class="layer-details">
<span class="layer-bytes">
{{ formatBytes(layer.opaqueBytes) }} / {{ formatBytes(layer.totalBytes) }}
</span>
@if (layer.indicators.length > 0) {
<div class="layer-indicators">
@for (ind of layer.indicators; track ind) {
<span class="indicator-tag">{{ ind }}</span>
}
</div>
}
</div>
</li>
}
</ul>
} @else {
<p class="empty-message">No layer entropy data available.</p>
}
</section>
}
<!-- Files View -->
@if (viewMode() === 'files') {
<section class="files-view" role="tabpanel">
@if (report()?.files?.length) {
<ul class="file-list">
@for (file of report()?.files ?? []; track file.path) {
<li class="file-item" [class.expanded]="expandedFile() === file.path">
<button
type="button"
class="file-header"
(click)="toggleFileExpanded(file.path)"
[attr.aria-expanded]="expandedFile() === file.path"
>
<span class="file-path">{{ file.path }}</span>
<span class="file-ratio" [class]="getRatioClass(file.opaqueRatio)">
{{ (file.opaqueRatio * 100).toFixed(1) }}%
</span>
</button>
<!-- Entropy Heatmap -->
<div class="file-heatmap" aria-label="Entropy heatmap for {{ file.path }}">
@for (window of file.windows; track window.offset) {
<div
class="heatmap-cell"
[style.background]="getEntropyColor(window.entropy)"
[attr.title]="'Offset: ' + window.offset + ', Entropy: ' + window.entropy.toFixed(2)"
></div>
}
</div>
@if (expandedFile() === file.path) {
<div class="file-details">
<dl class="file-meta">
<dt>Size:</dt>
<dd>{{ formatBytes(file.size) }}</dd>
<dt>Opaque bytes:</dt>
<dd>{{ formatBytes(file.opaqueBytes) }}</dd>
<dt>Opaque ratio:</dt>
<dd>{{ (file.opaqueRatio * 100).toFixed(2) }}%</dd>
</dl>
@if (file.flags.length > 0) {
<div class="file-flags">
<strong>Flags:</strong>
@for (flag of file.flags; track flag) {
<span class="flag-tag">{{ flag }}</span>
}
</div>
}
@if (file.windows.length > 0) {
<div class="file-windows">
<strong>High-entropy windows ({{ file.windows.length }}):</strong>
<table class="windows-table">
<thead>
<tr>
<th>Offset</th>
<th>Length</th>
<th>Entropy</th>
</tr>
</thead>
<tbody>
@for (w of file.windows.slice(0, 10); track w.offset) {
<tr>
<td>{{ w.offset }}</td>
<td>{{ w.length }}</td>
<td [class]="getEntropyClass(w.entropy)">{{ w.entropy.toFixed(3) }}</td>
</tr>
}
</tbody>
</table>
@if (file.windows.length > 10) {
<p class="more-windows">+ {{ file.windows.length - 10 }} more windows</p>
}
</div>
}
</div>
}
</li>
}
</ul>
} @else {
<p class="empty-message">No file entropy data available.</p>
}
</section>
}
</div>
</div>
`,
styles: [`
.entropy-panel {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.entropy-panel__header {
padding: 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.entropy-panel__title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.entropy-panel__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #111827;
}
.entropy-panel__download {
padding: 0.375rem 0.75rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #fff;
color: #3b82f6;
font-size: 0.8125rem;
text-decoration: none;
&:hover {
background: #eff6ff;
}
}
.entropy-panel__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.stat-card {
padding: 0.75rem;
border-radius: 6px;
background: #fff;
border: 1px solid #e5e7eb;
text-align: center;
&.severity-high {
border-color: #fca5a5;
background: #fef2f2;
}
&.severity-medium {
border-color: #fcd34d;
background: #fffbeb;
}
&.severity-low {
border-color: #86efac;
background: #f0fdf4;
}
}
.stat-label {
display: block;
font-size: 0.6875rem;
text-transform: uppercase;
color: #6b7280;
letter-spacing: 0.025em;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #111827;
margin: 0.25rem 0;
}
.stat-hint {
display: block;
font-size: 0.6875rem;
color: #9ca3af;
}
.entropy-panel__nav {
display: flex;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.nav-tab {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
&:hover {
color: #374151;
}
&.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
}
.entropy-panel__content {
padding: 1rem;
}
// Donut Chart
.donut-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
}
.donut-chart {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.donut-svg {
width: 150px;
height: 150px;
}
.donut-center-text {
font-size: 16px;
font-weight: 700;
fill: #111827;
}
.donut-center-label {
font-size: 8px;
fill: #6b7280;
}
.donut-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-label {
flex: 1;
font-family: monospace;
color: #374151;
}
.legend-value {
font-weight: 500;
color: #111827;
}
// Risk Chips
.indicators-section {
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
}
.risk-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.risk-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
&--high {
background: #fee2e2;
color: #dc2626;
}
&--medium {
background: #fef3c7;
color: #d97706;
}
&--low {
background: #e5e7eb;
color: #4b5563;
}
}
.chip-icon {
font-size: 0.875rem;
}
.chip-count {
font-size: 0.75rem;
opacity: 0.8;
}
// Layers View
.layer-list {
list-style: none;
margin: 0;
padding: 0;
}
.layer-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.layer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.layer-digest {
font-size: 0.75rem;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 2px;
}
.layer-ratio {
font-size: 0.8125rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 4px;
&.severity-high {
background: #fee2e2;
color: #dc2626;
}
&.severity-medium {
background: #fef3c7;
color: #d97706;
}
&.severity-low {
background: #dcfce7;
color: #15803d;
}
}
.layer-bar-container {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.layer-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
&.severity-high {
background: #ef4444;
}
&.severity-medium {
background: #f59e0b;
}
&.severity-low {
background: #22c55e;
}
}
.layer-details {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.layer-bytes {
font-size: 0.75rem;
color: #6b7280;
}
.layer-indicators {
display: flex;
gap: 0.25rem;
}
.indicator-tag {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: #f3f4f6;
border-radius: 2px;
color: #4b5563;
}
// Files View
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-item {
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
&.expanded {
border-color: #3b82f6;
}
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.75rem;
border: none;
background: #f9fafb;
cursor: pointer;
text-align: left;
&:hover {
background: #f3f4f6;
}
}
.file-path {
font-family: monospace;
font-size: 0.8125rem;
color: #374151;
word-break: break-all;
}
.file-ratio {
font-size: 0.8125rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 4px;
margin-left: 0.5rem;
flex-shrink: 0;
&.severity-high {
background: #fee2e2;
color: #dc2626;
}
&.severity-medium {
background: #fef3c7;
color: #d97706;
}
&.severity-low {
background: #dcfce7;
color: #15803d;
}
}
.file-heatmap {
display: flex;
height: 8px;
background: #e5e7eb;
}
.heatmap-cell {
flex: 1;
min-width: 2px;
}
.file-details {
padding: 0.75rem;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.file-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0 0 0.75rem;
font-size: 0.8125rem;
dt {
color: #6b7280;
}
dd {
margin: 0;
color: #111827;
}
}
.file-flags {
margin-bottom: 0.75rem;
font-size: 0.8125rem;
strong {
color: #374151;
margin-right: 0.5rem;
}
}
.flag-tag {
display: inline-block;
margin-right: 0.25rem;
padding: 0.125rem 0.375rem;
background: #fef3c7;
border-radius: 2px;
font-size: 0.75rem;
color: #92400e;
}
.file-windows {
font-size: 0.8125rem;
strong {
display: block;
color: #374151;
margin-bottom: 0.5rem;
}
}
.windows-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
th, td {
padding: 0.375rem 0.5rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background: #f9fafb;
font-weight: 500;
color: #6b7280;
}
td {
font-family: monospace;
}
}
.more-windows {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
.empty-message {
text-align: center;
color: #6b7280;
font-style: italic;
padding: 2rem;
}
.severity-high {
color: #dc2626;
}
.severity-medium {
color: #d97706;
}
.severity-low {
color: #15803d;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPanelComponent {
readonly evidence = input<EntropyEvidence | null>(null);
readonly download = output<void>();
readonly viewMode = signal<ViewMode>('summary');
readonly expandedFile = signal<string | null>(null);
readonly report = computed(() => this.evidence()?.report ?? null);
readonly layerSummary = computed(() => this.evidence()?.layerSummary ?? null);
readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null);
// Compute donut segments for layer visualization
readonly donutSegments = computed(() => {
const summary = this.layerSummary();
if (!summary?.layers?.length) return [];
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'];
const circumference = 2 * Math.PI * 40;
let offset = 0;
return summary.layers.map((layer, i) => {
const ratio = layer.totalBytes / summary.layers.reduce((sum, l) => sum + l.totalBytes, 0);
const length = circumference * ratio;
const segment = {
digest: layer.digest,
ratio: layer.opaqueRatio,
color: colors[i % colors.length],
dasharray: `${length} ${circumference - length}`,
dashoffset: -offset,
label: `Layer ${i + 1}`,
};
offset += length;
return segment;
});
});
// Aggregate all indicators across layers
readonly allIndicators = computed(() => {
const summary = this.layerSummary();
if (!summary?.layers?.length) return [];
const indicatorMap = new Map<string, { name: string; count: number; severity: string; description: string; icon: string }>();
const indicatorMeta: Record<string, { severity: string; description: string; icon: string }> = {
'packed': { severity: 'high', description: 'File appears to be packed/compressed', icon: '!' },
'no-symbols': { severity: 'medium', description: 'No debug symbols present', icon: '?' },
'stripped': { severity: 'medium', description: 'Binary has been stripped', icon: '-' },
'section:.UPX0': { severity: 'high', description: 'UPX packer detected', icon: '!' },
'section:.UPX1': { severity: 'high', description: 'UPX packer detected', icon: '!' },
'section:.aspack': { severity: 'high', description: 'ASPack packer detected', icon: '!' },
};
for (const layer of summary.layers) {
for (const ind of layer.indicators) {
const existing = indicatorMap.get(ind);
if (existing) {
existing.count++;
} else {
const meta = indicatorMeta[ind] ?? { severity: 'low', description: ind, icon: '*' };
indicatorMap.set(ind, { name: ind, count: 1, ...meta });
}
}
}
return Array.from(indicatorMap.values()).sort((a, b) => {
const severityOrder = { high: 0, medium: 1, low: 2 };
return (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) -
(severityOrder[b.severity as keyof typeof severityOrder] ?? 3);
});
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleFileExpanded(path: string): void {
const current = this.expandedFile();
this.expandedFile.set(current === path ? null : path);
}
getPenaltyClass(penalty: number): string {
if (penalty >= 0.2) return 'severity-high';
if (penalty >= 0.1) return 'severity-medium';
return 'severity-low';
}
getRatioClass(ratio: number): string {
if (ratio >= 0.3) return 'severity-high';
if (ratio >= 0.15) return 'severity-medium';
return 'severity-low';
}
getEntropyClass(entropy: number): string {
if (entropy >= 7.5) return 'severity-high';
if (entropy >= 7.0) return 'severity-medium';
return 'severity-low';
}
getEntropyColor(entropy: number): string {
// Map entropy (0-8) to color (green -> yellow -> red)
const normalized = Math.min(entropy / 8, 1);
if (normalized < 0.5) {
// Green to Yellow
const g = Math.round(255);
const r = Math.round(normalized * 2 * 255);
return `rgb(${r}, ${g}, 0)`;
} else {
// Yellow to Red
const r = 255;
const g = Math.round((1 - (normalized - 0.5) * 2) * 255);
return `rgb(${r}, ${g}, 0)`;
}
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
truncateHash(hash: string, length: number): string {
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
}

View File

@@ -0,0 +1,659 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import { EntropyEvidence } from '../../core/api/scanner.models';
export type PolicyDecisionKind = 'pass' | 'warn' | 'block';
export interface EntropyPolicyThresholds {
readonly blockImageOpaqueRatio: number; // Default 0.15
readonly warnFileOpaqueRatio: number; // Default 0.30
readonly maxEntropyPenalty: number; // Default 0.30
}
export interface EntropyPolicyResult {
readonly decision: PolicyDecisionKind;
readonly reasons: readonly string[];
readonly mitigations: readonly string[];
readonly thresholds: EntropyPolicyThresholds;
}
const DEFAULT_THRESHOLDS: EntropyPolicyThresholds = {
blockImageOpaqueRatio: 0.15,
warnFileOpaqueRatio: 0.30,
maxEntropyPenalty: 0.30,
};
@Component({
selector: 'app-entropy-policy-banner',
standalone: true,
imports: [CommonModule],
template: `
<div
class="entropy-policy-banner"
[class]="bannerClass()"
role="alert"
[attr.aria-live]="policyResult().decision === 'block' ? 'assertive' : 'polite'"
>
<!-- Banner Header -->
<header class="banner-header">
<span class="banner-icon" aria-hidden="true">
@switch (policyResult().decision) {
@case ('block') { &#9888; }
@case ('warn') { &#9888; }
@case ('pass') { &#10003; }
}
</span>
<h4 class="banner-title">{{ bannerTitle() }}</h4>
<button
type="button"
class="banner-toggle"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
aria-controls="banner-details"
>
{{ expanded() ? 'Hide details' : 'Show details' }}
</button>
</header>
<!-- Banner Summary -->
<p class="banner-summary">{{ bannerSummary() }}</p>
<!-- Expanded Details -->
@if (expanded()) {
<div id="banner-details" class="banner-details">
<!-- Reasons -->
@if (policyResult().reasons.length > 0) {
<section class="details-section">
<h5>Why {{ policyResult().decision === 'block' ? 'Blocked' : policyResult().decision === 'warn' ? 'Warning' : 'Passed' }}</h5>
<ul class="reason-list">
@for (reason of policyResult().reasons; track reason) {
<li>{{ reason }}</li>
}
</ul>
</section>
}
<!-- Thresholds -->
<section class="details-section">
<h5>Policy Thresholds</h5>
<dl class="threshold-list">
<dt>Block when image opaque ratio exceeds:</dt>
<dd>
<span
class="threshold-value"
[class.exceeded]="isBlockThresholdExceeded()"
>
{{ (policyResult().thresholds.blockImageOpaqueRatio * 100).toFixed(0) }}%
</span>
@if (evidence()?.layerSummary?.imageOpaqueRatio !== undefined) {
<span class="current-value">
(current: {{ (evidence()!.layerSummary!.imageOpaqueRatio * 100).toFixed(1) }}%)
</span>
}
</dd>
<dt>Warn when any file opaque ratio exceeds:</dt>
<dd>
<span
class="threshold-value"
[class.exceeded]="isWarnThresholdExceeded()"
>
{{ (policyResult().thresholds.warnFileOpaqueRatio * 100).toFixed(0) }}%
</span>
@if (maxFileOpaqueRatio() !== null) {
<span class="current-value">
(max file: {{ (maxFileOpaqueRatio()! * 100).toFixed(1) }}%)
</span>
}
</dd>
<dt>Maximum entropy penalty:</dt>
<dd>
<span class="threshold-value">
{{ (policyResult().thresholds.maxEntropyPenalty * 100).toFixed(0) }}%
</span>
@if (evidence()?.layerSummary?.entropyPenalty !== undefined) {
<span class="current-value">
(current: {{ (evidence()!.layerSummary!.entropyPenalty * 100).toFixed(1) }}%)
</span>
}
</dd>
</dl>
</section>
<!-- Mitigations -->
@if (policyResult().mitigations.length > 0) {
<section class="details-section">
<h5>Recommended Actions</h5>
<ol class="mitigation-list">
@for (mitigation of policyResult().mitigations; track mitigation) {
<li>{{ mitigation }}</li>
}
</ol>
</section>
}
<!-- Download Link -->
@if (downloadUrl()) {
<section class="details-section">
<h5>Evidence</h5>
<a
[href]="downloadUrl()"
class="evidence-download"
download="entropy.report.json"
>
<span class="download-icon" aria-hidden="true">&#8595;</span>
Download entropy.report.json
</a>
<p class="evidence-hint">
Use this file for offline audits and to verify entropy calculations.
</p>
</section>
}
<!-- Suppression Info -->
<section class="details-section details-section--info">
<h5>Suppression Options</h5>
<p class="suppression-info">
Entropy penalties can be suppressed when:
</p>
<ul class="suppression-list">
<li>Debug symbols are present <strong>and</strong> provenance is attested</li>
<li>An explicit policy waiver is configured for the tenant</li>
<li>The package has verified provenance from a trusted source</li>
</ul>
</section>
</div>
}
<!-- Tooltip Trigger -->
<div class="banner-tooltip-container">
<button
type="button"
class="tooltip-trigger"
(mouseenter)="showTooltip.set(true)"
(mouseleave)="showTooltip.set(false)"
(focus)="showTooltip.set(true)"
(blur)="showTooltip.set(false)"
aria-describedby="entropy-tooltip"
>
<span aria-hidden="true">?</span>
<span class="sr-only">More information about entropy policy</span>
</button>
@if (showTooltip()) {
<div
id="entropy-tooltip"
class="tooltip"
role="tooltip"
>
<p><strong>What is entropy analysis?</strong></p>
<p>
Entropy measures randomness in binary data. High entropy (>7.2 bits/byte)
often indicates compressed, encrypted, or packed code that is difficult to audit.
</p>
<p>
Opaque regions without provenance cannot be whitelisted without an explicit
policy waiver.
</p>
</div>
}
</div>
</div>
`,
styles: [`
.entropy-policy-banner {
position: relative;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
&.decision-pass {
background: #f0fdf4;
border: 1px solid #86efac;
}
&.decision-warn {
background: #fffbeb;
border: 1px solid #fcd34d;
}
&.decision-block {
background: #fef2f2;
border: 1px solid #fca5a5;
}
}
.banner-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.banner-icon {
font-size: 1.25rem;
.decision-pass & {
color: #22c55e;
}
.decision-warn & {
color: #f59e0b;
}
.decision-block & {
color: #ef4444;
}
}
.banner-title {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
.decision-pass & {
color: #15803d;
}
.decision-warn & {
color: #92400e;
}
.decision-block & {
color: #dc2626;
}
}
.banner-toggle {
padding: 0.25rem 0.5rem;
border: 1px solid currentColor;
border-radius: 4px;
background: transparent;
font-size: 0.75rem;
cursor: pointer;
opacity: 0.8;
&:hover {
opacity: 1;
}
.decision-pass & {
color: #15803d;
}
.decision-warn & {
color: #92400e;
}
.decision-block & {
color: #dc2626;
}
}
.banner-summary {
margin: 0;
font-size: 0.875rem;
.decision-pass & {
color: #166534;
}
.decision-warn & {
color: #78350f;
}
.decision-block & {
color: #991b1b;
}
}
.banner-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid currentColor;
opacity: 0.3;
.decision-pass & {
border-color: #86efac;
}
.decision-warn & {
border-color: #fcd34d;
}
.decision-block & {
border-color: #fca5a5;
}
}
.details-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
&--info {
background: rgba(255, 255, 255, 0.5);
padding: 0.75rem;
border-radius: 6px;
}
h5 {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.025em;
}
}
.reason-list,
.mitigation-list,
.suppression-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: #374151;
li {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.mitigation-list {
list-style: decimal;
}
.threshold-list {
margin: 0;
font-size: 0.8125rem;
dt {
color: #6b7280;
margin-top: 0.5rem;
&:first-child {
margin-top: 0;
}
}
dd {
margin: 0.25rem 0 0;
color: #111827;
}
}
.threshold-value {
font-weight: 600;
padding: 0.125rem 0.375rem;
background: #e5e7eb;
border-radius: 4px;
&.exceeded {
background: #fee2e2;
color: #dc2626;
}
}
.current-value {
font-size: 0.75rem;
color: #6b7280;
margin-left: 0.5rem;
}
.suppression-info {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: #374151;
}
.evidence-download {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #fff;
color: #3b82f6;
font-size: 0.8125rem;
text-decoration: none;
cursor: pointer;
&:hover {
background: #eff6ff;
}
.download-icon {
font-size: 1rem;
}
}
.evidence-hint {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #6b7280;
}
// Tooltip
.banner-tooltip-container {
position: absolute;
top: 1rem;
right: 1rem;
}
.tooltip-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid #d1d5db;
border-radius: 50%;
background: #fff;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
cursor: help;
&:hover,
&:focus {
border-color: #3b82f6;
color: #3b82f6;
}
}
.tooltip {
position: absolute;
top: 100%;
right: 0;
width: 280px;
margin-top: 0.5rem;
padding: 0.75rem;
background: #1f2937;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
z-index: 10;
p {
margin: 0 0 0.5rem;
font-size: 0.75rem;
color: #e5e7eb;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
strong {
color: #fff;
}
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPolicyBannerComponent {
readonly evidence = input<EntropyEvidence | null>(null);
readonly customThresholds = input<Partial<EntropyPolicyThresholds>>({});
readonly expanded = signal(false);
readonly showTooltip = signal(false);
readonly thresholds = computed<EntropyPolicyThresholds>(() => ({
...DEFAULT_THRESHOLDS,
...this.customThresholds(),
}));
readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null);
readonly maxFileOpaqueRatio = computed(() => {
const report = this.evidence()?.report;
if (!report?.files?.length) return null;
return Math.max(...report.files.map(f => f.opaqueRatio));
});
readonly policyResult = computed<EntropyPolicyResult>(() => {
const ev = this.evidence();
const thresholds = this.thresholds();
const reasons: string[] = [];
const mitigations: string[] = [];
let decision: PolicyDecisionKind = 'pass';
if (!ev?.layerSummary) {
return { decision: 'pass', reasons: ['No entropy data available'], mitigations: [], thresholds };
}
const summary = ev.layerSummary;
const report = ev.report;
// Check block condition: imageOpaqueRatio > threshold AND provenance unknown
if (summary.imageOpaqueRatio > thresholds.blockImageOpaqueRatio) {
decision = 'block';
reasons.push(
`Image opaque ratio (${(summary.imageOpaqueRatio * 100).toFixed(1)}%) exceeds ` +
`block threshold (${(thresholds.blockImageOpaqueRatio * 100).toFixed(0)}%)`
);
mitigations.push('Provide attestation of provenance for opaque binaries');
mitigations.push('Unpack or decompress packed executables before scanning');
}
// Check warn condition: any file with opaqueRatio > threshold
if (report?.files) {
const highOpaqueFiles = report.files.filter(f => f.opaqueRatio > thresholds.warnFileOpaqueRatio);
if (highOpaqueFiles.length > 0) {
if (decision !== 'block') {
decision = 'warn';
}
reasons.push(
`${highOpaqueFiles.length} file(s) exceed warn threshold ` +
`(${(thresholds.warnFileOpaqueRatio * 100).toFixed(0)}% opaque)`
);
mitigations.push('Review high-entropy files for packed or obfuscated code');
mitigations.push('Include debug symbols in builds where possible');
}
}
// Check for packed indicators
const packedLayers = summary.layers.filter(l =>
l.indicators.some(i => i === 'packed' || i.startsWith('section:.UPX'))
);
if (packedLayers.length > 0) {
if (decision !== 'block') {
decision = 'warn';
}
reasons.push(`${packedLayers.length} layer(s) contain packed or compressed binaries`);
mitigations.push('Use uncompressed binaries or provide packer provenance');
}
// Check for stripped binaries without symbols
const strippedLayers = summary.layers.filter(l =>
l.indicators.some(i => i === 'stripped' || i === 'no-symbols')
);
if (strippedLayers.length > 0 && decision === 'pass') {
reasons.push(`${strippedLayers.length} layer(s) contain stripped binaries without symbols`);
// Only add mitigation if not already present
if (!mitigations.includes('Include debug symbols in builds where possible')) {
mitigations.push('Include debug symbols in builds where possible');
}
}
// Default pass reasons
if (decision === 'pass' && reasons.length === 0) {
reasons.push('All entropy metrics within acceptable thresholds');
reasons.push(`Entropy penalty (${(summary.entropyPenalty * 100).toFixed(1)}%) is low`);
}
return { decision, reasons, mitigations, thresholds };
});
readonly bannerClass = computed(() => `decision-${this.policyResult().decision}`);
readonly bannerTitle = computed(() => {
switch (this.policyResult().decision) {
case 'block':
return 'Entropy Policy: Blocked';
case 'warn':
return 'Entropy Policy: Warning';
case 'pass':
return 'Entropy Policy: Passed';
}
});
readonly bannerSummary = computed(() => {
const result = this.policyResult();
const ev = this.evidence();
switch (result.decision) {
case 'block':
return `This image is blocked due to high entropy/opaque content. ` +
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
case 'warn':
return `This image has elevated entropy metrics that may indicate packed or obfuscated code. ` +
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
case 'pass':
return `This image has acceptable entropy metrics. ` +
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
}
});
isBlockThresholdExceeded(): boolean {
const ratio = this.evidence()?.layerSummary?.imageOpaqueRatio;
if (ratio === undefined) return false;
return ratio > this.thresholds().blockImageOpaqueRatio;
}
isWarnThresholdExceeded(): boolean {
const maxRatio = this.maxFileOpaqueRatio();
if (maxRatio === null) return false;
return maxRatio > this.thresholds().warnFileOpaqueRatio;
}
toggleExpanded(): void {
this.expanded.update(v => !v);
}
}

View File

@@ -49,4 +49,31 @@
<p *ngIf="!scan().attestation" class="attestation-empty">
No attestation has been recorded for this scan.
</p>
<!-- Determinism Evidence Section -->
<section class="determinism-section">
<h2>SBOM Determinism</h2>
@if (scan().determinism) {
<app-determinism-badge [evidence]="scan().determinism" />
} @else {
<p class="determinism-empty">
No determinism evidence available for this scan.
</p>
}
</section>
<!-- Entropy Analysis Section -->
<section class="entropy-section">
<h2>Entropy Analysis</h2>
@if (scan().entropy) {
<!-- Policy Banner with thresholds and mitigations -->
<app-entropy-policy-banner [evidence]="scan().entropy" />
<!-- Detailed entropy visualization -->
<app-entropy-panel [evidence]="scan().entropy" />
} @else {
<p class="entropy-empty">
No entropy analysis available for this scan.
</p>
}
</section>
</section>

View File

@@ -77,3 +77,43 @@
font-style: italic;
color: #94a3b8;
}
// Determinism Section
.determinism-section {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: #e2e8f0;
}
}
.determinism-empty {
font-style: italic;
color: #94a3b8;
margin: 0;
}
// Entropy Section
.entropy-section {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: #e2e8f0;
}
}
.entropy-empty {
font-style: italic;
color: #94a3b8;
margin: 0;
}

View File

@@ -8,6 +8,9 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
import { DeterminismBadgeComponent } from './determinism-badge.component';
import { EntropyPanelComponent } from './entropy-panel.component';
import { EntropyPolicyBannerComponent } from './entropy-policy-banner.component';
import { ScanDetail } from '../../core/api/scanner.models';
import {
scanDetailWithFailedAttestation,
@@ -24,7 +27,7 @@ const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
@Component({
selector: 'app-scan-detail-page',
standalone: true,
imports: [CommonModule, ScanAttestationPanelComponent],
imports: [CommonModule, ScanAttestationPanelComponent, DeterminismBadgeComponent, EntropyPanelComponent, EntropyPolicyBannerComponent],
templateUrl: './scan-detail-page.component.html',
styleUrls: ['./scan-detail-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,296 @@
<section class="aoc-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
</header>
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading dashboard...</p>
</div>
}
@if (!loading() && dashboard()) {
<!-- Top Tiles Row -->
<div class="tiles-row">
<!-- Pass/Fail Tile -->
<article class="tile tile--pass-fail">
<header class="tile__header">
<h2>AOC Pass Rate</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="pass-rate-display">
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
<span class="pass-rate-trend" [ngClass]="trendClass()">
{{ trendIcon() }}
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
</span>
</div>
<div class="pass-fail-stats">
<div class="stat stat--passed">
<span class="stat-label">Passed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
</div>
<div class="stat stat--failed">
<span class="stat-label">Failed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
</div>
<div class="stat stat--pending">
<span class="stat-label">Pending</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
</div>
</div>
<!-- Mini Chart -->
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
@for (point of chartData(); track point.timestamp) {
<div
class="chart-bar"
[style.height.%]="point.height"
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
></div>
}
</div>
</div>
</article>
<!-- Critical Violations Tile -->
<article class="tile tile--violations">
<header class="tile__header">
<h2>Recent Violations</h2>
@if (criticalViolations() > 0) {
<span class="critical-badge">{{ criticalViolations() }} critical</span>
}
</header>
<div class="tile__content">
<ul class="violations-list">
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
<li
class="violation-item"
(click)="selectViolation(violation)"
(keydown.enter)="selectViolation(violation)"
tabindex="0"
role="button"
>
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
{{ violation.severity | uppercase }}
</span>
<span class="violation-code">{{ violation.code }}</span>
<span class="violation-name">{{ violation.name }}</span>
<span class="violation-count">{{ violation.count }}</span>
</li>
} @empty {
<li class="no-violations">No recent violations</li>
}
</ul>
</div>
</article>
<!-- Ingest Throughput Tile -->
<article class="tile tile--throughput">
<header class="tile__header">
<h2>Ingest Throughput</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="throughput-summary">
<div class="throughput-stat">
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
<span class="throughput-label">Documents</span>
</div>
<div class="throughput-stat">
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
<span class="throughput-label">Total Size</span>
</div>
</div>
<table class="throughput-table">
<thead>
<tr>
<th>Tenant</th>
<th>Docs</th>
<th>Rate</th>
</tr>
</thead>
<tbody>
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
<tr>
<td>{{ tenant.tenantName }}</td>
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
</tr>
}
</tbody>
</table>
</div>
</article>
</div>
<!-- Sources Section -->
<section class="sources-section">
<header class="section-header">
<h2>Sources</h2>
<button
type="button"
class="verify-button"
(click)="startVerification()"
[disabled]="verifying()"
>
@if (verifying()) {
<span class="spinner-small"></span>
Verifying...
} @else {
Verify Last 24h
}
</button>
</header>
<!-- Verification Result -->
@if (verificationRequest()) {
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
<div class="verification-header">
<span class="verification-status">
@if (verificationRequest()!.status === 'completed') {
Verification Complete
} @else if (verificationRequest()!.status === 'running') {
Verification Running...
} @else {
Verification {{ verificationRequest()!.status | titlecase }}
}
</span>
@if (verificationRequest()!.completedAt) {
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
}
</div>
@if (verificationRequest()!.status === 'completed') {
<div class="verification-stats">
<div class="verification-stat verification-stat--passed">
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="verification-stat verification-stat--failed">
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="verification-stat">
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
<span class="stat-label">Total</span>
</div>
</div>
}
@if (verificationRequest()!.cliCommand) {
<div class="cli-parity">
<span class="cli-label">CLI Equivalent:</span>
<code>{{ verificationRequest()!.cliCommand }}</code>
</div>
}
</div>
}
<div class="sources-grid">
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
<div class="source-header">
<span class="source-icon" [attr.aria-label]="source.type">
@switch (source.type) {
@case ('registry') { <span>📦</span> }
@case ('pipeline') { <span>🔄</span> }
@case ('repository') { <span>📁</span> }
@case ('manual') { <span>📤</span> }
}
</span>
<div class="source-info">
<h3>{{ source.name }}</h3>
<span class="source-type">{{ source.type | titlecase }}</span>
</div>
<span class="source-status-badge">{{ source.status | titlecase }}</span>
</div>
<div class="source-stats">
<div class="source-stat">
<span class="source-stat-value">{{ source.checkCount }}</span>
<span class="source-stat-label">Checks</span>
</div>
<div class="source-stat">
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
<span class="source-stat-label">Pass Rate</span>
</div>
</div>
@if (source.recentViolations.length > 0) {
<div class="source-violations">
<span class="source-violations-label">Recent:</span>
@for (v of source.recentViolations; track v.code) {
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
{{ v.code }}
</span>
}
</div>
}
<div class="source-last-check">
Last check: {{ formatDate(source.lastCheck) }}
</div>
</article>
}
</div>
</section>
<!-- Violation Detail Modal -->
@if (selectedViolation()) {
<div
class="modal-overlay"
(click)="closeViolationDetail()"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'violation-title'"
>
<div class="modal-content" (click)="$event.stopPropagation()">
<header class="modal-header">
<h2 id="violation-title">
<span class="modal-code">{{ selectedViolation()!.code }}</span>
{{ selectedViolation()!.name }}
</h2>
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
&times;
</button>
</header>
<div class="modal-body">
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
{{ selectedViolation()!.severity | uppercase }}
</span>
<p class="violation-description">{{ selectedViolation()!.description }}</p>
<dl class="violation-meta">
<div>
<dt>Occurrences</dt>
<dd>{{ selectedViolation()!.count }}</dd>
</div>
<div>
<dt>Last Seen</dt>
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
</div>
</dl>
@if (selectedViolation()!.documentationUrl) {
<a
[href]="selectedViolation()!.documentationUrl"
class="docs-link"
target="_blank"
rel="noopener"
>
View Documentation &rarr;
</a>
}
</div>
<footer class="modal-footer">
<a
[routerLink]="['/sources/violations', selectedViolation()!.code]"
class="btn btn--primary"
>
View All Occurrences
</a>
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
Close
</button>
</footer>
</div>
</div>
}
}
</section>

View File

@@ -0,0 +1,752 @@
.aoc-dashboard {
display: grid;
gap: 1.5rem;
padding: 1.5rem;
color: #e2e8f0;
background: #0f172a;
min-height: calc(100vh - 120px);
}
// Header
.dashboard-header {
h1 {
margin: 0;
font-size: 1.5rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: #94a3b8;
font-size: 0.875rem;
}
}
// Loading
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #94a3b8;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Tiles
.tiles-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.tile {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
overflow: hidden;
}
.tile__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #1f2933;
h2 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
}
.tile__period {
font-size: 0.75rem;
color: #64748b;
}
.tile__content {
padding: 1.25rem;
}
// Pass/Fail Tile
.pass-rate-display {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1rem;
}
.pass-rate-value {
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.rate--excellent {
color: #22c55e;
}
.rate--good {
color: #84cc16;
}
.rate--warning {
color: #eab308;
}
.rate--critical {
color: #ef4444;
}
.pass-rate-trend {
font-size: 1.5rem;
font-weight: 600;
}
.trend--improving {
color: #22c55e;
}
.trend--stable {
color: #94a3b8;
}
.trend--degrading {
color: #ef4444;
}
.pass-fail-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.stat-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
}
.stat-value {
font-size: 1.125rem;
font-weight: 600;
}
.stat--passed .stat-value {
color: #22c55e;
}
.stat--failed .stat-value {
color: #ef4444;
}
.stat--pending .stat-value {
color: #eab308;
}
.mini-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
margin-top: 0.75rem;
}
.chart-bar {
flex: 1;
background: linear-gradient(to top, #3b82f6, #60a5fa);
border-radius: 2px 2px 0 0;
min-height: 4px;
transition: height 0.2s;
&:hover {
background: linear-gradient(to top, #2563eb, #3b82f6);
}
}
// Violations Tile
.critical-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.violations-list {
margin: 0;
padding: 0;
list-style: none;
}
.violation-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.625rem 0;
border-bottom: 1px solid #1f2933;
cursor: pointer;
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.violation-severity {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.severity--critical {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.severity--high {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.severity--medium {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.severity--low {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}
.severity--info {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.violation-code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #94a3b8;
}
.violation-name {
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.violation-count {
font-weight: 600;
color: #64748b;
font-size: 0.875rem;
}
.no-violations {
color: #64748b;
font-style: italic;
padding: 1rem 0;
text-align: center;
}
// Throughput Tile
.throughput-summary {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.throughput-stat {
display: flex;
flex-direction: column;
}
.throughput-value {
font-size: 1.75rem;
font-weight: 700;
color: #3b82f6;
}
.throughput-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
}
.throughput-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
th {
text-align: left;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid #334155;
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
font-weight: 500;
}
td {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid #1f2933;
color: #cbd5e1;
}
tr:last-child td {
border-bottom: none;
}
}
// Sources Section
.sources-section {
margin-top: 0.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
}
.verify-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: #1d4ed8;
border: none;
border-radius: 4px;
color: #f8fafc;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #1e40af;
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
}
.spinner-small {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// Verification Result
.verification-result {
background: #111827;
border: 1px solid #334155;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
&--completed {
border-color: rgba(34, 197, 94, 0.3);
}
}
.verification-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.verification-status {
font-weight: 600;
color: #22c55e;
}
.verification-time {
font-size: 0.75rem;
color: #64748b;
}
.verification-stats {
display: flex;
gap: 2rem;
margin-bottom: 0.75rem;
}
.verification-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
.stat-value {
font-size: 1.25rem;
font-weight: 700;
}
.stat-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
}
&--passed .stat-value {
color: #22c55e;
}
&--failed .stat-value {
color: #ef4444;
}
}
.cli-parity {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #0f172a;
border-radius: 4px;
font-size: 0.8125rem;
}
.cli-label {
color: #64748b;
}
.cli-parity code {
font-family: 'JetBrains Mono', monospace;
color: #22c55e;
}
// Sources Grid
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.source-card {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1rem;
transition: border-color 0.15s;
&:hover {
border-color: #334155;
}
}
.source-status--passed {
border-left: 3px solid #22c55e;
}
.source-status--failed {
border-left: 3px solid #ef4444;
}
.source-status--pending {
border-left: 3px solid #eab308;
}
.source-status--skipped {
border-left: 3px solid #64748b;
}
.source-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.source-icon {
font-size: 1.5rem;
}
.source-info {
flex: 1;
h3 {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
}
.source-type {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
}
.source-status-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.source-status--passed .source-status-badge {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.source-status--failed .source-status-badge {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.source-status--pending .source-status-badge {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.source-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
}
.source-stat {
display: flex;
flex-direction: column;
}
.source-stat-value {
font-size: 1.125rem;
font-weight: 600;
}
.source-stat-label {
font-size: 0.625rem;
text-transform: uppercase;
color: #64748b;
}
.source-violations {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.source-violations-label {
font-size: 0.75rem;
color: #64748b;
}
.source-violation-chip {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.625rem;
font-family: 'JetBrains Mono', monospace;
}
.source-last-check {
font-size: 0.6875rem;
color: #64748b;
}
// Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
width: 100%;
max-width: 500px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid #1f2933;
h2 {
margin: 0;
font-size: 1.125rem;
line-height: 1.4;
}
}
.modal-code {
font-family: 'JetBrains Mono', monospace;
color: #94a3b8;
margin-right: 0.5rem;
}
.modal-close {
background: transparent;
border: none;
color: #64748b;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0;
&:hover {
color: #e2e8f0;
}
}
.modal-body {
padding: 1.25rem;
}
.violation-severity-large {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
margin-bottom: 1rem;
}
.violation-description {
margin: 0 0 1rem;
color: #94a3b8;
line-height: 1.5;
}
.violation-meta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin: 0 0 1rem;
dt {
font-size: 0.6875rem;
text-transform: uppercase;
color: #64748b;
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.9375rem;
}
}
.docs-link {
display: inline-block;
color: #3b82f6;
font-size: 0.875rem;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.modal-footer {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding: 1rem 1.25rem;
border-top: 1px solid #1f2933;
}
.btn {
padding: 0.625rem 1.25rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
border: none;
&--primary {
background: #1d4ed8;
color: #f8fafc;
&:hover {
background: #1e40af;
}
}
&--secondary {
background: #334155;
color: #e2e8f0;
&:hover {
background: #475569;
}
}
}
// Screen reader only
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,207 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import {
AocDashboardSummary,
AocViolationCode,
IngestThroughput,
AocSource,
ViolationSeverity,
VerificationRequest,
} from '../../core/api/aoc.models';
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
@Component({
selector: 'app-aoc-dashboard',
standalone: true,
imports: [CommonModule, RouterModule],
providers: [{ provide: AOC_API, useClass: MockAocApi }],
templateUrl: './aoc-dashboard.component.html',
styleUrls: ['./aoc-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AocDashboardComponent implements OnInit {
private readonly aocApi = inject(AOC_API);
// State
readonly dashboard = signal<AocDashboardSummary | null>(null);
readonly loading = signal(true);
readonly verificationRequest = signal<VerificationRequest | null>(null);
readonly verifying = signal(false);
readonly selectedViolation = signal<AocViolationCode | null>(null);
// Computed values
readonly passRate = computed(() => {
const dash = this.dashboard();
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
});
readonly passRateClass = computed(() => {
const rate = this.passRate();
if (rate >= 95) return 'rate--excellent';
if (rate >= 85) return 'rate--good';
if (rate >= 70) return 'rate--warning';
return 'rate--critical';
});
readonly trendIcon = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return '↑';
if (trend === 'degrading') return '↓';
return '→';
});
readonly trendClass = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return 'trend--improving';
if (trend === 'degrading') return 'trend--degrading';
return 'trend--stable';
});
readonly totalThroughput = computed(() => {
const dash = this.dashboard();
if (!dash) return { docs: 0, bytes: 0 };
return dash.throughputByTenant.reduce(
(acc, t) => ({
docs: acc.docs + t.documentsIngested,
bytes: acc.bytes + t.bytesIngested,
}),
{ docs: 0, bytes: 0 }
);
});
readonly criticalViolations = computed(() => {
const dash = this.dashboard();
if (!dash) return 0;
return dash.recentViolations
.filter((v) => v.severity === 'critical')
.reduce((sum, v) => sum + v.count, 0);
});
readonly chartData = computed(() => {
const dash = this.dashboard();
if (!dash) return [];
const history = dash.passFail.history;
const max = Math.max(...history.map((p) => p.value));
return history.map((p) => ({
timestamp: p.timestamp,
value: p.value,
height: (p.value / max) * 100,
}));
});
ngOnInit(): void {
this.loadDashboard();
}
private loadDashboard(): void {
this.loading.set(true);
this.aocApi.getDashboardSummary().subscribe({
next: (summary) => {
this.dashboard.set(summary);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load AOC dashboard:', err);
this.loading.set(false);
},
});
}
startVerification(): void {
this.verifying.set(true);
this.verificationRequest.set(null);
this.aocApi.startVerification().subscribe({
next: (request) => {
this.verificationRequest.set(request);
// Poll for status updates (simplified - in real app would use interval)
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
},
error: (err) => {
console.error('Failed to start verification:', err);
this.verifying.set(false);
},
});
}
private pollVerificationStatus(requestId: string): void {
this.aocApi.getVerificationStatus(requestId).subscribe({
next: (request) => {
this.verificationRequest.set(request);
if (request.status === 'completed' || request.status === 'failed') {
this.verifying.set(false);
}
},
error: (err) => {
console.error('Failed to get verification status:', err);
this.verifying.set(false);
},
});
}
selectViolation(violation: AocViolationCode): void {
this.selectedViolation.set(violation);
}
closeViolationDetail(): void {
this.selectedViolation.set(null);
}
getSeverityClass(severity: ViolationSeverity): string {
return `severity--${severity}`;
}
getSourceStatusClass(source: AocSource): string {
return `source-status--${source.status}`;
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toString();
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
formatShortDate(isoString: string): string {
try {
const date = new Date(isoString);
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch {
return '';
}
}
trackByCode(_index: number, violation: AocViolationCode): string {
return violation.code;
}
trackByTenantId(_index: number, throughput: IngestThroughput): string {
return throughput.tenantId;
}
trackBySourceId(_index: number, source: AocSource): string {
return source.sourceId;
}
}

View File

@@ -0,0 +1,2 @@
export { AocDashboardComponent } from './aoc-dashboard.component';
export { ViolationDetailComponent } from './violation-detail.component';

Some files were not shown because too many files have changed in this diff Show More