save progress
This commit is contained in:
@@ -33,6 +33,9 @@ internal static class BinaryCommandGroup
|
||||
binary.Add(BuildLookupCommand(services, verboseOption, cancellationToken));
|
||||
binary.Add(BuildFingerprintCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260104_001_CLI - Binary call graph digest extraction
|
||||
binary.Add(BuildCallGraphCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return binary;
|
||||
}
|
||||
|
||||
@@ -188,6 +191,70 @@ internal static class BinaryCommandGroup
|
||||
return command;
|
||||
}
|
||||
|
||||
// CALLGRAPH-01: stella binary callgraph
|
||||
private static Command BuildCallGraphCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileArg = new Argument<string>("file")
|
||||
{
|
||||
Description = "Path to binary file to analyze."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: digest (default), json, summary."
|
||||
}.SetDefaultValue("digest").FromAmong("digest", "json", "summary");
|
||||
|
||||
var outputOption = new Option<string?>("--output", ["-o"])
|
||||
{
|
||||
Description = "Output file path (default: stdout)."
|
||||
};
|
||||
|
||||
var emitSbomOption = new Option<string?>("--emit-sbom")
|
||||
{
|
||||
Description = "Path to SBOM file to inject callgraph digest as property."
|
||||
};
|
||||
|
||||
var scanIdOption = new Option<string?>("--scan-id")
|
||||
{
|
||||
Description = "Scan ID for graph metadata (default: auto-generated)."
|
||||
};
|
||||
|
||||
var command = new Command("callgraph", "Extract call graph and compute deterministic digest.")
|
||||
{
|
||||
fileArg,
|
||||
formatOption,
|
||||
outputOption,
|
||||
emitSbomOption,
|
||||
scanIdOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var file = parseResult.GetValue(fileArg)!;
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var emitSbom = parseResult.GetValue(emitSbomOption);
|
||||
var scanId = parseResult.GetValue(scanIdOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return BinaryCommandHandlers.HandleCallGraphAsync(
|
||||
services,
|
||||
file,
|
||||
format,
|
||||
output,
|
||||
emitSbom,
|
||||
scanId,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildSubmitCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
// Description: Command handlers for binary reachability operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Binary;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Binary;
|
||||
|
||||
@@ -632,6 +636,238 @@ internal static class BinaryCommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle 'stella binary callgraph' command (CALLGRAPH-01).
|
||||
/// Extracts call graph from native binary and computes deterministic SHA-256 digest.
|
||||
/// </summary>
|
||||
public static async Task<int> HandleCallGraphAsync(
|
||||
IServiceProvider services,
|
||||
string filePath,
|
||||
string format,
|
||||
string? outputPath,
|
||||
string? emitSbomPath,
|
||||
string? scanId,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("binary-callgraph");
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {filePath}");
|
||||
return ExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
// Resolve scan ID (auto-generate if not provided)
|
||||
var effectiveScanId = scanId ?? $"cli-{Path.GetFileName(filePath)}-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
|
||||
CallGraphSnapshot snapshot = null!;
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Extracting binary call graph...", async ctx =>
|
||||
{
|
||||
ctx.Status("Loading binary...");
|
||||
|
||||
// Get the binary call graph extractor
|
||||
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var extractorLogger = loggerFactory.CreateLogger<BinaryCallGraphExtractor>();
|
||||
var extractor = new BinaryCallGraphExtractor(extractorLogger, timeProvider);
|
||||
|
||||
ctx.Status("Analyzing symbols and relocations...");
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: effectiveScanId,
|
||||
Language: "native",
|
||||
TargetPath: filePath);
|
||||
|
||||
snapshot = await extractor.ExtractAsync(request, cancellationToken);
|
||||
|
||||
ctx.Status("Computing digest...");
|
||||
});
|
||||
|
||||
// Format output based on requested format
|
||||
string output;
|
||||
switch (format)
|
||||
{
|
||||
case "digest":
|
||||
output = snapshot.GraphDigest;
|
||||
break;
|
||||
|
||||
case "json":
|
||||
output = JsonSerializer.Serialize(new
|
||||
{
|
||||
scanId = snapshot.ScanId,
|
||||
graphDigest = snapshot.GraphDigest,
|
||||
language = snapshot.Language,
|
||||
extractedAt = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
nodeCount = snapshot.Nodes.Length,
|
||||
edgeCount = snapshot.Edges.Length,
|
||||
entrypointCount = snapshot.EntrypointIds.Length,
|
||||
nodes = snapshot.Nodes,
|
||||
edges = snapshot.Edges,
|
||||
entrypointIds = snapshot.EntrypointIds
|
||||
}, JsonOptions);
|
||||
break;
|
||||
|
||||
case "summary":
|
||||
default:
|
||||
output = string.Join(Environment.NewLine,
|
||||
[
|
||||
$"GraphDigest: {snapshot.GraphDigest}",
|
||||
$"ScanId: {snapshot.ScanId}",
|
||||
$"Language: {snapshot.Language}",
|
||||
$"ExtractedAt: {snapshot.ExtractedAt:O}",
|
||||
$"Nodes: {snapshot.Nodes.Length}",
|
||||
$"Edges: {snapshot.Edges.Length}",
|
||||
$"Entrypoints: {snapshot.EntrypointIds.Length}"
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Write output
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, output, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]Output written to:[/] {outputPath}");
|
||||
}
|
||||
else if (format == "digest")
|
||||
{
|
||||
// For digest-only format, just output the digest
|
||||
Console.WriteLine(output);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.WriteLine(output);
|
||||
}
|
||||
|
||||
// Inject into SBOM if requested
|
||||
if (!string.IsNullOrWhiteSpace(emitSbomPath))
|
||||
{
|
||||
var sbomResult = await InjectCallGraphDigestIntoSbomAsync(
|
||||
emitSbomPath,
|
||||
filePath,
|
||||
snapshot,
|
||||
cancellationToken);
|
||||
|
||||
if (sbomResult == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Callgraph digest injected into SBOM:[/] {emitSbomPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Failed to inject digest into SBOM");
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Extracted call graph for {Path}: {Nodes} nodes, {Edges} edges, digest={Digest}",
|
||||
filePath,
|
||||
snapshot.Nodes.Length,
|
||||
snapshot.Edges.Length,
|
||||
snapshot.GraphDigest);
|
||||
}
|
||||
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
logger.LogError(ex, "Unsupported binary format for {Path}", filePath);
|
||||
return ExitCodes.InvalidArguments;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
logger.LogError(ex, "Failed to extract call graph for {Path}", filePath);
|
||||
return ExitCodes.GeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects callgraph digest as a property into a CycloneDX SBOM.
|
||||
/// </summary>
|
||||
private static async Task<int> InjectCallGraphDigestIntoSbomAsync(
|
||||
string sbomPath,
|
||||
string binaryPath,
|
||||
CallGraphSnapshot snapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(sbomPath))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sbomJson = await File.ReadAllTextAsync(sbomPath, cancellationToken);
|
||||
var doc = JsonNode.Parse(sbomJson) as JsonObject;
|
||||
|
||||
if (doc == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Ensure metadata.properties exists
|
||||
var metadata = doc["metadata"] as JsonObject;
|
||||
if (metadata == null)
|
||||
{
|
||||
metadata = new JsonObject();
|
||||
doc["metadata"] = metadata;
|
||||
}
|
||||
|
||||
var properties = metadata["properties"] as JsonArray;
|
||||
if (properties == null)
|
||||
{
|
||||
properties = new JsonArray();
|
||||
metadata["properties"] = properties;
|
||||
}
|
||||
|
||||
var binaryName = Path.GetFileName(binaryPath);
|
||||
|
||||
// Add callgraph properties using stellaops namespace
|
||||
properties.Add(new JsonObject
|
||||
{
|
||||
["name"] = $"stellaops:callgraph:digest:{binaryName}",
|
||||
["value"] = snapshot.GraphDigest
|
||||
});
|
||||
properties.Add(new JsonObject
|
||||
{
|
||||
["name"] = $"stellaops:callgraph:nodeCount:{binaryName}",
|
||||
["value"] = snapshot.Nodes.Length.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
properties.Add(new JsonObject
|
||||
{
|
||||
["name"] = $"stellaops:callgraph:edgeCount:{binaryName}",
|
||||
["value"] = snapshot.Edges.Length.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
properties.Add(new JsonObject
|
||||
{
|
||||
["name"] = $"stellaops:callgraph:entrypointCount:{binaryName}",
|
||||
["value"] = snapshot.EntrypointIds.Length.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
properties.Add(new JsonObject
|
||||
{
|
||||
["name"] = $"stellaops:callgraph:extractedAt:{binaryName}",
|
||||
["value"] = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
|
||||
// Write updated SBOM
|
||||
var updatedJson = doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(sbomPath, updatedJson, cancellationToken);
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectFormat(byte[] header)
|
||||
{
|
||||
// ELF magic: 0x7f 'E' 'L' 'F'
|
||||
|
||||
429
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Secrets.cs
Normal file
429
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Secrets.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.Secrets.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Tasks: RB-006, RB-007 - Command handlers for secrets bundle operations.
|
||||
// Description: Implements bundle create, verify, and info commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions SecretsJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
internal static async Task<int> HandleSecretsBundleCreateAsync(
|
||||
IServiceProvider services,
|
||||
string sources,
|
||||
string output,
|
||||
string id,
|
||||
string? version,
|
||||
bool sign,
|
||||
string? keyId,
|
||||
string? secret,
|
||||
string? secretFile,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = services.GetService<ILoggerFactory>()?.CreateLogger("Secrets.Bundle.Create");
|
||||
|
||||
if (!Directory.Exists(sources))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: Source directory not found: {Markup.Escape(sources)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Determine version (CalVer if not specified)
|
||||
var bundleVersion = version ?? DateTimeOffset.UtcNow.ToString("yyyy.MM", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Creating secrets detection rule bundle...[/]");
|
||||
AnsiConsole.MarkupLine($" Source: [bold]{Markup.Escape(sources)}[/]");
|
||||
AnsiConsole.MarkupLine($" Output: [bold]{Markup.Escape(output)}[/]");
|
||||
AnsiConsole.MarkupLine($" ID: [bold]{Markup.Escape(id)}[/]");
|
||||
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(bundleVersion)}[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Create output directory
|
||||
Directory.CreateDirectory(output);
|
||||
|
||||
// Build the bundle
|
||||
var builderLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleBuilder>();
|
||||
var validatorLogger = services.GetService<ILoggerFactory>()?.CreateLogger<RuleValidator>();
|
||||
|
||||
var validator = new RuleValidator(validatorLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RuleValidator>.Instance);
|
||||
var builder = new BundleBuilder(
|
||||
validator,
|
||||
builderLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleBuilder>.Instance);
|
||||
|
||||
var buildOptions = new BundleBuildOptions
|
||||
{
|
||||
SourceDirectory = sources,
|
||||
OutputDirectory = output,
|
||||
BundleId = id,
|
||||
Version = bundleVersion,
|
||||
TimeProvider = TimeProvider.System
|
||||
};
|
||||
|
||||
var artifact = await builder.BuildAsync(buildOptions, cancellationToken);
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Bundle created successfully![/]");
|
||||
AnsiConsole.MarkupLine($" Manifest: [bold]{Markup.Escape(artifact.ManifestPath)}[/]");
|
||||
AnsiConsole.MarkupLine($" Rules: [bold]{Markup.Escape(artifact.RulesPath)}[/]");
|
||||
AnsiConsole.MarkupLine($" Total rules: [bold]{artifact.TotalRules}[/]");
|
||||
AnsiConsole.MarkupLine($" Enabled rules: [bold]{artifact.EnabledRules}[/]");
|
||||
AnsiConsole.MarkupLine($" SHA-256: [bold]{artifact.RulesSha256}[/]");
|
||||
}
|
||||
|
||||
// Sign if requested
|
||||
if (sign)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error: --key-id is required for signing[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret) && string.IsNullOrWhiteSpace(secretFile))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error: --secret or --secret-file is required for signing[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Signing bundle...[/]");
|
||||
}
|
||||
|
||||
var signerLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleSigner>();
|
||||
var signer = new BundleSigner(signerLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleSigner>.Instance);
|
||||
|
||||
var signOptions = new BundleSigningOptions
|
||||
{
|
||||
KeyId = keyId,
|
||||
SharedSecret = secret,
|
||||
SharedSecretFile = secretFile,
|
||||
TimeProvider = TimeProvider.System
|
||||
};
|
||||
|
||||
var signResult = await signer.SignAsync(artifact, signOptions, cancellationToken);
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Bundle signed successfully![/]");
|
||||
AnsiConsole.MarkupLine($" Envelope: [bold]{Markup.Escape(signResult.EnvelopePath)}[/]");
|
||||
AnsiConsole.MarkupLine($" Key ID: [bold]{Markup.Escape(keyId)}[/]");
|
||||
}
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundle = new
|
||||
{
|
||||
id,
|
||||
version = bundleVersion,
|
||||
manifestPath = artifact.ManifestPath,
|
||||
rulesPath = artifact.RulesPath,
|
||||
totalRules = artifact.TotalRules,
|
||||
enabledRules = artifact.EnabledRules,
|
||||
rulesSha256 = artifact.RulesSha256,
|
||||
signed = sign
|
||||
}
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = ex.Message };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleSecretsBundleVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string bundle,
|
||||
string? secret,
|
||||
string? secretFile,
|
||||
string[] trustedKeys,
|
||||
bool requireSignature,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(bundle))
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: Bundle directory not found: {Markup.Escape(bundle)}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = $"Bundle directory not found: {bundle}" };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Verifying secrets detection rule bundle...[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle: [bold]{Markup.Escape(bundle)}[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verifierLogger = services.GetService<ILoggerFactory>()?.CreateLogger<BundleVerifier>();
|
||||
var verifier = new BundleVerifier(verifierLogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<BundleVerifier>.Instance);
|
||||
|
||||
var verifyOptions = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = secret,
|
||||
SharedSecretFile = secretFile,
|
||||
TrustedKeyIds = trustedKeys.Length > 0 ? trustedKeys : null,
|
||||
SkipSignatureVerification = !requireSignature && string.IsNullOrWhiteSpace(secret) && string.IsNullOrWhiteSpace(secretFile),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
var result = await verifier.VerifyAsync(bundle, verifyOptions, cancellationToken);
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
if (result.IsValid)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Bundle verification passed![/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{Markup.Escape(result.BundleId ?? "unknown")}[/]");
|
||||
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(result.BundleVersion ?? "unknown")}[/]");
|
||||
AnsiConsole.MarkupLine($" Rules: [bold]{result.RuleCount ?? 0}[/]");
|
||||
|
||||
if (result.SignerKeyId is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Signed by: [bold]{Markup.Escape(result.SignerKeyId)}[/]");
|
||||
}
|
||||
if (result.SignedAt.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Signed at: [bold]{result.SignedAt.Value:O}[/]");
|
||||
}
|
||||
|
||||
foreach (var warning in result.ValidationWarnings)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
|
||||
foreach (var error in result.ValidationErrors)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
success = result.IsValid,
|
||||
bundleId = result.BundleId,
|
||||
version = result.BundleVersion,
|
||||
ruleCount = result.RuleCount,
|
||||
signerKeyId = result.SignerKeyId,
|
||||
signedAt = result.SignedAt?.ToString("O"),
|
||||
errors = result.ValidationErrors,
|
||||
warnings = result.ValidationWarnings
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(jsonResult, SecretsJsonOptions));
|
||||
}
|
||||
|
||||
return result.IsValid ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = ex.Message };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<int> HandleSecretsBundleInfoAsync(
|
||||
IServiceProvider services,
|
||||
string bundle,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestPath = Path.Combine(bundle, "secrets.ruleset.manifest.json");
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: Bundle manifest not found: {Markup.Escape(manifestPath)}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = $"Bundle manifest not found: {manifestPath}" };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error: Failed to parse bundle manifest[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = "Failed to parse bundle manifest" };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Bundle Information[/]");
|
||||
AnsiConsole.MarkupLine($" ID: [bold]{Markup.Escape(manifest.Id)}[/]");
|
||||
AnsiConsole.MarkupLine($" Version: [bold]{Markup.Escape(manifest.Version)}[/]");
|
||||
AnsiConsole.MarkupLine($" Schema: [bold]{Markup.Escape(manifest.SchemaVersion)}[/]");
|
||||
AnsiConsole.MarkupLine($" Created: [bold]{manifest.CreatedAt:O}[/]");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest.Description))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Description: [bold]{Markup.Escape(manifest.Description)}[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[blue]Integrity[/]");
|
||||
AnsiConsole.MarkupLine($" Rules file: [bold]{Markup.Escape(manifest.Integrity.RulesFile)}[/]");
|
||||
AnsiConsole.MarkupLine($" SHA-256: [bold]{Markup.Escape(manifest.Integrity.RulesSha256)}[/]");
|
||||
AnsiConsole.MarkupLine($" Total rules: [bold]{manifest.Integrity.TotalRules}[/]");
|
||||
AnsiConsole.MarkupLine($" Enabled rules: [bold]{manifest.Integrity.EnabledRules}[/]");
|
||||
|
||||
if (manifest.Signatures is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[blue]Signature[/]");
|
||||
AnsiConsole.MarkupLine($" Envelope: [bold]{Markup.Escape(manifest.Signatures.DsseEnvelope)}[/]");
|
||||
AnsiConsole.MarkupLine($" Key ID: [bold]{Markup.Escape(manifest.Signatures.KeyId ?? "unknown")}[/]");
|
||||
if (manifest.Signatures.SignedAt.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Signed at: [bold]{manifest.Signatures.SignedAt.Value:O}[/]");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(manifest.Signatures.RekorLogId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Rekor log ID: [bold]{Markup.Escape(manifest.Signatures.RekorLogId)}[/]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[yellow]Bundle is not signed[/]");
|
||||
}
|
||||
|
||||
if (verbose && manifest.Rules.Length > 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[blue]Rules Summary[/]");
|
||||
var table = new Table();
|
||||
table.AddColumn("ID");
|
||||
table.AddColumn("Version");
|
||||
table.AddColumn("Severity");
|
||||
table.AddColumn("Enabled");
|
||||
|
||||
foreach (var rule in manifest.Rules.Take(20))
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(rule.Id),
|
||||
Markup.Escape(rule.Version),
|
||||
Markup.Escape(rule.Severity),
|
||||
rule.Enabled ? "[green]Yes[/]" : "[red]No[/]");
|
||||
}
|
||||
|
||||
if (manifest.Rules.Length > 20)
|
||||
{
|
||||
table.AddRow($"... and {manifest.Rules.Length - 20} more", "", "", "");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(manifest, SecretsJsonOptions));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (format == "text")
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new { success = false, error = ex.Message };
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, SecretsJsonOptions));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/Cli/StellaOps.Cli/Commands/SecretsCommandGroup.cs
Normal file
247
src/Cli/StellaOps.Cli/Commands/SecretsCommandGroup.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretsCommandGroup.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Tasks: RB-006, RB-007 - CLI commands for secrets rule bundle management.
|
||||
// Description: CLI commands for building, signing, and verifying secrets bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class SecretsCommandGroup
|
||||
{
|
||||
internal static Command BuildSecretsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var secrets = new Command("secrets", "Secrets detection rule bundle management.");
|
||||
|
||||
secrets.Add(BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return secrets;
|
||||
}
|
||||
|
||||
private static Command BuildBundleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = new Command("bundle", "Secrets rule bundle operations.");
|
||||
|
||||
bundle.Add(BuildCreateCommand(services, verboseOption, cancellationToken));
|
||||
bundle.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
|
||||
bundle.Add(BuildInfoCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static Command BuildCreateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourcesArg = new Argument<string>("sources")
|
||||
{
|
||||
Description = "Path to directory containing rule JSON files"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output directory for the bundle (default: ./bundle)"
|
||||
};
|
||||
outputOption.SetDefaultValue("./bundle");
|
||||
|
||||
var idOption = new Option<string>("--id")
|
||||
{
|
||||
Description = "Bundle identifier (default: stellaops-secrets)"
|
||||
};
|
||||
idOption.SetDefaultValue("stellaops-secrets");
|
||||
|
||||
var versionOption = new Option<string>("--version", "-v")
|
||||
{
|
||||
Description = "Bundle version (CalVer, e.g., 2026.01)"
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign the bundle after creation"
|
||||
};
|
||||
|
||||
var keyIdOption = new Option<string?>("--key-id")
|
||||
{
|
||||
Description = "Key identifier for signing"
|
||||
};
|
||||
|
||||
var secretOption = new Option<string?>("--secret")
|
||||
{
|
||||
Description = "Shared secret for HMAC signing (base64 or hex)"
|
||||
};
|
||||
|
||||
var secretFileOption = new Option<string?>("--secret-file")
|
||||
{
|
||||
Description = "Path to file containing the shared secret"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: text, json"
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("create", "Create a secrets detection rule bundle from JSON rule definitions.")
|
||||
{
|
||||
sourcesArg,
|
||||
outputOption,
|
||||
idOption,
|
||||
versionOption,
|
||||
signOption,
|
||||
keyIdOption,
|
||||
secretOption,
|
||||
secretFileOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var sources = parseResult.GetValue(sourcesArg) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption) ?? "./bundle";
|
||||
var id = parseResult.GetValue(idOption) ?? "stellaops-secrets";
|
||||
var version = parseResult.GetValue(versionOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var keyId = parseResult.GetValue(keyIdOption);
|
||||
var secret = parseResult.GetValue(secretOption);
|
||||
var secretFile = parseResult.GetValue(secretFileOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleSecretsBundleCreateAsync(
|
||||
services,
|
||||
sources,
|
||||
output,
|
||||
id,
|
||||
version,
|
||||
sign,
|
||||
keyId,
|
||||
secret,
|
||||
secretFile,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the bundle directory"
|
||||
};
|
||||
|
||||
var secretOption = new Option<string?>("--secret")
|
||||
{
|
||||
Description = "Shared secret for HMAC verification (base64 or hex)"
|
||||
};
|
||||
|
||||
var secretFileOption = new Option<string?>("--secret-file")
|
||||
{
|
||||
Description = "Path to file containing the shared secret"
|
||||
};
|
||||
|
||||
var trustedKeysOption = new Option<string[]>("--trusted-keys")
|
||||
{
|
||||
Description = "List of trusted key IDs for signature verification"
|
||||
};
|
||||
|
||||
var requireSignatureOption = new Option<bool>("--require-signature")
|
||||
{
|
||||
Description = "Require a valid signature (fail if unsigned)"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: text, json"
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("verify", "Verify a secrets detection rule bundle's integrity and signature.")
|
||||
{
|
||||
bundleArg,
|
||||
secretOption,
|
||||
secretFileOption,
|
||||
trustedKeysOption,
|
||||
requireSignatureOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var secret = parseResult.GetValue(secretOption);
|
||||
var secretFile = parseResult.GetValue(secretFileOption);
|
||||
var trustedKeys = parseResult.GetValue(trustedKeysOption) ?? Array.Empty<string>();
|
||||
var requireSignature = parseResult.GetValue(requireSignatureOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleSecretsBundleVerifyAsync(
|
||||
services,
|
||||
bundle,
|
||||
secret,
|
||||
secretFile,
|
||||
trustedKeys,
|
||||
requireSignature,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildInfoCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the bundle directory"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: text, json"
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("info", "Display information about a secrets detection rule bundle.")
|
||||
{
|
||||
bundleArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleSecretsBundleInfoAsync(
|
||||
services,
|
||||
bundle,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
|
||||
<!-- Binary Call Graph (SPRINT_20260104_001_CLI) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
@@ -9,6 +9,10 @@ Stand up the Policy Engine runtime host that evaluates organization policies aga
|
||||
- Change stream listeners and scheduler integration for incremental re-evaluation.
|
||||
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
|
||||
- Observability: metrics, traces, structured logs, trace sampling.
|
||||
- **StabilityDampingGate** (Sprint NG-001): Hysteresis-based damping to prevent flip-flopping verdicts:
|
||||
- Suppresses rapid status oscillations requiring min duration or significant confidence delta
|
||||
- Upgrades (more severe) bypass damping; downgrades are dampable
|
||||
- Integrates with VexLens NoiseGate for unified noise-gating
|
||||
|
||||
## Expectations
|
||||
- Keep endpoints deterministic, cancellation-aware, and tenant-scoped.
|
||||
|
||||
384
src/Policy/StellaOps.Policy.Engine/Gates/StabilityDampingGate.cs
Normal file
384
src/Policy/StellaOps.Policy.Engine/Gates/StabilityDampingGate.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Gate that applies hysteresis-based stability damping to prevent flip-flopping verdicts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This gate tracks verdict state transitions and suppresses rapid oscillations by requiring:
|
||||
/// - A minimum duration before a state change is surfaced, OR
|
||||
/// - A significant confidence delta that justifies immediate surfacing
|
||||
///
|
||||
/// This reduces alert fatigue from noisy or unstable feed data while still ensuring
|
||||
/// significant changes (especially upgrades to more severe states) surface promptly.
|
||||
/// </remarks>
|
||||
public interface IStabilityDampingGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict transition should be surfaced or damped.
|
||||
/// </summary>
|
||||
/// <param name="request">The damping evaluation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The damping decision.</returns>
|
||||
Task<StabilityDampingDecision> EvaluateAsync(
|
||||
StabilityDampingRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a verdict state for future damping calculations.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key for this verdict (e.g., "artifact:cve").</param>
|
||||
/// <param name="state">The verdict state to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordStateAsync(
|
||||
string key,
|
||||
VerdictState state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Prunes old verdict history records.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of records pruned.</returns>
|
||||
Task<int> PruneHistoryAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for stability damping evaluation.
|
||||
/// </summary>
|
||||
public sealed record StabilityDampingRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique key for this verdict (e.g., "artifact:cve" or "sha256:vuln_id").
|
||||
/// </summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current (proposed) verdict state.
|
||||
/// </summary>
|
||||
public required VerdictState ProposedState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenant deployments.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a verdict state at a point in time.
|
||||
/// </summary>
|
||||
public sealed record VerdictState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the VEX status (affected, not_affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of this state.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale class (e.g., "authoritative", "binary", "static").
|
||||
/// </summary>
|
||||
public string? RationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source that produced this state.
|
||||
/// </summary>
|
||||
public string? SourceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision from stability damping evaluation.
|
||||
/// </summary>
|
||||
public sealed record StabilityDampingDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the transition should be surfaced.
|
||||
/// </summary>
|
||||
public required bool ShouldSurface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the decision.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous state, if any.
|
||||
/// </summary>
|
||||
public VerdictState? PreviousState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets how long the previous state has persisted.
|
||||
/// </summary>
|
||||
public TimeSpan? StateDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence delta from previous to current.
|
||||
/// </summary>
|
||||
public double? ConfidenceDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a status upgrade (more severe).
|
||||
/// </summary>
|
||||
public bool? IsUpgrade { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the decision.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStabilityDampingGate"/>.
|
||||
/// </summary>
|
||||
public sealed class StabilityDampingGate : IStabilityDampingGate
|
||||
{
|
||||
private readonly IOptionsMonitor<StabilityDampingOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StabilityDampingGate> _logger;
|
||||
private readonly ConcurrentDictionary<string, VerdictState> _stateHistory = new();
|
||||
|
||||
// Status severity ordering: higher = more severe
|
||||
private static readonly Dictionary<string, int> StatusSeverity = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["affected"] = 100,
|
||||
["under_investigation"] = 50,
|
||||
["fixed"] = 25,
|
||||
["not_affected"] = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StabilityDampingGate"/> class.
|
||||
/// </summary>
|
||||
public StabilityDampingGate(
|
||||
IOptionsMonitor<StabilityDampingOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StabilityDampingGate> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<StabilityDampingDecision> EvaluateAsync(
|
||||
StabilityDampingRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var key = BuildKey(request.TenantId, request.Key);
|
||||
|
||||
// If disabled, always surface
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = "Stability damping disabled",
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status is subject to damping
|
||||
if (!opts.DampedStatuses.Contains(request.ProposedState.Status))
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Status '{request.ProposedState.Status}' is not subject to damping"),
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Get previous state
|
||||
if (!_stateHistory.TryGetValue(key, out var previousState))
|
||||
{
|
||||
// No history - this is a new verdict, surface it
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = "No previous state (new verdict)",
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status actually changed
|
||||
if (string.Equals(previousState.Status, request.ProposedState.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Same status - check confidence delta
|
||||
var confidenceDelta = Math.Abs(request.ProposedState.Confidence - previousState.Confidence);
|
||||
|
||||
if (confidenceDelta >= opts.MinConfidenceDeltaPercent)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence changed by {confidenceDelta:P1} (threshold: {opts.MinConfidenceDeltaPercent:P1})"),
|
||||
PreviousState = previousState,
|
||||
ConfidenceDelta = confidenceDelta,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// No significant change
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = false,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Same status, confidence delta {confidenceDelta:P1} below threshold"),
|
||||
PreviousState = previousState,
|
||||
ConfidenceDelta = confidenceDelta,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Status changed - check if it's an upgrade or downgrade
|
||||
var isUpgrade = IsStatusUpgrade(previousState.Status, request.ProposedState.Status);
|
||||
var stateDuration = now - previousState.Timestamp;
|
||||
|
||||
// Upgrades (more severe) bypass damping if configured
|
||||
if (isUpgrade && opts.OnlyDampDowngrades)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Status upgrade ({previousState.Status} -> {request.ProposedState.Status}) surfaces immediately"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
IsUpgrade = true,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check confidence delta for immediate surfacing
|
||||
var delta = Math.Abs(request.ProposedState.Confidence - previousState.Confidence);
|
||||
if (delta >= opts.MinConfidenceDeltaPercent)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence delta {delta:P1} exceeds threshold {opts.MinConfidenceDeltaPercent:P1}"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
ConfidenceDelta = delta,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check duration requirement
|
||||
if (stateDuration >= opts.MinDurationBeforeChange)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Previous state persisted for {stateDuration.TotalHours:F1}h (threshold: {opts.MinDurationBeforeChange.TotalHours:F1}h)"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Damped - don't surface yet
|
||||
var remainingTime = opts.MinDurationBeforeChange - stateDuration;
|
||||
|
||||
if (opts.LogDampedTransitions)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Damped transition for {Key}: {OldStatus}->{NewStatus}, remaining: {Remaining}",
|
||||
request.Key,
|
||||
previousState.Status,
|
||||
request.ProposedState.Status,
|
||||
remainingTime);
|
||||
}
|
||||
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = false,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Damped: state duration {stateDuration.TotalHours:F1}h < {opts.MinDurationBeforeChange.TotalHours:F1}h, " +
|
||||
$"delta {delta:P1} < {opts.MinConfidenceDeltaPercent:P1}"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
ConfidenceDelta = delta,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RecordStateAsync(
|
||||
string key,
|
||||
VerdictState state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
_stateHistory[key] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<int> PruneHistoryAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
var cutoff = _timeProvider.GetUtcNow() - opts.HistoryRetention;
|
||||
var pruned = 0;
|
||||
|
||||
foreach (var kvp in _stateHistory)
|
||||
{
|
||||
if (kvp.Value.Timestamp < cutoff)
|
||||
{
|
||||
if (_stateHistory.TryRemove(kvp.Key, out _))
|
||||
{
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0)
|
||||
{
|
||||
_logger.LogInformation("Pruned {Count} stale verdict state records", pruned);
|
||||
}
|
||||
|
||||
return Task.FromResult(pruned);
|
||||
}
|
||||
|
||||
private static string BuildKey(string? tenantId, string verdictKey)
|
||||
{
|
||||
return string.IsNullOrEmpty(tenantId)
|
||||
? verdictKey
|
||||
: $"{tenantId}:{verdictKey}";
|
||||
}
|
||||
|
||||
private static bool IsStatusUpgrade(string oldStatus, string newStatus)
|
||||
{
|
||||
var oldSeverity = StatusSeverity.GetValueOrDefault(oldStatus, 50);
|
||||
var newSeverity = StatusSeverity.GetValueOrDefault(newStatus, 50);
|
||||
return newSeverity > oldSeverity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the stability damping gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stability damping prevents flip-flopping verdicts by requiring that:
|
||||
/// - A verdict must persist for a minimum duration before a change is surfaced, OR
|
||||
/// - The confidence delta must exceed a minimum threshold
|
||||
///
|
||||
/// This reduces notification noise from unstable feed data while still allowing
|
||||
/// significant changes to surface quickly.
|
||||
/// </remarks>
|
||||
public sealed class StabilityDampingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether stability damping is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum duration a verdict must persist before
|
||||
/// a change is surfaced, unless the confidence delta exceeds the threshold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default: 4 hours. Set to TimeSpan.Zero to disable duration-based damping.
|
||||
/// </remarks>
|
||||
[Range(typeof(TimeSpan), "00:00:00", "7.00:00:00",
|
||||
ErrorMessage = "MinDurationBeforeChange must be between 0 and 7 days.")]
|
||||
public TimeSpan MinDurationBeforeChange { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum confidence change percentage required to
|
||||
/// bypass the duration requirement and surface a change immediately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default: 15%. A change from 0.70 to 0.85 (15%) would bypass duration damping.
|
||||
/// Set to 1.0 (100%) to effectively disable delta-based bypass.
|
||||
/// </remarks>
|
||||
[Range(0.0, 1.0, ErrorMessage = "MinConfidenceDeltaPercent must be between 0 and 1.")]
|
||||
public double MinConfidenceDeltaPercent { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VEX statuses to which damping applies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, damping applies to affected and not_affected transitions.
|
||||
/// Transitions to/from under_investigation are typically not damped.
|
||||
/// </remarks>
|
||||
public HashSet<string> DampedStatuses { get; set; } =
|
||||
[
|
||||
"affected",
|
||||
"not_affected",
|
||||
"fixed"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to apply damping only to downgrade transitions
|
||||
/// (e.g., affected -> not_affected).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When true, upgrades (not_affected -> affected) are surfaced immediately.
|
||||
/// This is conservative: users are alerted to new risks without delay.
|
||||
/// </remarks>
|
||||
public bool OnlyDampDowngrades { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the retention period for verdict history.
|
||||
/// Older records are pruned to prevent unbounded growth.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to log damped transitions for debugging.
|
||||
/// </summary>
|
||||
public bool LogDampedTransitions { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StabilityDampingGate"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class StabilityDampingGateTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly StabilityDampingOptions _defaultOptions;
|
||||
|
||||
public StabilityDampingGateTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_defaultOptions = new StabilityDampingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MinDurationBeforeChange = TimeSpan.FromHours(4),
|
||||
MinConfidenceDeltaPercent = 0.15,
|
||||
OnlyDampDowngrades = true,
|
||||
DampedStatuses = ["affected", "not_affected", "fixed", "under_investigation"]
|
||||
};
|
||||
}
|
||||
|
||||
private StabilityDampingGate CreateGate(StabilityDampingOptions? options = null)
|
||||
{
|
||||
var opts = options ?? _defaultOptions;
|
||||
var optionsMonitor = new TestOptionsMonitor<StabilityDampingOptions>(opts);
|
||||
return new StabilityDampingGate(optionsMonitor, _timeProvider, NullLogger<StabilityDampingGate>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NewVerdict_ShouldSurface()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.85,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("new verdict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ShouldAlwaysSurface()
|
||||
{
|
||||
// Arrange
|
||||
var options = new StabilityDampingOptions { Enabled = false };
|
||||
var gate = CreateGate(options);
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.85,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StatusUpgrade_ShouldSurfaceImmediately()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var key = "artifact:CVE-2024-1234";
|
||||
|
||||
// Record initial state as not_affected
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "not_affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Request upgrade to affected (more severe)
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.85,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.IsUpgrade.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("upgrade");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StatusDowngrade_WithoutMinDuration_ShouldDamp()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var key = "artifact:CVE-2024-1234";
|
||||
|
||||
// Record initial state as affected
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Advance time but not enough to meet threshold
|
||||
_timeProvider.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
// Request downgrade to not_affected (less severe)
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "not_affected",
|
||||
Confidence = 0.75,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeFalse();
|
||||
decision.Reason.Should().Contain("Damped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StatusDowngrade_AfterMinDuration_ShouldSurface()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var key = "artifact:CVE-2024-1234";
|
||||
|
||||
// Record initial state as affected
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Advance time past threshold
|
||||
_timeProvider.Advance(TimeSpan.FromHours(5));
|
||||
|
||||
// Request downgrade to not_affected
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "not_affected",
|
||||
Confidence = 0.75,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.StateDuration.Should().BeGreaterThan(TimeSpan.FromHours(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_LargeConfidenceDelta_ShouldSurfaceImmediately()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var key = "artifact:CVE-2024-1234";
|
||||
|
||||
// Record initial state
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.50,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Request with large confidence change (>15%)
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.90,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.ConfidenceDelta.Should().BeGreaterThan(0.15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SmallConfidenceDelta_SameStatus_ShouldDamp()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
var key = "artifact:CVE-2024-1234";
|
||||
|
||||
// Record initial state
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Request with small confidence change (<15%)
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.85,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeFalse();
|
||||
decision.ConfidenceDelta.Should().BeLessThan(0.15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneHistoryAsync_ShouldRemoveStaleRecords()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
|
||||
// Record old state
|
||||
await gate.RecordStateAsync("old-key", new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Advance time past retention period
|
||||
_timeProvider.Advance(TimeSpan.FromDays(8)); // Default retention is 7 days
|
||||
|
||||
// Record new state (to ensure we have something current)
|
||||
await gate.RecordStateAsync("new-key", new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Act
|
||||
var pruned = await gate.PruneHistoryAsync();
|
||||
|
||||
// Assert
|
||||
pruned.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithTenantId_ShouldIsolateTenants()
|
||||
{
|
||||
// Arrange
|
||||
var gate = CreateGate();
|
||||
|
||||
// Record state for tenant-a
|
||||
await gate.RecordStateAsync("tenant-a:artifact:CVE-2024-1234", new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Request for tenant-b (different tenant, no history)
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
TenantId = "tenant-b",
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("new verdict");
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
public TestOptionsMonitor(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string? name) => CurrentValue;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ public sealed class ScannerWorkerMetrics
|
||||
private readonly Counter<long> _surfacePayloadPersisted;
|
||||
private readonly Histogram<double> _surfaceManifestPublishDurationMs;
|
||||
|
||||
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
|
||||
private readonly Counter<long> _secretsAnalysisCompleted;
|
||||
private readonly Counter<long> _secretsAnalysisFailed;
|
||||
private readonly Counter<long> _secretFindingsDetected;
|
||||
private readonly Histogram<double> _secretsAnalysisDurationMs;
|
||||
|
||||
public ScannerWorkerMetrics()
|
||||
{
|
||||
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
@@ -80,6 +86,21 @@ public sealed class ScannerWorkerMetrics
|
||||
"scanner_worker_surface_manifest_publish_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds to persist and publish surface manifests.");
|
||||
|
||||
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
|
||||
_secretsAnalysisCompleted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_analysis_completed_total",
|
||||
description: "Number of successfully completed secrets analysis runs.");
|
||||
_secretsAnalysisFailed = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_analysis_failed_total",
|
||||
description: "Number of secrets analysis runs that failed.");
|
||||
_secretFindingsDetected = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_findings_detected_total",
|
||||
description: "Number of secret findings detected.");
|
||||
_secretsAnalysisDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_secrets_analysis_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds for secrets analysis.");
|
||||
}
|
||||
|
||||
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
|
||||
@@ -343,4 +364,39 @@ public sealed class ScannerWorkerMetrics
|
||||
// Native analysis metrics are tracked via counters/histograms
|
||||
// This is a placeholder for when we add dedicated native analysis metrics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records successful secrets analysis completion.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public void RecordSecretsAnalysisCompleted(
|
||||
ScanJobContext context,
|
||||
int findingCount,
|
||||
int filesScanned,
|
||||
TimeSpan duration,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
|
||||
_secretsAnalysisCompleted.Add(1, tags);
|
||||
|
||||
if (findingCount > 0)
|
||||
{
|
||||
_secretFindingsDetected.Add(findingCount, tags);
|
||||
}
|
||||
|
||||
if (duration > TimeSpan.Zero)
|
||||
{
|
||||
_secretsAnalysisDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records secrets analysis failure.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public void RecordSecretsAnalysisFailed(ScanJobContext context, TimeProvider timeProvider)
|
||||
{
|
||||
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
|
||||
_secretsAnalysisFailed.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public VerdictPushOptions VerdictPush { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Options for secrets leak detection scanning.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public SecretsOptions Secrets { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
@@ -311,4 +317,43 @@ public sealed class ScannerWorkerOptions
|
||||
/// </summary>
|
||||
public bool AllowAnonymousFallback { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for secrets leak detection scanning.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public sealed class SecretsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable secrets leak detection scanning.
|
||||
/// When disabled, the secrets scan stage will be skipped.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the secrets ruleset bundle directory.
|
||||
/// </summary>
|
||||
public string RulesetPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size in bytes to scan for secrets.
|
||||
/// Files larger than this will be skipped.
|
||||
/// </summary>
|
||||
public long MaxFileSizeBytes { get; set; } = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of files to scan per job.
|
||||
/// </summary>
|
||||
public int MaxFilesPerJob { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Enable entropy-based secret detection.
|
||||
/// </summary>
|
||||
public bool EnableEntropyDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum entropy threshold for high-entropy string detection.
|
||||
/// </summary>
|
||||
public double EntropyThreshold { get; set; } = 4.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public static class ScanStageNames
|
||||
// Sprint: SPRINT_20251226_014_BINIDX - Binary Vulnerability Lookup
|
||||
public const string BinaryLookup = "binary-lookup";
|
||||
|
||||
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
public const string ScanSecrets = "scan-secrets";
|
||||
|
||||
public static readonly IReadOnlyList<string> Ordered = new[]
|
||||
{
|
||||
IngestReplay,
|
||||
@@ -30,6 +33,7 @@ public static class ScanStageNames
|
||||
PullLayers,
|
||||
BuildFilesystem,
|
||||
ExecuteAnalyzers,
|
||||
ScanSecrets,
|
||||
BinaryLookup,
|
||||
EpssEnrichment,
|
||||
ComposeArtifacts,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Stage executor that scans filesystem for hardcoded secrets and credentials.
|
||||
/// </summary>
|
||||
internal sealed class SecretsAnalyzerStageExecutor : IScanStageExecutor
|
||||
{
|
||||
private static readonly string[] RootFsMetadataKeys =
|
||||
{
|
||||
"filesystem.rootfs",
|
||||
"rootfs.path",
|
||||
"scanner.rootfs",
|
||||
};
|
||||
|
||||
private readonly ISecretsAnalyzer _secretsAnalyzer;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptions<ScannerWorkerOptions> _options;
|
||||
private readonly ILogger<SecretsAnalyzerStageExecutor> _logger;
|
||||
|
||||
public SecretsAnalyzerStageExecutor(
|
||||
ISecretsAnalyzer secretsAnalyzer,
|
||||
ScannerWorkerMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ScannerWorkerOptions> options,
|
||||
ILogger<SecretsAnalyzerStageExecutor> logger)
|
||||
{
|
||||
_secretsAnalyzer = secretsAnalyzer ?? throw new ArgumentNullException(nameof(secretsAnalyzer));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string StageName => ScanStageNames.ScanSecrets;
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var secretsOptions = _options.Value.Secrets;
|
||||
if (!secretsOptions.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Secrets scanning is disabled; skipping stage.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file entries from analyzer stage
|
||||
if (!context.Analysis.TryGet<IReadOnlyList<ScanFileEntry>>(ScanAnalysisKeys.FileEntries, out var files) || files is null)
|
||||
{
|
||||
_logger.LogDebug("No file entries available; skipping secrets scan.");
|
||||
return;
|
||||
}
|
||||
|
||||
var rootfsPath = ResolveRootfsPath(context.Lease.Metadata);
|
||||
if (string.IsNullOrWhiteSpace(rootfsPath))
|
||||
{
|
||||
_logger.LogWarning("No rootfs path found in job metadata; skipping secrets scan for job {JobId}.", context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var startTime = _timeProvider.GetTimestamp();
|
||||
var allFindings = new List<SecretFinding>();
|
||||
|
||||
try
|
||||
{
|
||||
// Filter to text-like files only
|
||||
var textFiles = files
|
||||
.Where(f => ShouldScanFile(f))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scanning {FileCount} files for secrets in job {JobId}.",
|
||||
textFiles.Count,
|
||||
context.JobId);
|
||||
|
||||
foreach (var file in textFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(rootfsPath, file.Path.TrimStart('/'));
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content.Length == 0 || content.Length > secretsOptions.MaxFileSizeBytes)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var findings = await _secretsAnalyzer.AnalyzeAsync(
|
||||
content,
|
||||
file.Path,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (findings.Count > 0)
|
||||
{
|
||||
allFindings.AddRange(findings);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error scanning file {Path} for secrets: {Message}", file.Path, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTime);
|
||||
|
||||
// Store findings in analysis store
|
||||
var report = new SecretsAnalysisReport
|
||||
{
|
||||
JobId = context.JobId,
|
||||
ScanId = context.ScanId,
|
||||
Findings = allFindings.ToImmutableArray(),
|
||||
FilesScanned = textFiles.Count,
|
||||
RulesetVersion = _secretsAnalyzer.RulesetVersion,
|
||||
AnalyzedAtUtc = _timeProvider.GetUtcNow(),
|
||||
ElapsedMilliseconds = elapsed.TotalMilliseconds
|
||||
};
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.SecretFindings, report);
|
||||
context.Analysis.Set(ScanAnalysisKeys.SecretRulesetVersion, _secretsAnalyzer.RulesetVersion);
|
||||
|
||||
_metrics.RecordSecretsAnalysisCompleted(
|
||||
context,
|
||||
allFindings.Count,
|
||||
textFiles.Count,
|
||||
elapsed,
|
||||
_timeProvider);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Secrets scan completed for job {JobId}: {FindingCount} findings in {FileCount} files ({ElapsedMs:F0}ms).",
|
||||
context.JobId,
|
||||
allFindings.Count,
|
||||
textFiles.Count,
|
||||
elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Secrets scan cancelled for job {JobId}.", context.JobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecretsAnalysisFailed(context, _timeProvider);
|
||||
_logger.LogError(ex, "Secrets scan failed for job {JobId}: {Message}", context.JobId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldScanFile(ScanFileEntry file)
|
||||
{
|
||||
if (file is null || file.SizeBytes == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip binary files
|
||||
if (file.Kind is "elf" or "pe" or "mach-o" or "blob")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip very large files
|
||||
if (file.SizeBytes > 10 * 1024 * 1024)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(file.Path).ToLowerInvariant();
|
||||
|
||||
// Include common text/config file extensions
|
||||
return ext is ".json" or ".yaml" or ".yml" or ".xml" or ".properties" or ".conf" or ".config"
|
||||
or ".env" or ".ini" or ".toml" or ".cfg"
|
||||
or ".js" or ".ts" or ".jsx" or ".tsx" or ".mjs" or ".cjs"
|
||||
or ".py" or ".rb" or ".php" or ".go" or ".java" or ".cs" or ".rs" or ".swift" or ".kt"
|
||||
or ".sh" or ".bash" or ".zsh" or ".ps1" or ".bat" or ".cmd"
|
||||
or ".sql" or ".graphql" or ".gql"
|
||||
or ".tf" or ".tfvars" or ".hcl"
|
||||
or ".dockerfile" or ".dockerignore"
|
||||
or ".gitignore" or ".npmrc" or ".yarnrc" or ".pypirc"
|
||||
or ".pem" or ".key" or ".crt" or ".cer"
|
||||
or ".md" or ".txt" or ".log"
|
||||
|| string.IsNullOrEmpty(ext);
|
||||
}
|
||||
|
||||
private static string? ResolveRootfsPath(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in RootFsMetadataKeys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of secrets analysis for a scan job.
|
||||
/// </summary>
|
||||
public sealed record SecretsAnalysisReport
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required string ScanId { get; init; }
|
||||
public required ImmutableArray<SecretFinding> Findings { get; init; }
|
||||
public required int FilesScanned { get; init; }
|
||||
public required string RulesetVersion { get; init; }
|
||||
public required DateTimeOffset AnalyzedAtUtc { get; init; }
|
||||
public required double ElapsedMilliseconds { get; init; }
|
||||
}
|
||||
@@ -26,7 +26,9 @@ using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Worker.Processing.Entropy;
|
||||
using StellaOps.Scanner.Worker.Processing.Secrets;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Worker.Extensions;
|
||||
using StellaOps.Scanner.Worker.Processing.Surface;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
@@ -167,6 +169,18 @@ builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuild
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
|
||||
|
||||
// Secrets Leak Detection (Sprint: SPRINT_20251229_046_BE)
|
||||
if (workerOptions.Secrets.Enabled)
|
||||
{
|
||||
builder.Services.AddSecretsAnalyzer(options =>
|
||||
{
|
||||
options.RulesetPath = workerOptions.Secrets.RulesetPath;
|
||||
options.EnableEntropyDetection = workerOptions.Secrets.EnableEntropyDetection;
|
||||
options.EntropyThreshold = workerOptions.Secrets.EntropyThreshold;
|
||||
});
|
||||
builder.Services.AddSingleton<IScanStageExecutor, SecretsAnalyzerStageExecutor>();
|
||||
}
|
||||
|
||||
// Proof of Exposure (Sprint: SPRINT_3500_0001_0001_proof_of_exposure_mvp)
|
||||
builder.Services.AddOptions<StellaOps.Scanner.Core.Configuration.PoEConfiguration>()
|
||||
.BindConfiguration("PoE")
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Scanner Secrets Analyzer Guild Charter
|
||||
|
||||
## Mission
|
||||
|
||||
Detect accidentally committed secrets in container layers during scans using deterministic, DSSE-signed rule bundles. Ensure findings are reproducible, masked before output, and integrated with the Policy Engine for policy-driven decisions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Secret detection plugin implementing `ILayerAnalyzer`
|
||||
- Regex and entropy-based detection strategies
|
||||
- Rule bundle loading, verification, and execution
|
||||
- Payload masking engine
|
||||
- Evidence emission (`secret.leak`) for policy integration
|
||||
- Integration with Scanner Worker pipeline
|
||||
|
||||
## Required Reading
|
||||
|
||||
- `docs/modules/scanner/operations/secret-leak-detection.md` - Target specification
|
||||
- `docs/modules/scanner/design/surface-secrets.md` - Credential delivery (different from leak detection)
|
||||
- `docs/modules/scanner/architecture.md` - Scanner module architecture
|
||||
- `docs/modules/policy/secret-leak-detection-readiness.md` - Policy integration requirements
|
||||
- `docs/implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md` - Implementation sprint
|
||||
- `docs/implplan/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md` - Bundle infrastructure sprint
|
||||
- CLAUDE.md Section 8 (Code Quality & Determinism Rules)
|
||||
|
||||
## Working Agreement
|
||||
|
||||
1. **Status synchronisation**: Update task state in sprint file and local `TASKS.md` when starting or completing work.
|
||||
|
||||
2. **Determinism**:
|
||||
- Sort rules by ID for deterministic execution order
|
||||
- Use `CultureInfo.InvariantCulture` for all parsing
|
||||
- Inject `TimeProvider` for timestamps
|
||||
- Same inputs must produce same outputs
|
||||
|
||||
3. **Security posture**:
|
||||
- NEVER log secret payloads
|
||||
- Apply masking BEFORE any output or persistence
|
||||
- Verify bundle signatures on load
|
||||
- Enforce feature flag for gradual rollout
|
||||
|
||||
4. **Testing requirements**:
|
||||
- Unit tests for all detectors, masking, and rule loading
|
||||
- Integration tests with Scanner Worker
|
||||
- Golden fixture tests for determinism verification
|
||||
- Security tests ensuring secrets are not leaked
|
||||
|
||||
5. **Offline readiness**:
|
||||
- Support local bundle verification without network
|
||||
- Document Attestor mirror configuration
|
||||
- Ensure bundles ship with Offline Kit
|
||||
|
||||
## Key Interfaces
|
||||
|
||||
```csharp
|
||||
// Detection interface
|
||||
public interface ISecretDetector
|
||||
{
|
||||
string DetectorId { get; }
|
||||
ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// Masking interface
|
||||
public interface IPayloadMasker
|
||||
{
|
||||
string Mask(ReadOnlySpan<byte> payload, string? hint = null);
|
||||
}
|
||||
|
||||
// Bundle verification
|
||||
public interface IBundleVerifier
|
||||
{
|
||||
Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
VerificationOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `scanner.secret.finding_total` | Counter | Total findings by tenant, ruleId, severity |
|
||||
| `scanner.secret.scan_duration_seconds` | Histogram | Detection time per scan |
|
||||
| `scanner.secret.rules_loaded` | Gauge | Number of active rules |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
StellaOps.Scanner.Analyzers.Secrets/
|
||||
├── AGENTS.md # This file
|
||||
├── StellaOps.Scanner.Analyzers.Secrets.csproj
|
||||
├── Detectors/
|
||||
│ ├── ISecretDetector.cs
|
||||
│ ├── RegexDetector.cs
|
||||
│ ├── EntropyDetector.cs
|
||||
│ └── CompositeSecretDetector.cs
|
||||
├── Rules/
|
||||
│ ├── SecretRule.cs
|
||||
│ ├── SecretRuleset.cs
|
||||
│ └── RulesetLoader.cs
|
||||
├── Bundles/
|
||||
│ ├── BundleBuilder.cs
|
||||
│ ├── BundleVerifier.cs
|
||||
│ └── Schemas/
|
||||
├── Masking/
|
||||
│ ├── IPayloadMasker.cs
|
||||
│ └── PayloadMasker.cs
|
||||
├── Evidence/
|
||||
│ ├── SecretLeakEvidence.cs
|
||||
│ └── SecretFinding.cs
|
||||
├── SecretsAnalyzer.cs
|
||||
├── SecretsAnalyzerHost.cs
|
||||
├── SecretsAnalyzerOptions.cs
|
||||
└── ServiceCollectionExtensions.cs
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
See sprint files for current implementation status:
|
||||
- SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md
|
||||
- SPRINT_20260104_003_SCANNER_secret_rule_bundles.md
|
||||
- SPRINT_20260104_004_POLICY_secret_dsl_integration.md
|
||||
- SPRINT_20260104_005_AIRGAP_secret_offline_kit.md
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Secrets.Tests")]
|
||||
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Builds secrets detection rule bundles from individual rule files.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a bundle from individual rule files.
|
||||
/// </summary>
|
||||
Task<BundleArtifact> BuildAsync(
|
||||
BundleBuildOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle creation.
|
||||
/// </summary>
|
||||
public sealed record BundleBuildOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Directory where the bundle will be written.
|
||||
/// </summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version (e.g., "2026.01").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Paths to individual rule JSON files to include.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> RuleFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description for the bundle.
|
||||
/// </summary>
|
||||
public string Description { get; init; } = "StellaOps Secret Detection Rules";
|
||||
|
||||
/// <summary>
|
||||
/// Time provider for deterministic timestamps.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate rules during build.
|
||||
/// </summary>
|
||||
public bool ValidateRules { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail on validation warnings.
|
||||
/// </summary>
|
||||
public bool FailOnWarnings { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle creation.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the manifest file.
|
||||
/// </summary>
|
||||
public required string ManifestPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the rules JSONL file.
|
||||
/// </summary>
|
||||
public required string RulesPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the rules file (lowercase hex).
|
||||
/// </summary>
|
||||
public required string RulesSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
public required int TotalRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of enabled rules in the bundle.
|
||||
/// </summary>
|
||||
public required int EnabledRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated manifest.
|
||||
/// </summary>
|
||||
public required BundleManifest Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle builder.
|
||||
/// </summary>
|
||||
public sealed class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions RuleSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions RuleReaderOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
private readonly IRuleValidator _validator;
|
||||
private readonly ILogger<BundleBuilder> _logger;
|
||||
|
||||
public BundleBuilder(IRuleValidator validator, ILogger<BundleBuilder> logger)
|
||||
{
|
||||
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleArtifact> BuildAsync(
|
||||
BundleBuildOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var timeProvider = options.TimeProvider ?? TimeProvider.System;
|
||||
var createdAt = timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Building bundle {BundleId} v{Version} from {FileCount} rule files",
|
||||
options.BundleId,
|
||||
options.Version,
|
||||
options.RuleFiles.Count);
|
||||
|
||||
// Load and validate rules
|
||||
var rules = new List<SecretRule>();
|
||||
var validationErrors = new List<string>();
|
||||
var validationWarnings = new List<string>();
|
||||
|
||||
foreach (var ruleFile in options.RuleFiles)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(ruleFile))
|
||||
{
|
||||
validationErrors.Add($"Rule file not found: {ruleFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(ruleFile, ct).ConfigureAwait(false);
|
||||
var rule = JsonSerializer.Deserialize<SecretRule>(json, RuleReaderOptions);
|
||||
|
||||
if (rule is null)
|
||||
{
|
||||
validationErrors.Add($"Failed to deserialize rule from {ruleFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.ValidateRules)
|
||||
{
|
||||
var validation = _validator.Validate(rule);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
foreach (var error in validation.Errors)
|
||||
{
|
||||
validationErrors.Add($"{ruleFile}: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var warning in validation.Warnings)
|
||||
{
|
||||
validationWarnings.Add($"{ruleFile}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
rules.Add(rule);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
validationErrors.Add($"JSON parse error in {ruleFile}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle warnings
|
||||
if (validationWarnings.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle build has {WarningCount} warnings: {Warnings}",
|
||||
validationWarnings.Count,
|
||||
string.Join("; ", validationWarnings.Take(5)));
|
||||
|
||||
if (options.FailOnWarnings)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle build failed due to warnings: {string.Join("; ", validationWarnings)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle build failed with {validationErrors.Count} errors: {string.Join("; ", validationErrors)}");
|
||||
}
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No valid rules found to include in bundle.");
|
||||
}
|
||||
|
||||
// Sort rules by ID for deterministic output
|
||||
rules.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
|
||||
|
||||
// Ensure output directory exists
|
||||
Directory.CreateDirectory(options.OutputDirectory);
|
||||
|
||||
// Write rules JSONL file
|
||||
var rulesPath = Path.Combine(options.OutputDirectory, "secrets.ruleset.rules.jsonl");
|
||||
await WriteRulesJsonlAsync(rulesPath, rules, ct).ConfigureAwait(false);
|
||||
|
||||
// Compute SHA-256 of rules file
|
||||
var rulesSha256 = await ComputeFileSha256Async(rulesPath, ct).ConfigureAwait(false);
|
||||
|
||||
// Build manifest
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
SchemaVersion = "1.0",
|
||||
Id = options.BundleId,
|
||||
Version = options.Version,
|
||||
CreatedAt = createdAt,
|
||||
Description = options.Description,
|
||||
Rules = rules.Select(r => new BundleRuleSummary
|
||||
{
|
||||
Id = r.Id,
|
||||
Version = r.Version,
|
||||
Severity = r.Severity.ToString().ToLowerInvariant(),
|
||||
Enabled = r.Enabled
|
||||
}).ToImmutableArray(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesFile = "secrets.ruleset.rules.jsonl",
|
||||
RulesSha256 = rulesSha256,
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled)
|
||||
}
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(options.OutputDirectory, "secrets.ruleset.manifest.json");
|
||||
await WriteManifestAsync(manifestPath, manifest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle {BundleId} v{Version} created with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
options.BundleId,
|
||||
options.Version,
|
||||
rules.Count,
|
||||
rules.Count(r => r.Enabled));
|
||||
|
||||
return new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = rulesPath,
|
||||
RulesSha256 = rulesSha256,
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled),
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WriteRulesJsonlAsync(
|
||||
string path,
|
||||
IReadOnlyList<SecretRule> rules,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
useAsync: true);
|
||||
|
||||
await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var json = JsonSerializer.Serialize(rule, RuleSerializerOptions);
|
||||
await writer.WriteLineAsync(json).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteManifestAsync(
|
||||
string path,
|
||||
BundleManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(manifest, ManifestSerializerOptions);
|
||||
await File.WriteAllTextAsync(path, json, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileSha256Async(string path, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
useAsync: true);
|
||||
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the manifest of a secrets detection rule bundle.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for the bundle (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version using CalVer (e.g., "2026.01").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Summary of rules included in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules")]
|
||||
public ImmutableArray<BundleRuleSummary> Rules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Integrity information for the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrity")]
|
||||
public required BundleIntegrity Integrity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public BundleSignatures? Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a rule included in the bundle manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleRuleSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule version (SemVer).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule severity level.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the rule is enabled by default.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Integrity information for bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleIntegrity
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the rules file within the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesFile")]
|
||||
public string RulesFile { get; init; } = "secrets.ruleset.rules.jsonl";
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the rules file (lowercase hex).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesSha256")]
|
||||
public required string RulesSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalRules")]
|
||||
public required int TotalRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of enabled rules in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabledRules")]
|
||||
public required int EnabledRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature references for the bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleSignatures
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the DSSE envelope file within the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public string DsseEnvelope { get; init; } = "secrets.ruleset.dsse.json";
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing (for informational purposes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry ID (if applicable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Signs secrets detection rule bundles using DSSE envelopes.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a bundle artifact producing a DSSE envelope.
|
||||
/// </summary>
|
||||
Task<BundleSigningResult> SignAsync(
|
||||
BundleArtifact artifact,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle signing.
|
||||
/// </summary>
|
||||
public sealed record BundleSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier for the signature.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "HMAC-SHA256", "ES256").
|
||||
/// </summary>
|
||||
public string Algorithm { get; init; } = "HMAC-SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC signing (base64 or hex encoded).
|
||||
/// Required for HMAC-SHA256 algorithm.
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload type for the DSSE envelope.
|
||||
/// </summary>
|
||||
public string PayloadType { get; init; } = "application/vnd.stellaops.secrets-ruleset+json";
|
||||
|
||||
/// <summary>
|
||||
/// Time provider for deterministic timestamps.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle signing.
|
||||
/// </summary>
|
||||
public sealed record BundleSigningResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the generated DSSE envelope file.
|
||||
/// </summary>
|
||||
public required string EnvelopePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated DSSE envelope.
|
||||
/// </summary>
|
||||
public required DsseEnvelope Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated manifest with signature information.
|
||||
/// </summary>
|
||||
public required BundleManifest UpdatedManifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure for bundle signatures.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64url-encoded payload.
|
||||
/// </summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload type URI.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the PAE.
|
||||
/// </summary>
|
||||
public required ImmutableArray<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64url-encoded signature bytes.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle signing using HMAC-SHA256.
|
||||
/// </summary>
|
||||
public sealed class BundleSigner : IBundleSigner
|
||||
{
|
||||
private const string DssePrefix = "DSSEv1";
|
||||
|
||||
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ILogger<BundleSigner> _logger;
|
||||
|
||||
public BundleSigner(ILogger<BundleSigner> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleSigningResult> SignAsync(
|
||||
BundleArtifact artifact,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var timeProvider = options.TimeProvider ?? TimeProvider.System;
|
||||
var signedAt = timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signing bundle {BundleId} v{Version} with key {KeyId}",
|
||||
artifact.Manifest.Id,
|
||||
artifact.Manifest.Version,
|
||||
options.KeyId);
|
||||
|
||||
// Read manifest as payload
|
||||
var manifestJson = await File.ReadAllBytesAsync(artifact.ManifestPath, ct).ConfigureAwait(false);
|
||||
|
||||
// Encode payload as base64url
|
||||
var payloadBase64 = ToBase64Url(manifestJson);
|
||||
|
||||
// Build PAE (Pre-Authentication Encoding)
|
||||
var pae = BuildPae(options.PayloadType, manifestJson);
|
||||
|
||||
// Sign the PAE
|
||||
var signature = await SignPaeAsync(pae, options, ct).ConfigureAwait(false);
|
||||
var signatureBase64 = ToBase64Url(signature);
|
||||
|
||||
// Build DSSE envelope
|
||||
var envelope = new DsseEnvelope
|
||||
{
|
||||
Payload = payloadBase64,
|
||||
PayloadType = options.PayloadType,
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature
|
||||
{
|
||||
Sig = signatureBase64,
|
||||
KeyId = options.KeyId
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Write envelope to file
|
||||
var bundleDir = Path.GetDirectoryName(artifact.ManifestPath)!;
|
||||
var envelopePath = Path.Combine(bundleDir, "secrets.ruleset.dsse.json");
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, EnvelopeSerializerOptions);
|
||||
await File.WriteAllTextAsync(envelopePath, envelopeJson, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
|
||||
// Update manifest with signature info
|
||||
var updatedManifest = artifact.Manifest with
|
||||
{
|
||||
Signatures = new BundleSignatures
|
||||
{
|
||||
DsseEnvelope = "secrets.ruleset.dsse.json",
|
||||
KeyId = options.KeyId,
|
||||
SignedAt = signedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Rewrite manifest with signature info
|
||||
var updatedManifestJson = JsonSerializer.Serialize(updatedManifest, EnvelopeSerializerOptions);
|
||||
await File.WriteAllTextAsync(artifact.ManifestPath, updatedManifestJson, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle signed successfully. Envelope: {EnvelopePath}",
|
||||
envelopePath);
|
||||
|
||||
return new BundleSigningResult
|
||||
{
|
||||
EnvelopePath = envelopePath,
|
||||
Envelope = envelope,
|
||||
UpdatedManifest = updatedManifest
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<byte[]> SignPaeAsync(
|
||||
byte[] pae,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!options.Algorithm.Equals("HMAC-SHA256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException($"Algorithm '{options.Algorithm}' is not supported. Use HMAC-SHA256.");
|
||||
}
|
||||
|
||||
var secret = await LoadSecretAsync(options, ct).ConfigureAwait(false);
|
||||
if (secret is null || secret.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Shared secret is required for HMAC-SHA256 signing.");
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
return hmac.ComputeHash(pae);
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> LoadSecretAsync(BundleSigningOptions options, CancellationToken ct)
|
||||
{
|
||||
// Try file first
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecretFile) && File.Exists(options.SharedSecretFile))
|
||||
{
|
||||
var content = (await File.ReadAllTextAsync(options.SharedSecretFile, ct).ConfigureAwait(false)).Trim();
|
||||
return DecodeSecret(content);
|
||||
}
|
||||
|
||||
// Then inline secret
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecret))
|
||||
{
|
||||
return DecodeSecret(options.SharedSecret);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] DecodeSecret(string value)
|
||||
{
|
||||
// Try base64 first
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Not base64
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0 && IsHexString(value))
|
||||
{
|
||||
return Convert.FromHexString(value);
|
||||
}
|
||||
|
||||
// Treat as raw UTF-8
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds DSSE v1 Pre-Authentication Encoding.
|
||||
/// Format: "DSSEv1" SP LEN(type) SP type SP LEN(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenStr = typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var payloadLenStr = payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
// Calculate total size
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
|
||||
|
||||
var totalSize = prefixBytes.Length + 1 // prefix + SP
|
||||
+ typeLenBytes.Length + 1 // type len + SP
|
||||
+ typeBytes.Length + 1 // type + SP
|
||||
+ payloadLenBytes.Length + 1 // payload len + SP
|
||||
+ payload.Length;
|
||||
|
||||
var pae = new byte[totalSize];
|
||||
var offset = 0;
|
||||
|
||||
// DSSEv1
|
||||
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
|
||||
offset += prefixBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// type length
|
||||
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
|
||||
offset += typeLenBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// type
|
||||
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// payload length
|
||||
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
|
||||
offset += payloadLenBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// payload
|
||||
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies secrets detection rule bundle signatures and integrity.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a bundle's DSSE signature and integrity.
|
||||
/// </summary>
|
||||
Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// URL of the attestor service for online verification.
|
||||
/// </summary>
|
||||
public string? AttestorUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Rekor transparency log proof.
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs. If empty, any key is accepted.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? TrustedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC verification (base64 or hex encoded).
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify file integrity (SHA-256).
|
||||
/// </summary>
|
||||
public bool VerifyIntegrity { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip signature verification (integrity only).
|
||||
/// </summary>
|
||||
public bool SkipSignatureVerification { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the bundle is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version that was verified.
|
||||
/// </summary>
|
||||
public string? BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle ID that was verified.
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that signed the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry ID (if available).
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int? RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors encountered.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ValidationErrors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (non-fatal).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ValidationWarnings { get; init; } = [];
|
||||
|
||||
public static BundleVerificationResult Success(BundleManifest manifest, string? keyId) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
SignedAt = manifest.Signatures?.SignedAt,
|
||||
SignerKeyId = keyId ?? manifest.Signatures?.KeyId,
|
||||
RekorLogId = manifest.Signatures?.RekorLogId,
|
||||
RuleCount = manifest.Integrity.TotalRules
|
||||
};
|
||||
|
||||
public static BundleVerificationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationErrors = [.. errors]
|
||||
};
|
||||
|
||||
public static BundleVerificationResult Failure(IEnumerable<string> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationErrors = [.. errors]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle verification.
|
||||
/// </summary>
|
||||
public sealed class BundleVerifier : IBundleVerifier
|
||||
{
|
||||
private const string DssePrefix = "DSSEv1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly ILogger<BundleVerifier> _logger;
|
||||
|
||||
public BundleVerifier(ILogger<BundleVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDirectory);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
_logger.LogDebug("Verifying bundle at {BundleDir}", bundleDirectory);
|
||||
|
||||
// Check directory exists
|
||||
if (!Directory.Exists(bundleDirectory))
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Bundle directory not found: {bundleDirectory}");
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(bundleDirectory, "secrets.ruleset.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Manifest not found: {manifestPath}");
|
||||
}
|
||||
|
||||
BundleManifest manifest;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions)!;
|
||||
if (manifest is null)
|
||||
{
|
||||
return BundleVerificationResult.Failure("Failed to parse manifest.");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Invalid manifest JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded manifest: {BundleId} v{Version} with {RuleCount} rules",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
manifest.Integrity.TotalRules);
|
||||
|
||||
// Verify file integrity
|
||||
if (options.VerifyIntegrity)
|
||||
{
|
||||
var rulesPath = Path.Combine(bundleDirectory, manifest.Integrity.RulesFile);
|
||||
if (!File.Exists(rulesPath))
|
||||
{
|
||||
errors.Add($"Rules file not found: {rulesPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var actualHash = await ComputeFileSha256Async(rulesPath, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(actualHash, manifest.Integrity.RulesSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Rules file integrity check failed. Expected: {manifest.Integrity.RulesSha256}, Actual: {actualHash}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Rules file integrity verified.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (!options.SkipSignatureVerification)
|
||||
{
|
||||
if (manifest.Signatures is null)
|
||||
{
|
||||
errors.Add("Bundle is not signed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var envelopePath = Path.Combine(bundleDirectory, manifest.Signatures.DsseEnvelope);
|
||||
if (!File.Exists(envelopePath))
|
||||
{
|
||||
errors.Add($"DSSE envelope not found: {envelopePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var signatureResult = await VerifySignatureAsync(
|
||||
manifestPath,
|
||||
envelopePath,
|
||||
options,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!signatureResult.IsValid)
|
||||
{
|
||||
errors.AddRange(signatureResult.Errors);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check trusted key IDs
|
||||
if (options.TrustedKeyIds is { Count: > 0 } trustedKeys)
|
||||
{
|
||||
if (signatureResult.KeyId is null || !trustedKeys.Contains(signatureResult.KeyId))
|
||||
{
|
||||
errors.Add($"Signature key '{signatureResult.KeyId}' is not in the trusted keys list.");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Signature verified with key: {KeyId}", signatureResult.KeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Rekor requirement
|
||||
if (options.RequireRekorProof)
|
||||
{
|
||||
if (manifest.Signatures?.RekorLogId is null)
|
||||
{
|
||||
errors.Add("Rekor transparency log proof is required but not present.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: Implement Rekor verification via Attestor client
|
||||
warnings.Add("Rekor verification not yet implemented; proof present but not verified.");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle verification failed for {BundleId} v{Version}: {Errors}",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
string.Join("; ", errors));
|
||||
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
ValidationErrors = [.. errors],
|
||||
ValidationWarnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle verified: {BundleId} v{Version} ({RuleCount} rules)",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
manifest.Integrity.TotalRules);
|
||||
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
SignedAt = manifest.Signatures?.SignedAt,
|
||||
SignerKeyId = manifest.Signatures?.KeyId,
|
||||
RekorLogId = manifest.Signatures?.RekorLogId,
|
||||
RuleCount = manifest.Integrity.TotalRules,
|
||||
ValidationWarnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignatureVerificationResult> VerifySignatureAsync(
|
||||
string manifestPath,
|
||||
string envelopePath,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load envelope
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, ct).ConfigureAwait(false);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, JsonOptions);
|
||||
|
||||
if (envelope is null || envelope.Signatures.IsDefaultOrEmpty)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Invalid or empty DSSE envelope.");
|
||||
}
|
||||
|
||||
// Decode payload - this is the original manifest (before signature was added)
|
||||
var payloadBytes = FromBase64Url(envelope.Payload);
|
||||
var payloadManifest = JsonSerializer.Deserialize<BundleManifest>(payloadBytes, JsonOptions);
|
||||
|
||||
if (payloadManifest is null)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Failed to parse envelope payload as manifest.");
|
||||
}
|
||||
|
||||
// Load current manifest and verify it matches the signed version (ignoring the Signatures field
|
||||
// which was added after signing)
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
var currentManifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions);
|
||||
|
||||
if (currentManifest is null)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Failed to parse current manifest.");
|
||||
}
|
||||
|
||||
// Compare all fields except Signatures (which is added after signing)
|
||||
if (!ManifestsMatchIgnoringSignatures(payloadManifest, currentManifest))
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Envelope payload does not match manifest content.");
|
||||
}
|
||||
|
||||
// Build PAE
|
||||
var pae = BuildPae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Verify each signature (at least one must be valid)
|
||||
var secret = await LoadSecretAsync(options, ct).ConfigureAwait(false);
|
||||
if (secret is null || secret.Length == 0)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Shared secret is required for signature verification.");
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var signatureBytes = FromBase64Url(sig.Sig);
|
||||
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
var expectedSignature = hmac.ComputeHash(pae);
|
||||
|
||||
if (CryptographicOperations.FixedTimeEquals(expectedSignature, signatureBytes))
|
||||
{
|
||||
return SignatureVerificationResult.Success(sig.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
return SignatureVerificationResult.Failure("Signature verification failed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SignatureVerificationResult.Failure($"Signature verification error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ManifestsMatchIgnoringSignatures(BundleManifest a, BundleManifest b)
|
||||
{
|
||||
// Compare all fields except Signatures
|
||||
return a.SchemaVersion == b.SchemaVersion
|
||||
&& a.Id == b.Id
|
||||
&& a.Version == b.Version
|
||||
&& a.CreatedAt == b.CreatedAt
|
||||
&& a.Description == b.Description
|
||||
&& a.Integrity.RulesFile == b.Integrity.RulesFile
|
||||
&& a.Integrity.RulesSha256 == b.Integrity.RulesSha256
|
||||
&& a.Integrity.TotalRules == b.Integrity.TotalRules
|
||||
&& a.Integrity.EnabledRules == b.Integrity.EnabledRules;
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> LoadSecretAsync(BundleVerificationOptions options, CancellationToken ct)
|
||||
{
|
||||
// Try file first
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecretFile) && File.Exists(options.SharedSecretFile))
|
||||
{
|
||||
var content = (await File.ReadAllTextAsync(options.SharedSecretFile, ct).ConfigureAwait(false)).Trim();
|
||||
return DecodeSecret(content);
|
||||
}
|
||||
|
||||
// Then inline secret
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecret))
|
||||
{
|
||||
return DecodeSecret(options.SharedSecret);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] DecodeSecret(string value)
|
||||
{
|
||||
// Try base64 first
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Not base64
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0 && IsHexString(value))
|
||||
{
|
||||
return Convert.FromHexString(value);
|
||||
}
|
||||
|
||||
// Treat as raw UTF-8
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenStr = typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var payloadLenStr = payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
|
||||
|
||||
var totalSize = prefixBytes.Length + 1
|
||||
+ typeLenBytes.Length + 1
|
||||
+ typeBytes.Length + 1
|
||||
+ payloadLenBytes.Length + 1
|
||||
+ payload.Length;
|
||||
|
||||
var pae = new byte[totalSize];
|
||||
var offset = 0;
|
||||
|
||||
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
|
||||
offset += prefixBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
|
||||
offset += typeLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
|
||||
offset += payloadLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileSha256Async(string path, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SignatureVerificationResult(bool IsValid, string? KeyId, ImmutableArray<string> Errors)
|
||||
{
|
||||
public static SignatureVerificationResult Success(string? keyId) => new(true, keyId, []);
|
||||
public static SignatureVerificationResult Failure(params string[] errors) => new(false, null, [.. errors]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Validates secret detection rules against the schema requirements.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IRuleValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a rule and returns validation errors, if any.
|
||||
/// </summary>
|
||||
RuleValidationResult Validate(SecretRule rule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule validation.
|
||||
/// </summary>
|
||||
public sealed record RuleValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rule is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors encountered.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (non-fatal).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
|
||||
public static RuleValidationResult Success() => new() { IsValid = true };
|
||||
|
||||
public static RuleValidationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors]
|
||||
};
|
||||
|
||||
public static RuleValidationResult Failure(IEnumerable<string> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of rule validation.
|
||||
/// </summary>
|
||||
public sealed class RuleValidator : IRuleValidator
|
||||
{
|
||||
private static readonly Regex NamespacedIdPattern = new(
|
||||
@"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SemVerPattern = new(
|
||||
@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly ILogger<RuleValidator> _logger;
|
||||
|
||||
public RuleValidator(ILogger<RuleValidator> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public RuleValidationResult Validate(SecretRule rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Validate ID
|
||||
if (string.IsNullOrWhiteSpace(rule.Id))
|
||||
{
|
||||
errors.Add("Rule ID is required.");
|
||||
}
|
||||
else if (!NamespacedIdPattern.IsMatch(rule.Id))
|
||||
{
|
||||
errors.Add($"Rule ID '{rule.Id}' must be namespaced (e.g., 'stellaops.secrets.aws-key').");
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if (string.IsNullOrWhiteSpace(rule.Version))
|
||||
{
|
||||
errors.Add("Rule version is required.");
|
||||
}
|
||||
else if (!SemVerPattern.IsMatch(rule.Version))
|
||||
{
|
||||
errors.Add($"Rule version '{rule.Version}' must be valid SemVer.");
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (string.IsNullOrWhiteSpace(rule.Name))
|
||||
{
|
||||
warnings.Add("Rule name is recommended for documentation.");
|
||||
}
|
||||
|
||||
// Validate pattern for regex/composite rules
|
||||
if (rule.Type is SecretRuleType.Regex or SecretRuleType.Composite)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.Pattern))
|
||||
{
|
||||
errors.Add("Pattern is required for regex/composite rules.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate regex compiles
|
||||
_ = new Regex(rule.Pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (RegexParseException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern: {ex.Message}");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate entropy threshold for entropy rules
|
||||
if (rule.Type is SecretRuleType.Entropy or SecretRuleType.Composite)
|
||||
{
|
||||
if (rule.EntropyThreshold <= 0 || rule.EntropyThreshold > 8)
|
||||
{
|
||||
warnings.Add($"Entropy threshold {rule.EntropyThreshold} may be out of typical range (3.0-6.0).");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate min/max length
|
||||
if (rule.MinLength < 0)
|
||||
{
|
||||
errors.Add("MinLength cannot be negative.");
|
||||
}
|
||||
|
||||
if (rule.MaxLength < rule.MinLength)
|
||||
{
|
||||
errors.Add("MaxLength must be greater than or equal to MinLength.");
|
||||
}
|
||||
|
||||
// Validate file patterns if provided
|
||||
foreach (var pattern in rule.FilePatterns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
warnings.Add("Empty file pattern will be ignored.");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Rule {RuleId} validation failed: {Errors}", rule.Id, string.Join("; ", errors));
|
||||
return new RuleValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors],
|
||||
Warnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Rule {RuleId} validated with warnings: {Warnings}", rule.Id, string.Join("; ", warnings));
|
||||
}
|
||||
|
||||
return new RuleValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Errors = [],
|
||||
Warnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Combines multiple detection strategies for comprehensive secret detection.
|
||||
/// </summary>
|
||||
public sealed class CompositeSecretDetector : ISecretDetector
|
||||
{
|
||||
private readonly RegexDetector _regexDetector;
|
||||
private readonly EntropyDetector _entropyDetector;
|
||||
private readonly ILogger<CompositeSecretDetector> _logger;
|
||||
|
||||
public CompositeSecretDetector(
|
||||
RegexDetector regexDetector,
|
||||
EntropyDetector entropyDetector,
|
||||
ILogger<CompositeSecretDetector> logger)
|
||||
{
|
||||
_regexDetector = regexDetector ?? throw new ArgumentNullException(nameof(regexDetector));
|
||||
_entropyDetector = entropyDetector ?? throw new ArgumentNullException(nameof(entropyDetector));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "composite";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) => true;
|
||||
|
||||
public async ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SecretMatch>();
|
||||
|
||||
// Choose detector based on rule type
|
||||
switch (rule.Type)
|
||||
{
|
||||
case SecretRuleType.Regex:
|
||||
var regexMatches = await _regexDetector.DetectAsync(content, filePath, rule, ct);
|
||||
results.AddRange(regexMatches);
|
||||
break;
|
||||
|
||||
case SecretRuleType.Entropy:
|
||||
var entropyMatches = await _entropyDetector.DetectAsync(content, filePath, rule, ct);
|
||||
results.AddRange(entropyMatches);
|
||||
break;
|
||||
|
||||
case SecretRuleType.Composite:
|
||||
// Run both detectors and merge results
|
||||
var regexTask = _regexDetector.DetectAsync(content, filePath, rule, ct);
|
||||
var entropyTask = _entropyDetector.DetectAsync(content, filePath, rule, ct);
|
||||
|
||||
var regexResults = await regexTask;
|
||||
var entropyResults = await entropyTask;
|
||||
|
||||
// Add regex matches
|
||||
results.AddRange(regexResults);
|
||||
|
||||
// Add entropy matches, boosting confidence if they overlap with regex
|
||||
foreach (var entropyMatch in entropyResults)
|
||||
{
|
||||
var overlappingRegex = regexResults.FirstOrDefault(r =>
|
||||
r.LineNumber == entropyMatch.LineNumber &&
|
||||
OverlapsColumn(r, entropyMatch));
|
||||
|
||||
if (overlappingRegex is not null)
|
||||
{
|
||||
// Boost confidence for overlapping matches
|
||||
results.Add(entropyMatch with
|
||||
{
|
||||
ConfidenceScore = Math.Min(0.99, entropyMatch.ConfidenceScore + 0.1)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(entropyMatch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Deduplicate overlapping matches
|
||||
return DeduplicateMatches(results);
|
||||
}
|
||||
|
||||
private static bool OverlapsColumn(SecretMatch a, SecretMatch b)
|
||||
{
|
||||
return a.ColumnStart <= b.ColumnEnd && b.ColumnStart <= a.ColumnEnd;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SecretMatch> DeduplicateMatches(List<SecretMatch> matches)
|
||||
{
|
||||
if (matches.Count <= 1)
|
||||
{
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
matches.Sort((a, b) =>
|
||||
{
|
||||
var lineComp = a.LineNumber.CompareTo(b.LineNumber);
|
||||
return lineComp != 0 ? lineComp : a.ColumnStart.CompareTo(b.ColumnStart);
|
||||
});
|
||||
|
||||
var deduplicated = new List<SecretMatch>();
|
||||
SecretMatch? previous = null;
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (previous is null)
|
||||
{
|
||||
previous = match;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this match overlaps with the previous one
|
||||
if (match.LineNumber == previous.LineNumber && OverlapsColumn(previous, match))
|
||||
{
|
||||
// Keep the one with higher confidence
|
||||
if (match.ConfidenceScore > previous.ConfidenceScore)
|
||||
{
|
||||
previous = match;
|
||||
}
|
||||
// Otherwise keep previous
|
||||
}
|
||||
else
|
||||
{
|
||||
deduplicated.Add(previous);
|
||||
previous = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (previous is not null)
|
||||
{
|
||||
deduplicated.Add(previous);
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy for detecting high-entropy strings that may be secrets.
|
||||
/// </summary>
|
||||
public static class EntropyCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy in bits per character for the given data.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to analyze.</param>
|
||||
/// <returns>Entropy in bits per character (0.0 to 8.0 for byte data).</returns>
|
||||
public static double Calculate(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Count occurrences of each byte value
|
||||
Span<int> counts = stackalloc int[256];
|
||||
counts.Clear();
|
||||
|
||||
foreach (byte b in data)
|
||||
{
|
||||
counts[b]++;
|
||||
}
|
||||
|
||||
// Calculate entropy using Shannon's formula
|
||||
double entropy = 0.0;
|
||||
double length = data.Length;
|
||||
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
double probability = counts[i] / length;
|
||||
entropy -= probability * Math.Log2(probability);
|
||||
}
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy for a string.
|
||||
/// </summary>
|
||||
public static double Calculate(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// For character data, we calculate based on unique characters seen
|
||||
var counts = new Dictionary<char, int>();
|
||||
|
||||
foreach (char c in data)
|
||||
{
|
||||
counts.TryGetValue(c, out int count);
|
||||
counts[c] = count + 1;
|
||||
}
|
||||
|
||||
double entropy = 0.0;
|
||||
double length = data.Length;
|
||||
|
||||
foreach (var count in counts.Values)
|
||||
{
|
||||
double probability = count / length;
|
||||
entropy -= probability * Math.Log2(probability);
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be base64 encoded.
|
||||
/// </summary>
|
||||
public static bool IsBase64Like(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int validChars = 0;
|
||||
foreach (char c in data)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c is '+' or '/' or '=')
|
||||
{
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
return validChars >= data.Length * 0.9;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be hexadecimal.
|
||||
/// </summary>
|
||||
public static bool IsHexLike(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (char c in data)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a string is likely a secret based on entropy and charset.
|
||||
/// </summary>
|
||||
/// <param name="data">The string to check.</param>
|
||||
/// <param name="threshold">Minimum entropy threshold (default 4.5).</param>
|
||||
/// <returns>True if the string appears to be a high-entropy secret.</returns>
|
||||
public static bool IsLikelySecret(ReadOnlySpan<char> data, double threshold = 4.5)
|
||||
{
|
||||
if (data.Length < 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if it looks like a UUID (common false positive)
|
||||
if (LooksLikeUuid(data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entropy = Calculate(data);
|
||||
return entropy >= threshold;
|
||||
}
|
||||
|
||||
private static bool LooksLikeUuid(ReadOnlySpan<char> data)
|
||||
{
|
||||
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars)
|
||||
if (data.Length == 36)
|
||||
{
|
||||
if (data[8] == '-' && data[13] == '-' && data[18] == '-' && data[23] == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// UUID without dashes: 32 hex chars
|
||||
if (data.Length == 32 && IsHexLike(data))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Entropy-based secret detector for high-entropy strings.
|
||||
/// </summary>
|
||||
public sealed class EntropyDetector : ISecretDetector
|
||||
{
|
||||
private readonly ILogger<EntropyDetector> _logger;
|
||||
|
||||
// Regex to find potential secret strings (alphanumeric with common secret characters)
|
||||
private static readonly Regex CandidatePattern = new(
|
||||
@"[A-Za-z0-9+/=_\-]{16,}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
public EntropyDetector(ILogger<EntropyDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "entropy";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) =>
|
||||
ruleType is SecretRuleType.Entropy or SecretRuleType.Composite;
|
||||
|
||||
public ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Decode content as UTF-8
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(content.Span);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var matches = new List<SecretMatch>();
|
||||
var lineStarts = ComputeLineStarts(text);
|
||||
var threshold = rule.EntropyThreshold > 0 ? rule.EntropyThreshold : 4.5;
|
||||
var minLength = rule.MinLength > 0 ? rule.MinLength : 16;
|
||||
var maxLength = rule.MaxLength > 0 ? rule.MaxLength : 1000;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Match candidate in CandidatePattern.Matches(text))
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var value = candidate.Value.AsSpan();
|
||||
|
||||
// Check length constraints
|
||||
if (value.Length < minLength || value.Length > maxLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip common false positives
|
||||
if (ShouldSkip(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate entropy
|
||||
var entropy = EntropyCalculator.Calculate(value);
|
||||
if (entropy < threshold)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (lineNumber, columnStart) = GetLineAndColumn(lineStarts, candidate.Index);
|
||||
var matchBytes = Encoding.UTF8.GetBytes(candidate.Value);
|
||||
|
||||
// Adjust confidence based on entropy level
|
||||
var confidenceScore = CalculateConfidence(entropy, threshold);
|
||||
|
||||
matches.Add(new SecretMatch
|
||||
{
|
||||
Rule = rule,
|
||||
FilePath = filePath,
|
||||
LineNumber = lineNumber,
|
||||
ColumnStart = columnStart,
|
||||
ColumnEnd = columnStart + candidate.Length - 1,
|
||||
RawMatch = matchBytes,
|
||||
ConfidenceScore = confidenceScore,
|
||||
DetectorId = DetectorId,
|
||||
Entropy = entropy
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Entropy detection timeout on file '{FilePath}'",
|
||||
filePath);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>(matches);
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(ReadOnlySpan<char> value)
|
||||
{
|
||||
// Skip UUIDs
|
||||
if (EntropyCalculator.IsHexLike(value) && value.Length == 32)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if it looks like a UUID with dashes
|
||||
if (value.Length == 36 && value[8] == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip common hash prefixes that aren't secrets
|
||||
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("md5:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if it's all the same character repeated
|
||||
char first = value[0];
|
||||
bool allSame = true;
|
||||
for (int i = 1; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != first)
|
||||
{
|
||||
allSame = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allSame)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(double entropy, double threshold)
|
||||
{
|
||||
// Scale confidence based on how far above threshold
|
||||
// entropy >= threshold + 1.5 => 0.95 (high)
|
||||
// entropy >= threshold + 0.5 => 0.75 (medium)
|
||||
// entropy >= threshold => 0.5 (low)
|
||||
var excess = entropy - threshold;
|
||||
return excess switch
|
||||
{
|
||||
>= 1.5 => 0.95,
|
||||
>= 0.5 => 0.75,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int> ComputeLineStarts(string text)
|
||||
{
|
||||
var lineStarts = new List<int> { 0 };
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lineStarts.Add(i + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
private static (int lineNumber, int column) GetLineAndColumn(List<int> lineStarts, int position)
|
||||
{
|
||||
int line = 1;
|
||||
for (int i = 1; i < lineStarts.Count; i++)
|
||||
{
|
||||
if (lineStarts[i] > position)
|
||||
{
|
||||
break;
|
||||
}
|
||||
line = i + 1;
|
||||
}
|
||||
|
||||
int lineStart = lineStarts[line - 1];
|
||||
int column = position - lineStart + 1;
|
||||
return (line, column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for secret detection strategies.
|
||||
/// Implementations must be thread-safe and deterministic.
|
||||
/// </summary>
|
||||
public interface ISecretDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this detector (e.g., "regex", "entropy").
|
||||
/// </summary>
|
||||
string DetectorId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Detects secrets in the provided content using the specified rule.
|
||||
/// </summary>
|
||||
/// <param name="content">The file content to scan.</param>
|
||||
/// <param name="filePath">The file path (for reporting).</param>
|
||||
/// <param name="rule">The rule to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matches found.</returns>
|
||||
ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this detector can handle the specified rule type.
|
||||
/// </summary>
|
||||
bool CanHandle(SecretRuleType ruleType);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Regex-based secret detector.
|
||||
/// </summary>
|
||||
public sealed class RegexDetector : ISecretDetector
|
||||
{
|
||||
private readonly ILogger<RegexDetector> _logger;
|
||||
|
||||
public RegexDetector(ILogger<RegexDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "regex";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) =>
|
||||
ruleType is SecretRuleType.Regex or SecretRuleType.Composite;
|
||||
|
||||
public ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
if (regex is null)
|
||||
{
|
||||
_logger.LogWarning("Rule '{RuleId}' has invalid regex pattern", rule.Id);
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Decode content as UTF-8
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(content.Span);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
// Not valid UTF-8, skip
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Apply keyword pre-filter
|
||||
if (!rule.MightMatch(text.AsSpan()))
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var matches = new List<SecretMatch>();
|
||||
var lineStarts = ComputeLineStarts(text);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Match match in regex.Matches(text))
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (match.Length < rule.MinLength || match.Length > rule.MaxLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (lineNumber, columnStart) = GetLineAndColumn(lineStarts, match.Index);
|
||||
var matchBytes = Encoding.UTF8.GetBytes(match.Value);
|
||||
|
||||
matches.Add(new SecretMatch
|
||||
{
|
||||
Rule = rule,
|
||||
FilePath = filePath,
|
||||
LineNumber = lineNumber,
|
||||
ColumnStart = columnStart,
|
||||
ColumnEnd = columnStart + match.Length - 1,
|
||||
RawMatch = matchBytes,
|
||||
ConfidenceScore = MapConfidenceToScore(rule.Confidence),
|
||||
DetectorId = DetectorId
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Regex timeout for rule '{RuleId}' on file '{FilePath}'",
|
||||
rule.Id,
|
||||
filePath);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>(matches);
|
||||
}
|
||||
|
||||
private static List<int> ComputeLineStarts(string text)
|
||||
{
|
||||
var lineStarts = new List<int> { 0 };
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lineStarts.Add(i + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
private static (int lineNumber, int column) GetLineAndColumn(List<int> lineStarts, int position)
|
||||
{
|
||||
int line = 1;
|
||||
for (int i = 1; i < lineStarts.Count; i++)
|
||||
{
|
||||
if (lineStarts[i] > position)
|
||||
{
|
||||
break;
|
||||
}
|
||||
line = i + 1;
|
||||
}
|
||||
|
||||
int lineStart = lineStarts[line - 1];
|
||||
int column = position - lineStart + 1;
|
||||
return (line, column);
|
||||
}
|
||||
|
||||
private static double MapConfidenceToScore(SecretConfidence confidence) => confidence switch
|
||||
{
|
||||
SecretConfidence.Low => 0.5,
|
||||
SecretConfidence.Medium => 0.75,
|
||||
SecretConfidence.High => 0.95,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a potential secret match found by a detector.
|
||||
/// </summary>
|
||||
public sealed record SecretMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// The rule that produced this match.
|
||||
/// </summary>
|
||||
public required SecretRule Rule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The file path where the match was found.
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based line number of the match.
|
||||
/// </summary>
|
||||
public required int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column where the match starts.
|
||||
/// </summary>
|
||||
public required int ColumnStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column where the match ends.
|
||||
/// </summary>
|
||||
public required int ColumnEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw matched content (will be masked before output).
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> RawMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score from 0.0 to 1.0.
|
||||
/// </summary>
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detector that found this match.
|
||||
/// </summary>
|
||||
public required string DetectorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional entropy value if entropy-based detection was used.
|
||||
/// </summary>
|
||||
public double? Entropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the length of the matched content.
|
||||
/// </summary>
|
||||
public int MatchLength => RawMatch.Length;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated finding for storage in ScanAnalysisStore.
|
||||
/// </summary>
|
||||
public sealed record SecretFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this finding.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The evidence record.
|
||||
/// </summary>
|
||||
public required SecretLeakEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The scan that produced this finding.
|
||||
/// </summary>
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tenant that owns this finding.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The artifact digest (container image or other artifact).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new finding from evidence.
|
||||
/// </summary>
|
||||
public static SecretFinding Create(
|
||||
SecretLeakEvidence evidence,
|
||||
string scanId,
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
Guid? id = null)
|
||||
{
|
||||
return new SecretFinding
|
||||
{
|
||||
Id = id ?? Guid.NewGuid(),
|
||||
Evidence = evidence,
|
||||
ScanId = scanId,
|
||||
TenantId = tenantId,
|
||||
ArtifactDigest = artifactDigest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for a detected secret leak.
|
||||
/// This record is emitted to the policy engine for decision-making.
|
||||
/// </summary>
|
||||
public sealed record SecretLeakEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// The evidence type identifier.
|
||||
/// </summary>
|
||||
public const string EvidenceType = "secret.leak";
|
||||
|
||||
/// <summary>
|
||||
/// The rule ID that produced this finding.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rule version.
|
||||
/// </summary>
|
||||
public required string RuleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The severity of the finding.
|
||||
/// </summary>
|
||||
public required SecretSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The confidence level of the finding.
|
||||
/// </summary>
|
||||
public required SecretConfidence Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The file path where the secret was found (relative to scan root).
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based line number.
|
||||
/// </summary>
|
||||
public required int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column number.
|
||||
/// </summary>
|
||||
public int ColumnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The masked payload (e.g., "AKIA****B7"). Never contains the actual secret.
|
||||
/// </summary>
|
||||
public required string Mask { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle ID that contained the rule.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle version.
|
||||
/// </summary>
|
||||
public required string BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was detected.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detector that found this secret.
|
||||
/// </summary>
|
||||
public required string DetectorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entropy value if entropy-based detection was used.
|
||||
/// </summary>
|
||||
public double? Entropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the finding.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates evidence from a secret match and masker.
|
||||
/// </summary>
|
||||
public static SecretLeakEvidence FromMatch(
|
||||
SecretMatch match,
|
||||
IPayloadMasker masker,
|
||||
SecretRuleset ruleset,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(match);
|
||||
ArgumentNullException.ThrowIfNull(masker);
|
||||
ArgumentNullException.ThrowIfNull(ruleset);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var masked = masker.Mask(match.RawMatch.Span, match.Rule.MaskingHint);
|
||||
|
||||
return new SecretLeakEvidence
|
||||
{
|
||||
RuleId = match.Rule.Id,
|
||||
RuleVersion = match.Rule.Version,
|
||||
Severity = match.Rule.Severity,
|
||||
Confidence = MapScoreToConfidence(match.ConfidenceScore, match.Rule.Confidence),
|
||||
FilePath = match.FilePath,
|
||||
LineNumber = match.LineNumber,
|
||||
ColumnNumber = match.ColumnStart,
|
||||
Mask = masked,
|
||||
BundleId = ruleset.Id,
|
||||
BundleVersion = ruleset.Version,
|
||||
DetectedAt = timeProvider.GetUtcNow(),
|
||||
DetectorId = match.DetectorId,
|
||||
Entropy = match.Entropy
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretConfidence MapScoreToConfidence(double score, SecretConfidence ruleDefault)
|
||||
{
|
||||
// Adjust confidence based on detection score
|
||||
if (score >= 0.9)
|
||||
{
|
||||
return SecretConfidence.High;
|
||||
}
|
||||
if (score >= 0.7)
|
||||
{
|
||||
return SecretConfidence.Medium;
|
||||
}
|
||||
if (score >= 0.5)
|
||||
{
|
||||
return ruleDefault;
|
||||
}
|
||||
return SecretConfidence.Low;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Globalization;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.RegularExpressions;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for secret payload masking.
|
||||
/// </summary>
|
||||
public interface IPayloadMasker
|
||||
{
|
||||
/// <summary>
|
||||
/// Masks a secret payload, preserving prefix/suffix for identification.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw secret bytes.</param>
|
||||
/// <param name="hint">Optional masking hint (e.g., "prefix:4,suffix:2").</param>
|
||||
/// <returns>Masked string (e.g., "AKIA****B7").</returns>
|
||||
string Mask(ReadOnlySpan<byte> payload, string? hint = null);
|
||||
|
||||
/// <summary>
|
||||
/// Masks a secret string, preserving prefix/suffix for identification.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw secret string.</param>
|
||||
/// <param name="hint">Optional masking hint (e.g., "prefix:4,suffix:2").</param>
|
||||
/// <returns>Masked string (e.g., "AKIA****B7").</returns>
|
||||
string Mask(ReadOnlySpan<char> payload, string? hint = null);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of payload masking for secrets.
|
||||
/// </summary>
|
||||
public sealed class PayloadMasker : IPayloadMasker
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of characters to preserve at the start.
|
||||
/// </summary>
|
||||
public const int DefaultPrefixLength = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Default number of characters to preserve at the end.
|
||||
/// </summary>
|
||||
public const int DefaultSuffixLength = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of mask characters to use.
|
||||
/// </summary>
|
||||
public const int MaxMaskLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum output length for masked values.
|
||||
/// </summary>
|
||||
public const int MinOutputLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total characters to expose (prefix + suffix).
|
||||
/// </summary>
|
||||
public const int MaxExposedChars = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The character used for masking.
|
||||
/// </summary>
|
||||
public const char MaskChar = '*';
|
||||
|
||||
public string Mask(ReadOnlySpan<byte> payload, string? hint = null)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Try to decode as UTF-8
|
||||
try
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(payload);
|
||||
return Mask(text.AsSpan(), hint);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
// Not valid UTF-8, represent as hex
|
||||
var hex = Convert.ToHexString(payload);
|
||||
return Mask(hex.AsSpan(), hint);
|
||||
}
|
||||
}
|
||||
|
||||
public string Mask(ReadOnlySpan<char> payload, string? hint = null)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var (prefixLen, suffixLen) = ParseHint(hint);
|
||||
|
||||
// Enforce maximum exposed characters
|
||||
if (prefixLen + suffixLen > MaxExposedChars)
|
||||
{
|
||||
var ratio = (double)prefixLen / (prefixLen + suffixLen);
|
||||
prefixLen = (int)(MaxExposedChars * ratio);
|
||||
suffixLen = MaxExposedChars - prefixLen;
|
||||
}
|
||||
|
||||
// Handle short payloads
|
||||
if (payload.Length <= prefixLen + suffixLen)
|
||||
{
|
||||
// Too short to mask meaningfully, just return masked placeholder
|
||||
return new string(MaskChar, Math.Min(payload.Length, MinOutputLength));
|
||||
}
|
||||
|
||||
// Calculate mask length
|
||||
var middleLength = payload.Length - prefixLen - suffixLen;
|
||||
var maskLength = Math.Min(middleLength, MaxMaskLength);
|
||||
|
||||
// Build masked output
|
||||
var sb = new StringBuilder(prefixLen + maskLength + suffixLen);
|
||||
|
||||
// Prefix
|
||||
if (prefixLen > 0)
|
||||
{
|
||||
sb.Append(payload[..prefixLen]);
|
||||
}
|
||||
|
||||
// Mask
|
||||
sb.Append(MaskChar, maskLength);
|
||||
|
||||
// Suffix
|
||||
if (suffixLen > 0)
|
||||
{
|
||||
sb.Append(payload[^suffixLen..]);
|
||||
}
|
||||
|
||||
// Ensure minimum length
|
||||
while (sb.Length < MinOutputLength)
|
||||
{
|
||||
sb.Insert(prefixLen, MaskChar);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (int prefix, int suffix) ParseHint(string? hint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
return (DefaultPrefixLength, DefaultSuffixLength);
|
||||
}
|
||||
|
||||
int prefix = DefaultPrefixLength;
|
||||
int suffix = DefaultSuffixLength;
|
||||
|
||||
// Parse hint format: "prefix:4,suffix:2"
|
||||
var parts = hint.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var kv = part.Split(':', StringSplitOptions.TrimEntries);
|
||||
if (kv.Length != 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kv[0].Equals("prefix", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prefix = Math.Max(0, Math.Min(value, MaxExposedChars));
|
||||
}
|
||||
else if (kv[0].Equals("suffix", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
suffix = Math.Max(0, Math.Min(value, MaxExposedChars));
|
||||
}
|
||||
}
|
||||
|
||||
return (prefix, suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for loading secret detection rulesets.
|
||||
/// </summary>
|
||||
public interface IRulesetLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a ruleset from a directory containing bundle files.
|
||||
/// </summary>
|
||||
/// <param name="bundlePath">Path to the bundle directory.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded ruleset.</returns>
|
||||
ValueTask<SecretRuleset> LoadAsync(string bundlePath, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a ruleset from a JSONL stream.
|
||||
/// </summary>
|
||||
/// <param name="rulesStream">Stream containing NDJSON rule definitions.</param>
|
||||
/// <param name="bundleId">The bundle identifier.</param>
|
||||
/// <param name="bundleVersion">The bundle version.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded ruleset.</returns>
|
||||
ValueTask<SecretRuleset> LoadFromJsonlAsync(
|
||||
Stream rulesStream,
|
||||
string bundleId,
|
||||
string bundleVersion,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Loads secret detection rulesets from bundle files.
|
||||
/// </summary>
|
||||
public sealed class RulesetLoader : IRulesetLoader
|
||||
{
|
||||
private readonly ILogger<RulesetLoader> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
|
||||
}
|
||||
};
|
||||
|
||||
public RulesetLoader(ILogger<RulesetLoader> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async ValueTask<SecretRuleset> LoadAsync(string bundlePath, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
throw new ArgumentException("Bundle path is required", nameof(bundlePath));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(bundlePath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Bundle directory not found: {bundlePath}");
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(bundlePath, "secrets.ruleset.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("Bundle manifest not found", manifestPath);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse bundle manifest");
|
||||
|
||||
// Load rules
|
||||
var rulesPath = Path.Combine(bundlePath, "secrets.ruleset.rules.jsonl");
|
||||
if (!File.Exists(rulesPath))
|
||||
{
|
||||
throw new FileNotFoundException("Bundle rules file not found", rulesPath);
|
||||
}
|
||||
|
||||
await using var rulesStream = File.OpenRead(rulesPath);
|
||||
var ruleset = await LoadFromJsonlAsync(
|
||||
rulesStream,
|
||||
manifest.Id ?? "secrets.ruleset",
|
||||
manifest.Version ?? "unknown",
|
||||
ct);
|
||||
|
||||
// Verify integrity if digest is available
|
||||
if (!string.IsNullOrEmpty(manifest.Integrity?.RulesSha256))
|
||||
{
|
||||
rulesStream.Position = 0;
|
||||
var actualDigest = await ComputeSha256Async(rulesStream, ct);
|
||||
if (!string.Equals(actualDigest, manifest.Integrity.RulesSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Rules file integrity check failed. Expected: {manifest.Integrity.RulesSha256}, Actual: {actualDigest}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded secrets ruleset '{BundleId}' version {Version} with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
ruleset.Id,
|
||||
ruleset.Version,
|
||||
ruleset.Rules.Length,
|
||||
ruleset.EnabledRuleCount);
|
||||
|
||||
return ruleset;
|
||||
}
|
||||
|
||||
public async ValueTask<SecretRuleset> LoadFromJsonlAsync(
|
||||
Stream rulesStream,
|
||||
string bundleId,
|
||||
string bundleVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rulesStream);
|
||||
|
||||
var rules = new List<SecretRule>();
|
||||
using var reader = new StreamReader(rulesStream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
int lineNumber = 0;
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) is not null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lineNumber++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ruleJson = JsonSerializer.Deserialize<RuleJson>(line, JsonOptions);
|
||||
if (ruleJson is null)
|
||||
{
|
||||
_logger.LogWarning("Skipping null rule at line {LineNumber}", lineNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var rule = MapToRule(ruleJson);
|
||||
rules.Add(rule);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse rule at line {LineNumber}", lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rules by ID for deterministic ordering
|
||||
rules.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
|
||||
|
||||
return new SecretRuleset
|
||||
{
|
||||
Id = bundleId,
|
||||
Version = bundleVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Rules = [.. rules]
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule MapToRule(RuleJson json)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = json.Id ?? throw new InvalidOperationException("Rule ID is required"),
|
||||
Version = json.Version ?? "1.0.0",
|
||||
Name = json.Name ?? json.Id ?? "Unknown",
|
||||
Description = json.Description ?? string.Empty,
|
||||
Type = ParseRuleType(json.Type),
|
||||
Pattern = json.Pattern ?? throw new InvalidOperationException("Rule pattern is required"),
|
||||
Severity = ParseSeverity(json.Severity),
|
||||
Confidence = ParseConfidence(json.Confidence),
|
||||
MaskingHint = json.MaskingHint,
|
||||
Keywords = json.Keywords?.ToImmutableArray() ?? [],
|
||||
FilePatterns = json.FilePatterns?.ToImmutableArray() ?? [],
|
||||
Enabled = json.Enabled ?? true,
|
||||
EntropyThreshold = json.EntropyThreshold ?? 4.5,
|
||||
MinLength = json.MinLength ?? 16,
|
||||
MaxLength = json.MaxLength ?? 1000,
|
||||
Metadata = json.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRuleType ParseRuleType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"regex" => SecretRuleType.Regex,
|
||||
"entropy" => SecretRuleType.Entropy,
|
||||
"composite" => SecretRuleType.Composite,
|
||||
_ => SecretRuleType.Regex
|
||||
};
|
||||
|
||||
private static SecretSeverity ParseSeverity(string? severity) => severity?.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => SecretSeverity.Low,
|
||||
"medium" => SecretSeverity.Medium,
|
||||
"high" => SecretSeverity.High,
|
||||
"critical" => SecretSeverity.Critical,
|
||||
_ => SecretSeverity.Medium
|
||||
};
|
||||
|
||||
private static SecretConfidence ParseConfidence(string? confidence) => confidence?.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => SecretConfidence.Low,
|
||||
"medium" => SecretConfidence.Medium,
|
||||
"high" => SecretConfidence.High,
|
||||
_ => SecretConfidence.Medium
|
||||
};
|
||||
|
||||
private static async Task<string> ComputeSha256Async(Stream stream, CancellationToken ct)
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// JSON deserialization models
|
||||
private sealed class BundleManifest
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public IntegrityInfo? Integrity { get; set; }
|
||||
}
|
||||
|
||||
private sealed class IntegrityInfo
|
||||
{
|
||||
public string? RulesSha256 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RuleJson
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Pattern { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Confidence { get; set; }
|
||||
public string? MaskingHint { get; set; }
|
||||
public List<string>? Keywords { get; set; }
|
||||
public List<string>? FilePatterns { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public double? EntropyThreshold { get; set; }
|
||||
public int? MinLength { get; set; }
|
||||
public int? MaxLength { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for a secret detection finding.
|
||||
/// </summary>
|
||||
public enum SecretConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - may be a false positive.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - likely a real secret but requires verification.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - almost certainly a real secret.
|
||||
/// </summary>
|
||||
High = 2
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// A single secret detection rule defining patterns and metadata for identifying secrets.
|
||||
/// </summary>
|
||||
public sealed record SecretRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique rule identifier (e.g., "stellaops.secrets.aws-access-key").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule version in SemVer format (e.g., "1.0.0").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable rule name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of what this rule detects.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detection strategy type.
|
||||
/// </summary>
|
||||
public required SecretRuleType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detection pattern (regex pattern for Regex type, entropy config for Entropy type).
|
||||
/// </summary>
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default severity for findings from this rule.
|
||||
/// </summary>
|
||||
public required SecretSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default confidence level for findings from this rule.
|
||||
/// </summary>
|
||||
public required SecretConfidence Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional masking hint (e.g., "prefix:4,suffix:2") for payload masking.
|
||||
/// </summary>
|
||||
public string? MaskingHint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-filter keywords for fast rejection of non-matching content.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Keywords { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Glob patterns for files this rule should be applied to.
|
||||
/// Empty means all text files.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> FilePatterns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this rule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum entropy threshold for entropy-based detection.
|
||||
/// Only used when Type is Entropy or Composite.
|
||||
/// </summary>
|
||||
public double EntropyThreshold { get; init; } = 4.5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum string length for entropy-based detection.
|
||||
/// </summary>
|
||||
public int MinLength { get; init; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum string length for detection (prevents matching entire files).
|
||||
/// </summary>
|
||||
public int MaxLength { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for the rule.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The compiled regex pattern, created lazily.
|
||||
/// </summary>
|
||||
private Regex? _compiledPattern;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compiled regex for this rule. Returns null if the pattern is invalid.
|
||||
/// </summary>
|
||||
public Regex? GetCompiledPattern()
|
||||
{
|
||||
if (Type == SecretRuleType.Entropy)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_compiledPattern is not null)
|
||||
{
|
||||
return _compiledPattern;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_compiledPattern = new Regex(
|
||||
Pattern,
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromSeconds(5));
|
||||
return _compiledPattern;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the content might match this rule based on keywords.
|
||||
/// Returns true if no keywords are defined or if any keyword is found.
|
||||
/// </summary>
|
||||
public bool MightMatch(ReadOnlySpan<char> content)
|
||||
{
|
||||
if (Keywords.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var keyword in Keywords)
|
||||
{
|
||||
if (content.Contains(keyword.AsSpan(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this rule should be applied to the given file path.
|
||||
/// </summary>
|
||||
public bool AppliesToFile(string filePath)
|
||||
{
|
||||
if (FilePatterns.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
foreach (var pattern in FilePatterns)
|
||||
{
|
||||
if (MatchesGlob(fileName, pattern) || MatchesGlob(filePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesGlob(string path, string pattern)
|
||||
{
|
||||
// Simple glob matching for common patterns
|
||||
if (pattern.StartsWith("**", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[2..].TrimStart('/').TrimStart('\\');
|
||||
if (suffix.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var extension = suffix[1..];
|
||||
return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.Contains(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var extension = pattern[1..];
|
||||
return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// The type of detection strategy used by a secret rule.
|
||||
/// </summary>
|
||||
public enum SecretRuleType
|
||||
{
|
||||
/// <summary>
|
||||
/// Regex-based pattern matching.
|
||||
/// </summary>
|
||||
Regex = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Shannon entropy-based detection for high-entropy strings.
|
||||
/// </summary>
|
||||
Entropy = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Combined regex and entropy detection.
|
||||
/// </summary>
|
||||
Composite = 2
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// A versioned collection of secret detection rules.
|
||||
/// </summary>
|
||||
public sealed record SecretRuleset
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format (e.g., "2026.01").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rules in this bundle.
|
||||
/// </summary>
|
||||
public required ImmutableArray<SecretRule> Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the rules file for integrity verification.
|
||||
/// </summary>
|
||||
public string? Sha256Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of this bundle.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets only the enabled rules from this bundle.
|
||||
/// </summary>
|
||||
public IEnumerable<SecretRule> EnabledRules => Rules.Where(r => r.Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of enabled rules.
|
||||
/// </summary>
|
||||
public int EnabledRuleCount => Rules.Count(r => r.Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty ruleset.
|
||||
/// </summary>
|
||||
public static SecretRuleset Empty { get; } = new()
|
||||
{
|
||||
Id = "empty",
|
||||
Version = "0.0",
|
||||
CreatedAt = DateTimeOffset.MinValue,
|
||||
Rules = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all rules in this bundle have valid patterns.
|
||||
/// </summary>
|
||||
/// <returns>A list of validation errors, empty if valid.</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
{
|
||||
errors.Add("Bundle ID is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Version))
|
||||
{
|
||||
errors.Add("Bundle version is required");
|
||||
}
|
||||
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.Id))
|
||||
{
|
||||
errors.Add("Rule ID is required");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(rule.Id))
|
||||
{
|
||||
errors.Add($"Duplicate rule ID: {rule.Id}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rule.Pattern))
|
||||
{
|
||||
errors.Add($"Rule '{rule.Id}' has no pattern");
|
||||
}
|
||||
|
||||
if (rule.Type is SecretRuleType.Regex or SecretRuleType.Composite)
|
||||
{
|
||||
if (rule.GetCompiledPattern() is null)
|
||||
{
|
||||
errors.Add($"Rule '{rule.Id}' has invalid regex pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets rules that apply to the specified file.
|
||||
/// </summary>
|
||||
public IEnumerable<SecretRule> GetRulesForFile(string filePath)
|
||||
{
|
||||
return EnabledRules.Where(r => r.AppliesToFile(filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for a secret detection rule.
|
||||
/// </summary>
|
||||
public enum SecretSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Low severity - informational or low-risk credentials.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity - credentials with limited scope or short lifespan.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High severity - production credentials with broad access.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Critical severity - highly privileged credentials requiring immediate action.
|
||||
/// </summary>
|
||||
Critical = 3
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer that detects accidentally committed secrets in container layers.
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
private readonly IOptions<SecretsAnalyzerOptions> _options;
|
||||
private readonly CompositeSecretDetector _detector;
|
||||
private readonly IPayloadMasker _masker;
|
||||
private readonly ILogger<SecretsAnalyzer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private SecretRuleset? _ruleset;
|
||||
|
||||
public SecretsAnalyzer(
|
||||
IOptions<SecretsAnalyzerOptions> options,
|
||||
CompositeSecretDetector detector,
|
||||
IPayloadMasker masker,
|
||||
ILogger<SecretsAnalyzer> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
|
||||
_masker = masker ?? throw new ArgumentNullException(nameof(masker));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public string Id => "secrets";
|
||||
public string DisplayName => "Secret Leak Detector";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the analyzer is enabled and has a valid ruleset.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Value.Enabled && _ruleset is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently loaded ruleset.
|
||||
/// </summary>
|
||||
public SecretRuleset? Ruleset => _ruleset;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the ruleset to use for detection.
|
||||
/// Called by SecretsAnalyzerHost after loading the bundle.
|
||||
/// </summary>
|
||||
internal void SetRuleset(SecretRuleset ruleset)
|
||||
{
|
||||
_ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset));
|
||||
}
|
||||
|
||||
public async ValueTask AnalyzeAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Secrets analyzer is disabled or has no ruleset");
|
||||
return;
|
||||
}
|
||||
|
||||
var options = _options.Value;
|
||||
var findings = new List<SecretLeakEvidence>();
|
||||
var filesScanned = 0;
|
||||
|
||||
// Scan all text files in the root
|
||||
foreach (var filePath in EnumerateTextFiles(context.RootPath, options))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (findings.Count >= options.MaxFindingsPerScan)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Maximum findings limit ({MaxFindings}) reached, stopping scan",
|
||||
options.MaxFindingsPerScan);
|
||||
break;
|
||||
}
|
||||
|
||||
var fileFindings = await ScanFileAsync(context, filePath, options, cancellationToken);
|
||||
findings.AddRange(fileFindings);
|
||||
filesScanned++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Secrets scan complete: {FileCount} files scanned, {FindingCount} findings",
|
||||
filesScanned,
|
||||
findings.Count);
|
||||
|
||||
// Store findings in analysis store if available
|
||||
if (context.AnalysisStore is not null && findings.Count > 0)
|
||||
{
|
||||
await StoreFindings(context.AnalysisStore, findings, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<List<SecretLeakEvidence>> ScanFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string filePath,
|
||||
SecretsAnalyzerOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var findings = new List<SecretLeakEvidence>();
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (fileInfo.Length > options.MaxFileSizeBytes)
|
||||
{
|
||||
_logger.LogDebug("Skipping large file: {FilePath} ({Size} bytes)", filePath, fileInfo.Length);
|
||||
return findings;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var relativePath = context.GetRelativePath(filePath);
|
||||
|
||||
foreach (var rule in _ruleset!.GetRulesForFile(relativePath))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = await _detector.DetectAsync(content, relativePath, rule, ct);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
// Check confidence threshold
|
||||
var confidence = MapScoreToConfidence(match.ConfidenceScore);
|
||||
if (confidence < options.MinConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset, _timeProvider);
|
||||
findings.Add(evidence);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found secret: Rule={RuleId}, File={FilePath}:{Line}, Mask={Mask}",
|
||||
rule.Id,
|
||||
relativePath,
|
||||
match.LineNumber,
|
||||
evidence.Mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error scanning file: {FilePath}", filePath);
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateTextFiles(string rootPath, SecretsAnalyzerOptions options)
|
||||
{
|
||||
var searchOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System | FileAttributes.Hidden
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(rootPath, "*", searchOptions))
|
||||
{
|
||||
var extension = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
// Check exclusions
|
||||
if (options.ExcludeExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if directory is excluded
|
||||
var relativePath = Path.GetRelativePath(rootPath, file).Replace('\\', '/');
|
||||
if (IsExcludedDirectory(relativePath, options.ExcludeDirectories))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check inclusions if specified
|
||||
if (options.IncludeExtensions.Count > 0 && !options.IncludeExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExcludedDirectory(string relativePath, HashSet<string> patterns)
|
||||
{
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (MatchesGlobPattern(relativePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesGlobPattern(string path, string pattern)
|
||||
{
|
||||
if (pattern.StartsWith("**/", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[3..];
|
||||
if (suffix.EndsWith("/**", StringComparison.Ordinal))
|
||||
{
|
||||
var middle = suffix[..^3];
|
||||
return path.Contains(middle, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.StartsWith(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SecretConfidence MapScoreToConfidence(double score) => score switch
|
||||
{
|
||||
>= 0.9 => SecretConfidence.High,
|
||||
>= 0.7 => SecretConfidence.Medium,
|
||||
_ => SecretConfidence.Low
|
||||
};
|
||||
|
||||
private async ValueTask StoreFindings(
|
||||
object analysisStore,
|
||||
List<SecretLeakEvidence> findings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// TODO: Store findings in ScanAnalysisStore when interface is defined
|
||||
// For now, just log that we would store them
|
||||
_logger.LogDebug("Would store {Count} secret findings in analysis store", findings.Count);
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the lifecycle of the secrets analyzer.
|
||||
/// Loads and validates the rule bundle on startup with optional signature verification.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzerHost : IHostedService
|
||||
{
|
||||
private readonly SecretsAnalyzer _analyzer;
|
||||
private readonly IRulesetLoader _rulesetLoader;
|
||||
private readonly IBundleVerifier? _bundleVerifier;
|
||||
private readonly IOptions<SecretsAnalyzerOptions> _options;
|
||||
private readonly ILogger<SecretsAnalyzerHost> _logger;
|
||||
|
||||
public SecretsAnalyzerHost(
|
||||
SecretsAnalyzer analyzer,
|
||||
IRulesetLoader rulesetLoader,
|
||||
IOptions<SecretsAnalyzerOptions> options,
|
||||
ILogger<SecretsAnalyzerHost> logger,
|
||||
IBundleVerifier? bundleVerifier = null)
|
||||
{
|
||||
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
|
||||
_rulesetLoader = rulesetLoader ?? throw new ArgumentNullException(nameof(rulesetLoader));
|
||||
_bundleVerifier = bundleVerifier;
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle verification result from the last startup, if available.
|
||||
/// </summary>
|
||||
public BundleVerificationResult? LastVerificationResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the analyzer is enabled and has loaded successfully.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _analyzer.IsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the loaded bundle version, if available.
|
||||
/// </summary>
|
||||
public string? BundleVersion => _analyzer.Ruleset?.Version;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Secret leak detection is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Loading secrets rule bundle from {Path}", options.RulesetPath);
|
||||
|
||||
try
|
||||
{
|
||||
// Verify bundle signature if required
|
||||
if (options.RequireSignatureVerification || _bundleVerifier is not null)
|
||||
{
|
||||
await VerifyBundleAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
var ruleset = await _rulesetLoader.LoadAsync(options.RulesetPath, cancellationToken);
|
||||
|
||||
// Validate the ruleset
|
||||
var errors = ruleset.Validate();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"SecretsAnalyzerHost: Bundle validation failed with {ErrorCount} errors: {Errors}",
|
||||
errors.Count,
|
||||
string.Join(", ", errors));
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Secret detection bundle validation failed: {string.Join(", ", errors)}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the ruleset on the analyzer
|
||||
_analyzer.SetRuleset(ruleset);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SecretsAnalyzerHost: Loaded bundle '{BundleId}' version {Version} with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
ruleset.Id,
|
||||
ruleset.Version,
|
||||
ruleset.Rules.Length,
|
||||
ruleset.EnabledRuleCount);
|
||||
}
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SecretsAnalyzerHost: Bundle directory not found, analyzer disabled");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SecretsAnalyzerHost: Bundle file not found, analyzer disabled");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "SecretsAnalyzerHost: Failed to load bundle");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Shutting down");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task VerifyBundleAsync(SecretsAnalyzerOptions options, CancellationToken ct)
|
||||
{
|
||||
if (_bundleVerifier is null)
|
||||
{
|
||||
if (options.RequireSignatureVerification)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Signature verification is required but no IBundleVerifier is registered.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var verificationOptions = new BundleVerificationOptions
|
||||
{
|
||||
RequireRekorProof = options.RequireRekorProof,
|
||||
TrustedKeyIds = options.TrustedKeyIds.Count > 0 ? [.. options.TrustedKeyIds] : null,
|
||||
SharedSecret = options.SignatureSecret,
|
||||
SharedSecretFile = options.SignatureSecretFile,
|
||||
VerifyIntegrity = true,
|
||||
SkipSignatureVerification = !options.RequireSignatureVerification
|
||||
};
|
||||
|
||||
_logger.LogDebug("SecretsAnalyzerHost: Verifying bundle signature");
|
||||
|
||||
var result = await _bundleVerifier.VerifyAsync(
|
||||
options.RulesetPath,
|
||||
verificationOptions,
|
||||
ct);
|
||||
|
||||
LastVerificationResult = result;
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var errorMessage = $"Bundle verification failed: {string.Join("; ", result.ValidationErrors)}";
|
||||
_logger.LogError("SecretsAnalyzerHost: {Error}", errorMessage);
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
// Allow loading but log prominently
|
||||
_logger.LogWarning(
|
||||
"SecretsAnalyzerHost: Continuing with unverified bundle. " +
|
||||
"Set RequireSignatureVerification=true to enforce verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"SecretsAnalyzerHost: Bundle verified - signed by {KeyId} at {SignedAt}",
|
||||
result.SignerKeyId ?? "unknown",
|
||||
result.SignedAt?.ToString("o") ?? "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the secrets analyzer.
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:Analyzers:Secrets";
|
||||
|
||||
/// <summary>
|
||||
/// Enable secret leak detection (experimental feature).
|
||||
/// Default: false (opt-in).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the ruleset bundle directory.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string RulesetPath { get; set; } = "/opt/stellaops/plugins/scanner/analyzers/secrets";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence level to report findings.
|
||||
/// </summary>
|
||||
public SecretConfidence MinConfidence { get; set; } = SecretConfidence.Low;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum findings per scan (circuit breaker).
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int MaxFindingsPerScan { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to scan in bytes.
|
||||
/// Files larger than this are skipped.
|
||||
/// </summary>
|
||||
[Range(1, 100 * 1024 * 1024)]
|
||||
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
/// <summary>
|
||||
/// Enable entropy-based detection.
|
||||
/// </summary>
|
||||
public bool EnableEntropyDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default entropy threshold (bits per character).
|
||||
/// </summary>
|
||||
[Range(3.0, 8.0)]
|
||||
public double EntropyThreshold { get; set; } = 4.5;
|
||||
|
||||
/// <summary>
|
||||
/// File extensions to scan. Empty means all text files.
|
||||
/// </summary>
|
||||
public HashSet<string> IncludeExtensions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// File extensions to exclude from scanning.
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludeExtensions { get; set; } =
|
||||
[
|
||||
".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".mp3", ".mp4", ".avi", ".mov", ".mkv",
|
||||
".ttf", ".woff", ".woff2", ".eot",
|
||||
".min.js", ".min.css"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Directories to exclude from scanning (glob patterns).
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludeDirectories { get; set; } =
|
||||
[
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/vendor/**",
|
||||
"**/__pycache__/**",
|
||||
"**/bin/**",
|
||||
"**/obj/**"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the scan if the bundle cannot be loaded.
|
||||
/// </summary>
|
||||
public bool FailOnInvalidBundle { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require DSSE signature verification for bundles.
|
||||
/// </summary>
|
||||
public bool RequireSignatureVerification { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC signature verification (base64 or hex).
|
||||
/// </summary>
|
||||
public string? SignatureSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SignatureSecretFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs for signature verification.
|
||||
/// If empty, any key is accepted.
|
||||
/// </summary>
|
||||
public HashSet<string> TrustedKeyIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Rekor transparency log proof.
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering secrets analyzer services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the secrets analyzer services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSecretsAnalyzer(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register options
|
||||
services.AddOptions<SecretsAnalyzerOptions>()
|
||||
.Bind(configuration.GetSection(SecretsAnalyzerOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
RegisterCoreServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the secrets analyzer services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSecretsAnalyzer(
|
||||
this IServiceCollection services,
|
||||
Action<SecretsAnalyzerOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
// Register options
|
||||
services.AddOptions<SecretsAnalyzerOptions>()
|
||||
.Configure(configureOptions)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
RegisterCoreServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterCoreServices(IServiceCollection services)
|
||||
{
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<IPayloadMasker, PayloadMasker>();
|
||||
services.AddSingleton<IRulesetLoader, RulesetLoader>();
|
||||
|
||||
// Register detectors
|
||||
services.AddSingleton<RegexDetector>();
|
||||
services.AddSingleton<EntropyDetector>();
|
||||
services.AddSingleton<CompositeSecretDetector>();
|
||||
|
||||
// Register bundle infrastructure (Sprint: SPRINT_20260104_003_SCANNER)
|
||||
services.AddSingleton<IRuleValidator, RuleValidator>();
|
||||
services.AddSingleton<IBundleBuilder, BundleBuilder>();
|
||||
services.AddSingleton<IBundleSigner, BundleSigner>();
|
||||
services.AddSingleton<IBundleVerifier, BundleVerifier>();
|
||||
|
||||
// Register analyzer
|
||||
services.AddSingleton<SecretsAnalyzer>();
|
||||
services.AddSingleton<ILanguageAnalyzer>(sp => sp.GetRequiredService<SecretsAnalyzer>());
|
||||
|
||||
// Register hosted service
|
||||
services.AddSingleton<SecretsAnalyzerHost>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<SecretsAnalyzerHost>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.CallGraph.Binary;
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using StellaOps.Scanner.CallGraph.Go;
|
||||
@@ -40,6 +41,7 @@ public static class CallGraphServiceCollectionExtensions
|
||||
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>(); // Node.js/JavaScript via Babel
|
||||
services.AddSingleton<ICallGraphExtractor, PythonCallGraphExtractor>(); // Python via AST analysis
|
||||
services.AddSingleton<ICallGraphExtractor, GoCallGraphExtractor>(); // Go via SSA analysis
|
||||
services.AddSingleton<ICallGraphExtractor, BinaryCallGraphExtractor>(); // Native ELF/PE/Mach-O binaries
|
||||
|
||||
// Register the extractor registry for language-based lookup
|
||||
services.AddSingleton<ICallGraphExtractorRegistry, CallGraphExtractorRegistry>();
|
||||
|
||||
@@ -50,4 +50,8 @@ public static class ScanAnalysisKeys
|
||||
public const string VulnerabilityMatches = "analysis.poe.vulnerability.matches";
|
||||
public const string PoEResults = "analysis.poe.results";
|
||||
public const string PoEConfiguration = "analysis.poe.configuration";
|
||||
|
||||
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
public const string SecretFindings = "analysis.secrets.findings";
|
||||
public const string SecretRulesetVersion = "analysis.secrets.ruleset.version";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleBuilderTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle building.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _outputDir;
|
||||
private readonly BundleBuilder _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"bundle-test-{Guid.NewGuid():N}");
|
||||
_sourceDir = Path.Combine(_tempDir, "sources");
|
||||
_outputDir = Path.Combine(_tempDir, "output");
|
||||
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
|
||||
var validator = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
_sut = new BundleBuilder(validator, NullLogger<BundleBuilder>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ValidRules_CreatesBundle()
|
||||
{
|
||||
// Arrange
|
||||
var rule1Path = CreateRuleFile("rule1.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule1",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 1",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var rule2Path = CreateRuleFile("rule2.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule2",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 2",
|
||||
Description = "Another test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[0-9]{8}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rule1Path, rule2Path },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "test-bundle",
|
||||
Version = "2026.01",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(artifact);
|
||||
Assert.Equal("test-bundle", artifact.Manifest.Id);
|
||||
Assert.Equal("2026.01", artifact.Manifest.Version);
|
||||
Assert.Equal(2, artifact.TotalRules);
|
||||
Assert.Equal(2, artifact.EnabledRules);
|
||||
Assert.True(File.Exists(artifact.ManifestPath));
|
||||
Assert.True(File.Exists(artifact.RulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SortsRulesById()
|
||||
{
|
||||
// Arrange
|
||||
var zebraPath = CreateRuleFile("z-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.zebra",
|
||||
Version = "1.0.0",
|
||||
Name = "Zebra Rule",
|
||||
Description = "Rule that should sort last",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "zebra",
|
||||
Severity = SecretSeverity.Low,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var alphaPath = CreateRuleFile("a-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.alpha",
|
||||
Version = "1.0.0",
|
||||
Name = "Alpha Rule",
|
||||
Description = "Rule that should sort first",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "alpha",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { zebraPath, alphaPath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "sorted-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - check the manifest rules array is sorted (manifest is already built)
|
||||
Assert.Equal(2, artifact.Manifest.Rules.Length);
|
||||
Assert.Equal("stellaops.secrets.alpha", artifact.Manifest.Rules[0].Id);
|
||||
Assert.Equal("stellaops.secrets.zebra", artifact.Manifest.Rules[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ComputesCorrectSha256()
|
||||
{
|
||||
// Arrange
|
||||
var rulePath = CreateRuleFile("rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.hash-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Hash Test",
|
||||
Description = "Rule for testing SHA-256 computation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test123",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "hash-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(artifact.RulesSha256);
|
||||
Assert.Matches("^[a-f0-9]{64}$", artifact.RulesSha256);
|
||||
|
||||
// Verify hash matches file content
|
||||
await using var stream = File.OpenRead(artifact.RulesPath);
|
||||
var hash = await System.Security.Cryptography.SHA256.HashDataAsync(stream, TestContext.Current.CancellationToken);
|
||||
var expectedHash = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
Assert.Equal(expectedHash, artifact.RulesSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_InvalidRule_ThrowsException()
|
||||
{
|
||||
// Arrange - create an invalid rule (id not properly namespaced)
|
||||
var invalidRulePath = Path.Combine(_sourceDir, "invalid-rule.json");
|
||||
var invalidRuleJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = "invalid", // Not namespaced with stellaops.secrets
|
||||
version = "1.0.0",
|
||||
name = "Invalid Rule",
|
||||
description = "This rule has an invalid ID",
|
||||
type = "regex",
|
||||
pattern = "test",
|
||||
severity = "medium",
|
||||
confidence = "medium"
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(invalidRulePath, invalidRuleJson, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { invalidRulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "invalid-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_EmptyRuleFiles_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = Array.Empty<string>(),
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "empty-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NonexistentRuleFile_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { Path.Combine(_tempDir, "nonexistent.json") },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "missing-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private string CreateRuleFile(string filename, SecretRule rule)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var path = Path.Combine(_sourceDir, filename);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleSignerTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle signing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleSignerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleSigner _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleSignerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"signer-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ValidArtifact_CreatesDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]), // 256-bit key
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(File.Exists(result.EnvelopePath));
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Single(result.Envelope.Signatures);
|
||||
Assert.Equal("test-key-001", result.Envelope.Signatures[0].KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UpdatesManifestWithSignatureInfo()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "signer-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.UpdatedManifest.Signatures);
|
||||
Assert.Equal("signer-key", result.UpdatedManifest.Signatures.KeyId);
|
||||
Assert.Equal("secrets.ruleset.dsse.json", result.UpdatedManifest.Signatures.DsseEnvelope);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.UpdatedManifest.Signatures.SignedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_EnvelopeContainsBase64UrlPayload()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Envelope.Payload);
|
||||
// Base64url should not contain +, /, or =
|
||||
Assert.DoesNotContain("+", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("/", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("=", result.Envelope.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSecretFile_LoadsSecret()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var secretFile = Path.Combine(_tempDir, "secret.key");
|
||||
var secret = Convert.ToBase64String(new byte[32]);
|
||||
await File.WriteAllTextAsync(secretFile, secret, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "file-key",
|
||||
SharedSecretFile = secretFile,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithoutSecret_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "no-secret-key",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UnsupportedAlgorithm_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
Algorithm = "ES256", // Not supported
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_HexEncodedSecret_Works()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var hexSecret = new string('a', 64); // 32 bytes as hex
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "hex-key",
|
||||
SharedSecret = hexSecret,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
private BundleArtifact CreateTestArtifact()
|
||||
{
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = new string('0', 64),
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_tempDir, "secrets.ruleset.manifest.json");
|
||||
var rulesPath = Path.Combine(_tempDir, "secrets.ruleset.rules.jsonl");
|
||||
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.WriteAllText(rulesPath, "{\"id\":\"test.rule\"}");
|
||||
|
||||
return new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = rulesPath,
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleVerifierTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleVerifier _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly byte[] _testSecret;
|
||||
|
||||
public BundleVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"verifier-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleVerifier(NullLogger<BundleVerifier>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_testSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(_testSecret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-bundle", result.BundleId);
|
||||
Assert.Equal("1.0.0", result.BundleVersion);
|
||||
Assert.Empty(result.ValidationErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedRulesFile_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Tamper with the rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.AppendAllTextAsync(rulesPath, "\n{\"id\":\"injected.rule\"}", TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("integrity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var wrongSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(wrongSecret);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(wrongSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("signature", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingManifest_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = Path.Combine(_tempDir, "missing-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("manifest", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NonexistentDirectory_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleVerificationOptions();
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(Path.Combine(_tempDir, "nonexistent"), options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SkipSignatureVerification_OnlyChecksIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateUnsignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SkipSignatureVerification = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UntrustedKeyId_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "other-trusted-key" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("trusted", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TrustedKeyId_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "test-key-001" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-key-001", result.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RequireRekorProof_ReturnsWarningWhenNotVerified()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleWithRekorAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
RequireRekorProof = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert (Rekor verification not implemented, should have warning)
|
||||
Assert.NotEmpty(result.ValidationWarnings);
|
||||
Assert.Contains(result.ValidationWarnings, w => w.Contains("Rekor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<string> CreateUnsignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempDir, $"bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
// Create rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
var ruleJson = JsonSerializer.Serialize(new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test",
|
||||
Description = "A test rule for verification tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
});
|
||||
await File.WriteAllTextAsync(rulesPath, ruleJson, ct);
|
||||
|
||||
// Compute hash
|
||||
await using var stream = File.OpenRead(rulesPath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
// Create manifest
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = hashHex,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateUnsignedBundleAsync(ct);
|
||||
|
||||
// Sign the bundle
|
||||
var signer = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var artifact = new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl"),
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
|
||||
await signer.SignAsync(artifact, new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TimeProvider = _timeProvider
|
||||
}, ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleWithRekorAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateSignedBundleAsync(ct);
|
||||
|
||||
// Update manifest to include Rekor log ID
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var updatedManifest = manifest with
|
||||
{
|
||||
Signatures = manifest.Signatures! with
|
||||
{
|
||||
RekorLogId = "rekor-log-entry-123456"
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(updatedManifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleValidatorTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for rule validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class RuleValidatorTests
|
||||
{
|
||||
private readonly RuleValidator _sut;
|
||||
|
||||
public RuleValidatorTests()
|
||||
{
|
||||
_sut = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid-id")] // No namespace separator (no dots)
|
||||
[InlineData("InvalidCase.rule")] // Starts with uppercase
|
||||
public void Validate_InvalidId_ReturnsError(string invalidId)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = invalidId,
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("ID"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("1.0")]
|
||||
[InlineData("v1.0.0")]
|
||||
public void Validate_InvalidVersion_ReturnsError(string invalidVersion)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = invalidVersion,
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("version", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[")]
|
||||
[InlineData("(unclosed")]
|
||||
[InlineData("(?invalid)")]
|
||||
public void Validate_InvalidRegex_ReturnsError(string invalidPattern)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = invalidPattern,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("regex", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPattern_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidEntropyRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 4.5
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithDefaultThreshold_ReturnsValid()
|
||||
{
|
||||
// Arrange - using the default entropy threshold (4.5) which is in valid range
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with default threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
// Default entropy threshold is 4.5, which is in the valid range
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - default threshold (4.5) is valid, no warnings expected
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithOutOfRangeThreshold_ReturnsWarning()
|
||||
{
|
||||
// Arrange - using an out-of-range threshold
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with extreme threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 0 // Zero triggers <= 0 warning condition
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - valid but with warning about unusual threshold
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("entropy", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EntropyCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calculate_EmptyString_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(string.Empty);
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_SingleCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("a");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_RepeatedCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("aaaaaaaaaa");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_TwoDistinctCharacters_ReturnsOne()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("ababababab");
|
||||
|
||||
entropy.Should().BeApproximately(1.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FourDistinctCharacters_ReturnsTwo()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("abcdabcdabcd");
|
||||
|
||||
entropy.Should().BeApproximately(2.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_HighEntropyString_ReturnsHighValue()
|
||||
{
|
||||
var highEntropyString = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(highEntropyString);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_LowEntropyPassword_ReturnsLowValue()
|
||||
{
|
||||
var lowEntropyString = "password";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(lowEntropyString);
|
||||
|
||||
entropy.Should().BeLessThan(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AwsAccessKeyPattern_ReturnsHighEntropy()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(awsKey);
|
||||
|
||||
entropy.Should().BeGreaterThan(3.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Base64String_ReturnsHighEntropy()
|
||||
{
|
||||
var base64 = "SGVsbG8gV29ybGQhIFRoaXMgaXMgYSB0ZXN0";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(base64);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsDeterministic()
|
||||
{
|
||||
var input = "TestString123!@#";
|
||||
|
||||
var entropy1 = EntropyCalculator.Calculate(input);
|
||||
var entropy2 = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy1.Should().Be(entropy2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0123456789", 3.32)]
|
||||
[InlineData("abcdefghij", 3.32)]
|
||||
[InlineData("ABCDEFGHIJ", 3.32)]
|
||||
public void Calculate_KnownPatterns_ReturnsExpectedEntropy(string input, double expectedEntropy)
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy.Should().BeApproximately(expectedEntropy, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PayloadMaskerTests
|
||||
{
|
||||
private readonly PayloadMasker _masker = new();
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptySpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<char>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ShortValue_ReturnsMaskChars()
|
||||
{
|
||||
// Values shorter than prefix+suffix get masked placeholder
|
||||
var result = _masker.Mask("abc".AsSpan());
|
||||
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_StandardValue_PreservesPrefixAndSuffix()
|
||||
{
|
||||
var result = _masker.Mask("1234567890".AsSpan());
|
||||
|
||||
// Default: 4 char prefix, 2 char suffix
|
||||
result.Should().StartWith("1234");
|
||||
result.Should().EndWith("90");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_AwsAccessKey_PreservesPrefix()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result = _masker.Mask(awsKey.AsSpan());
|
||||
|
||||
result.Should().StartWith("AKIA");
|
||||
result.Should().EndWith("LE");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_WithPrefixHint_UsesCustomPrefixLength()
|
||||
{
|
||||
var apiKey = "sk-proj-abcdefghijklmnop";
|
||||
|
||||
// MaxExposedChars is 6, so prefix:8 + suffix:2 gets scaled down
|
||||
var result = _masker.Mask(apiKey.AsSpan(), "prefix:4,suffix:2");
|
||||
|
||||
result.Should().StartWith("sk-p");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_LongValue_MasksMiddle()
|
||||
{
|
||||
var longSecret = "verylongsecretthatexceeds100characters" +
|
||||
"andshouldbemaskkedproperlywithoutexpo" +
|
||||
"singtheentirecontentstoanyoneviewingit";
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Should contain mask characters and be shorter than original
|
||||
result.Should().Contain("****");
|
||||
result.Length.Should().BeLessThan(longSecret.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_IsDeterministic()
|
||||
{
|
||||
var secret = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan());
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_NeverExposesFullSecret()
|
||||
{
|
||||
var secret = "supersecretkey123";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().NotBe(secret);
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("prefix:6,suffix:0")]
|
||||
[InlineData("prefix:0,suffix:6")]
|
||||
[InlineData("prefix:3,suffix:3")]
|
||||
public void Mask_WithVariousHints_RespectsTotalLimit(string hint)
|
||||
{
|
||||
var secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan(), hint);
|
||||
|
||||
var visibleChars = result.Replace("*", "").Length;
|
||||
visibleChars.Should().BeLessThanOrEqualTo(PayloadMasker.MaxExposedChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EnforcesMinOutputLength()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Length.Should().BeGreaterThanOrEqualTo(PayloadMasker.MinOutputLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ByteOverload_DecodesUtf8()
|
||||
{
|
||||
var text = "secretpassword123";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
|
||||
var result = _masker.Mask(bytes.AsSpan());
|
||||
|
||||
result.Should().Contain("****");
|
||||
result.Should().StartWith("secr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptyByteSpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<byte>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_InvalidHint_UsesDefaults()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan(), "invalid:hint:format");
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_UsesCorrectMaskChar()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().Contain(PayloadMasker.MaskChar.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_MaskLengthLimited()
|
||||
{
|
||||
var longSecret = new string('x', 100);
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Count mask characters
|
||||
var maskCount = result.Count(c => c == PayloadMasker.MaskChar);
|
||||
maskCount.Should().BeLessThanOrEqualTo(PayloadMasker.MaxMaskLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegexDetectorTests
|
||||
{
|
||||
private readonly RegexDetector _detector = new(NullLogger<RegexDetector>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void DetectorId_ReturnsRegex()
|
||||
{
|
||||
_detector.DetectorId.Should().Be("regex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_RegexType_ReturnsTrue()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Regex).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_EntropyType_ReturnsFalse()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Entropy).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_CompositeType_ReturnsTrue()
|
||||
{
|
||||
// RegexDetector handles both Regex and Composite types
|
||||
_detector.CanHandle(SecretRuleType.Composite).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("no aws key here");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_SingleMatch_ReturnsOne()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("aws_key = AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].Rule.Id.Should().Be("test-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_MultipleMatches_ReturnsAll()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"key1 = AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"key2 = AKIABCDEFGHIJKLMNOP1");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectLineNumber()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key\s*=\s*\S+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"# config file\n" +
|
||||
"debug = true\n" +
|
||||
"secret_key = mysecretvalue\n" +
|
||||
"port = 8080");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"config.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectColumn()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key");
|
||||
var content = Encoding.UTF8.GetBytes("config: secret_key = value");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
// "secret_key" starts at index 8 (0-based), column 9 (1-based)
|
||||
matches[0].ColumnStart.Should().Be(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_HandlesMultilineContent()
|
||||
{
|
||||
var rule = CreateRule(@"API_KEY\s*=\s*\w+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"line1\n" +
|
||||
"line2\n" +
|
||||
"API_KEY = abc123\n" +
|
||||
"line4\n" +
|
||||
"API_KEY = xyz789");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
matches[1].LineNumber.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_DisabledRule_StillProcesses()
|
||||
{
|
||||
// Note: The detector doesn't filter by Enabled status.
|
||||
// Filtering disabled rules is the caller's responsibility (e.g., SecretsAnalyzer)
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}", enabled: false);
|
||||
var content = Encoding.UTF8.GetBytes("AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Detector processes regardless of Enabled flag
|
||||
matches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_RespectsCancellation()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
var content = Encoding.UTF8.GetBytes("test content");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
// When cancellation is already requested, detector returns empty (doesn't throw)
|
||||
var matches = await _detector.DetectAsync(content.AsMemory(), "test.txt", rule, cts.Token);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_IncludesFilePath()
|
||||
{
|
||||
var rule = CreateRule(@"secret");
|
||||
var content = Encoding.UTF8.GetBytes("mysecret");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"path/to/file.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].FilePath.Should().Be("path/to/file.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_LargeFile_HandlesEfficiently()
|
||||
{
|
||||
var rule = CreateRule(@"SECRET_KEY");
|
||||
var lines = Enumerable.Range(0, 10000)
|
||||
.Select(i => i == 5000 ? "SECRET_KEY = value" : $"line {i}")
|
||||
.ToArray();
|
||||
var content = Encoding.UTF8.GetBytes(string.Join("\n", lines));
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"large.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(5001);
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern, bool enabled = true)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule for unit tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RulesetLoaderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RulesetLoader _loader;
|
||||
|
||||
public RulesetLoaderTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"secrets-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_loader = new RulesetLoader(NullLogger<RulesetLoader>.Instance, _timeProvider);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidBundle_LoadsRuleset()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-secrets");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingDirectory_ThrowsDirectoryNotFound()
|
||||
{
|
||||
var nonExistentPath = Path.Combine(_testDir, "does-not-exist");
|
||||
|
||||
await Assert.ThrowsAsync<DirectoryNotFoundException>(
|
||||
() => _loader.LoadAsync(nonExistentPath, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingManifest_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
"""{"id":"rule1","pattern":"test"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingRulesFile_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
"""{"id":"test","version":"1.0.0"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_InvalidIntegrity_ThrowsException()
|
||||
{
|
||||
await CreateBundleWithBadIntegrityAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SortsRulesById()
|
||||
{
|
||||
await CreateBundleWithUnorderedRulesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Select(r => r.Id).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsBlankLines()
|
||||
{
|
||||
await CreateBundleWithBlankLinesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidJsonLines()
|
||||
{
|
||||
await CreateBundleWithInvalidJsonAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
// JSONL processes each line independently - invalid lines are skipped but don't stop processing
|
||||
// So we get rule1 and rule2 (2 rules), with the invalid line skipped
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SetsCreatedAt()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ValidStream_LoadsRules()
|
||||
{
|
||||
var jsonl = """
|
||||
{"id":"rule1","version":"1.0","name":"Rule 1","type":"regex","pattern":"secret","severity":"high","confidence":"high"}
|
||||
{"id":"rule2","version":"1.0","name":"Rule 2","type":"regex","pattern":"password","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-bundle");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_DefaultValues_AppliedCorrectly()
|
||||
{
|
||||
var jsonl = """{"id":"minimal-rule","pattern":"test"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var rule = ruleset.Rules[0];
|
||||
rule.Version.Should().Be("1.0.0");
|
||||
rule.Enabled.Should().BeTrue();
|
||||
rule.Severity.Should().Be(SecretSeverity.Medium);
|
||||
rule.Confidence.Should().Be(SecretConfidence.Medium);
|
||||
rule.Type.Should().Be(SecretRuleType.Regex);
|
||||
rule.EntropyThreshold.Should().Be(4.5);
|
||||
rule.MinLength.Should().Be(16);
|
||||
rule.MaxLength.Should().Be(1000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("regex", SecretRuleType.Regex)]
|
||||
[InlineData("entropy", SecretRuleType.Entropy)]
|
||||
[InlineData("composite", SecretRuleType.Composite)]
|
||||
[InlineData("REGEX", SecretRuleType.Regex)]
|
||||
[InlineData("unknown", SecretRuleType.Regex)]
|
||||
public async Task LoadFromJsonlAsync_ParsesRuleType(string typeString, SecretRuleType expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","type":"{{{typeString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Type.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("low", SecretSeverity.Low)]
|
||||
[InlineData("medium", SecretSeverity.Medium)]
|
||||
[InlineData("high", SecretSeverity.High)]
|
||||
[InlineData("critical", SecretSeverity.Critical)]
|
||||
[InlineData("HIGH", SecretSeverity.High)]
|
||||
public async Task LoadFromJsonlAsync_ParsesSeverity(string severityString, SecretSeverity expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","severity":"{{{severityString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Severity.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesKeywords()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","keywords":["aws","key","secret"]}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Keywords.Should().BeEquivalentTo(["aws", "key", "secret"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesMetadata()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","metadata":{"source":"gitleaks","category":"api-key"}}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Metadata.Should().Contain("source", "gitleaks");
|
||||
ruleset.Rules[0].Metadata.Should().Contain("category", "api-key");
|
||||
}
|
||||
|
||||
private async Task CreateValidBundleAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"aws-key","version":"1.0","name":"AWS Access Key","type":"regex","pattern":"AKIA[0-9A-Z]{16}","severity":"critical","confidence":"high"}
|
||||
{"id":"generic-secret","version":"1.0","name":"Generic Secret","type":"regex","pattern":"secret[_-]?key","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
var hash = await ComputeHashAsync(rulesPath, ct);
|
||||
var manifest = $$$"""{"id":"test-secrets","version":"1.0.0","integrity":{"rulesSha256":"{{{hash}}}"}}""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBadIntegrityAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """{"id":"rule1","pattern":"test"}""";
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
// Use a known bad hash (clearly different from any real SHA-256)
|
||||
const string badHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var manifest = $$$"""{"id":"test","version":"1.0","integrity":{"rulesSha256":"{{{badHash}}}"}}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithUnorderedRulesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"z-rule","pattern":"z"}
|
||||
{"id":"a-rule","pattern":"a"}
|
||||
{"id":"m-rule","pattern":"m"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBlankLinesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"test1"}
|
||||
|
||||
{"id":"rule2","pattern":"test2"}
|
||||
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithInvalidJsonAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"valid"}
|
||||
not valid json at all
|
||||
{"id":"rule2","pattern":"also valid but will be skipped due to earlier error?"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCompiledPattern_ValidRegex_ReturnsRegex()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().NotBeNull();
|
||||
regex!.IsMatch("AKIAIOSFODNN7EXAMPLE").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_InvalidRegex_ReturnsNull()
|
||||
{
|
||||
var rule = CreateRule(@"[invalid(regex");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_IsCached()
|
||||
{
|
||||
var rule = CreateRule(@"test\d+");
|
||||
|
||||
var regex1 = rule.GetCompiledPattern();
|
||||
var regex2 = rule.GetCompiledPattern();
|
||||
|
||||
regex1.Should().BeSameAs(regex2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_EntropyType_ReturnsNull()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "entropy-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Rule",
|
||||
Description = "Test",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = string.Empty,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 4.5,
|
||||
MinLength = 16,
|
||||
MaxLength = 100,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_NoPatterns_MatchesAll()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.AppliesToFile("any/path/file.txt").Should().BeTrue();
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("secrets.yaml").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_WithExtensionPattern_FiltersByExtension()
|
||||
{
|
||||
var rule = CreateRuleWithFilePatterns(@"test", "*.json", "*.yaml");
|
||||
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("config.yaml").Should().BeTrue();
|
||||
rule.AppliesToFile("config.xml").Should().BeFalse();
|
||||
rule.AppliesToFile("config.txt").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_NoKeywords_ReturnsTrue()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.MightMatch("any content here".AsSpan()).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_WithKeywords_MatchesIfKeywordFound()
|
||||
{
|
||||
var rule = CreateRuleWithKeywords(@"test", "secret", "password");
|
||||
|
||||
rule.MightMatch("contains secret here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("contains password here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("no matching content".AsSpan()).Should().BeFalse();
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithFilePatterns(string pattern, params string[] filePatterns)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [..filePatterns],
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithKeywords(string pattern, params string[] keywords)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = [..keywords],
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRulesetTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnabledRuleCount_ReturnsCorrectCount()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllDisabled_ReturnsZero()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: false),
|
||||
CreateRule("rule2", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllEnabled_ReturnsTotal()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_ReturnsEnabledMatchingRules()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true),
|
||||
CreateRuleWithPattern("yaml-rule", "*.yaml", enabled: true),
|
||||
CreateRuleWithPattern("disabled-rule", "*.json", enabled: false));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.json").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("json-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_NoMatchingPatterns_ReturnsRulesWithNoPatterns()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("generic-rule", enabled: true),
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.xml").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("generic-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRuleset_ReturnsEmpty()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateIds_ReturnsError()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("same-id", enabled: true),
|
||||
CreateRule("same-id", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("Duplicate", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_ReturnsError()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "bad-regex",
|
||||
Version = "1.0.0",
|
||||
Name = "Bad Regex",
|
||||
Description = "Invalid regex pattern",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[invalid(regex",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var ruleset = new SecretRuleset
|
||||
{
|
||||
Id = "test",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [rule]
|
||||
};
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("bad-regex", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.Contains("invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRules_ReturnsOnlyEnabled()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: false),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
var enabled = ruleset.EnabledRules.ToList();
|
||||
|
||||
enabled.Should().HaveCount(2);
|
||||
enabled.Select(r => r.Id).Should().BeEquivalentTo(["rule1", "rule3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsEmptyRuleset()
|
||||
{
|
||||
var empty = SecretRuleset.Empty;
|
||||
|
||||
empty.Id.Should().Be("empty");
|
||||
empty.Version.Should().Be("0.0");
|
||||
empty.Rules.Should().BeEmpty();
|
||||
empty.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static SecretRuleset CreateRuleset(params SecretRule[] rules)
|
||||
{
|
||||
return new SecretRuleset
|
||||
{
|
||||
Id = "test-ruleset",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [..rules]
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string id, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithPattern(string id, string filePattern, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [filePattern],
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures/**/*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,469 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphDigestsTests.cs
|
||||
// Sprint: SPRINT_20260104_001_CLI
|
||||
// Description: Unit tests for call graph digest computation and determinism.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class CallGraphDigestsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
Assert.Equal(71, digest.Length); // "sha256:" (7) + 64 hex chars
|
||||
Assert.True(IsValidHex(digest[7..]), "Digest should be valid hex string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest3 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.Equal(digest2, digest3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EquivalentSnapshotsProduceSameDigest()
|
||||
{
|
||||
// Arrange - two separately created but equivalent snapshots
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow.AddMinutes(5), // Different timestamp
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because ExtractedAt is not part of the digest payload
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_DifferentNodesProduceDifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_NodeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - nodes in different order
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts nodes
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - edges in different order
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null),
|
||||
new CallGraphNode("node-c", "func_c", "test.c", 30, "pkg", Visibility.Internal, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts edges
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_WhitespaceIsTrimmed()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: " test-scan ",
|
||||
GraphDigest: "",
|
||||
Language: " native ",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(" node-a ", " func_a ", " test.c ", 10, " pkg ", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() trims whitespace
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ThrowsOnNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => CallGraphDigests.ComputeGraphDigest(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_HandlesEmptySnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_LanguageAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeExplanationAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, null)
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, CallEdgeExplanation.DirectCall())
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:main";
|
||||
|
||||
// Act
|
||||
var nodeId = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(nodeId);
|
||||
Assert.StartsWith("sha256:", nodeId, StringComparison.Ordinal);
|
||||
Assert.Equal(71, nodeId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:SSL_read";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId);
|
||||
var id3 = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id1, id2);
|
||||
Assert.Equal(id2, id3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_DifferentSymbolsProduceDifferentIds()
|
||||
{
|
||||
// Arrange
|
||||
var stableId1 = "native:func_a";
|
||||
var stableId2 = "native:func_b";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId1);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_CreatesConsistentFormat()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId("Native", "SSL_read");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_TrimsWhitespace()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId(" Native ", " SSL_read ");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMinimalSnapshot()
|
||||
{
|
||||
return new CallGraphSnapshot(
|
||||
ScanId: "test-scan-001",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "sha256:abc123",
|
||||
Symbol: "main",
|
||||
File: "main.c",
|
||||
Line: 1,
|
||||
Package: "test-binary",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.CliCommand,
|
||||
IsSink: false,
|
||||
SinkCategory: null
|
||||
)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray.Create("sha256:abc123"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsValidHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
return false;
|
||||
|
||||
foreach (char c in hex)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Delta;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.WebService.Extensions;
|
||||
|
||||
@@ -73,6 +76,32 @@ public static class VexLensEndpointExtensions
|
||||
.WithDescription("Get projections with conflicts")
|
||||
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Delta/Noise-Gating endpoints
|
||||
var deltaGroup = app.MapGroup("/api/v1/vexlens/deltas")
|
||||
.WithTags("VexLens Delta")
|
||||
.WithOpenApi();
|
||||
|
||||
deltaGroup.MapPost("/compute", ComputeDeltaAsync)
|
||||
.WithName("ComputeDelta")
|
||||
.WithDescription("Compute delta report between two snapshots")
|
||||
.Produces<DeltaReportResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
var gatingGroup = app.MapGroup("/api/v1/vexlens/gating")
|
||||
.WithTags("VexLens Gating")
|
||||
.WithOpenApi();
|
||||
|
||||
gatingGroup.MapGet("/statistics", GetGatingStatisticsAsync)
|
||||
.WithName("GetGatingStatistics")
|
||||
.WithDescription("Get aggregated noise-gating statistics")
|
||||
.Produces<AggregatedGatingStatisticsResponse>(StatusCodes.Status200OK);
|
||||
|
||||
gatingGroup.MapPost("/snapshots/{snapshotId}/gate", GateSnapshotAsync)
|
||||
.WithName("GateSnapshot")
|
||||
.WithDescription("Apply noise-gating to a snapshot")
|
||||
.Produces<GatedSnapshotResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Issuer endpoints
|
||||
var issuerGroup = app.MapGroup("/api/v1/vexlens/issuers")
|
||||
.WithTags("VexLens Issuers")
|
||||
@@ -265,6 +294,91 @@ public static class VexLensEndpointExtensions
|
||||
return Results.Ok(conflictsOnly);
|
||||
}
|
||||
|
||||
// Delta/Noise-Gating handlers
|
||||
private static async Task<IResult> ComputeDeltaAsync(
|
||||
[FromBody] ComputeDeltaRequest request,
|
||||
[FromServices] INoiseGate noiseGate,
|
||||
[FromServices] ISnapshotStore snapshotStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? request.TenantId;
|
||||
|
||||
// Get snapshots
|
||||
var fromSnapshot = await snapshotStore.GetAsync(request.FromSnapshotId, tenantId, cancellationToken);
|
||||
var toSnapshot = await snapshotStore.GetAsync(request.ToSnapshotId, tenantId, cancellationToken);
|
||||
|
||||
if (fromSnapshot is null || toSnapshot is null)
|
||||
{
|
||||
return Results.BadRequest("One or both snapshot IDs not found");
|
||||
}
|
||||
|
||||
// Compute delta
|
||||
var options = NoiseGatingApiMapper.MapOptions(request.Options);
|
||||
var delta = await noiseGate.DiffAsync(fromSnapshot, toSnapshot, options, cancellationToken);
|
||||
|
||||
return Results.Ok(NoiseGatingApiMapper.MapToResponse(delta));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetGatingStatisticsAsync(
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] DateTimeOffset? fromDate,
|
||||
[FromQuery] DateTimeOffset? toDate,
|
||||
[FromServices] IGatingStatisticsStore statsStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = GetTenantId(context) ?? tenantId;
|
||||
var stats = await statsStore.GetAggregatedAsync(tenant, fromDate, toDate, cancellationToken);
|
||||
|
||||
return Results.Ok(new AggregatedGatingStatisticsResponse(
|
||||
TotalSnapshots: stats.TotalSnapshots,
|
||||
TotalEdgesProcessed: stats.TotalEdgesProcessed,
|
||||
TotalEdgesAfterDedup: stats.TotalEdgesAfterDedup,
|
||||
AverageEdgeReductionPercent: stats.AverageEdgeReductionPercent,
|
||||
TotalVerdicts: stats.TotalVerdicts,
|
||||
TotalSurfaced: stats.TotalSurfaced,
|
||||
TotalDamped: stats.TotalDamped,
|
||||
AverageDampingPercent: stats.AverageDampingPercent,
|
||||
ComputedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GateSnapshotAsync(
|
||||
string snapshotId,
|
||||
[FromBody] GateSnapshotRequest request,
|
||||
[FromServices] INoiseGate noiseGate,
|
||||
[FromServices] ISnapshotStore snapshotStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context) ?? request.TenantId;
|
||||
|
||||
// Get the raw snapshot
|
||||
var snapshot = await snapshotStore.GetRawAsync(snapshotId, tenantId, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Apply noise-gating
|
||||
var gateRequest = new NoiseGateRequest
|
||||
{
|
||||
Graph = snapshot.Graph,
|
||||
SnapshotId = snapshotId,
|
||||
Verdicts = snapshot.Verdicts
|
||||
};
|
||||
|
||||
var gatedSnapshot = await noiseGate.GateAsync(gateRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(new GatedSnapshotResponse(
|
||||
SnapshotId: gatedSnapshot.SnapshotId,
|
||||
Digest: gatedSnapshot.Digest,
|
||||
CreatedAt: gatedSnapshot.CreatedAt,
|
||||
EdgeCount: gatedSnapshot.Edges.Count,
|
||||
VerdictCount: gatedSnapshot.Verdicts.Count,
|
||||
Statistics: NoiseGatingApiMapper.MapStatistics(gatedSnapshot.Statistics)));
|
||||
}
|
||||
|
||||
// Issuer handlers
|
||||
private static async Task<IResult> ListIssuersAsync(
|
||||
[FromQuery] string? category,
|
||||
|
||||
@@ -7,6 +7,12 @@ Deliver the VEX Consensus Lens service that normalizes VEX evidence, computes de
|
||||
- Service code under `src/VexLens/StellaOps.VexLens` (normalizer, mapping, trust weighting, consensus projection, APIs, simulation hooks).
|
||||
- Batch workers consuming Excitor, Conseiller, SBOM, and policy events; projection storage and caching; telemetry.
|
||||
- Coordination with Policy Engine, Vuln Explorer, Findings Ledger, Console, CLI, and Docs.
|
||||
- **NoiseGate** (Sprint NG-001): Unified noise-gating for vulnerability graphs:
|
||||
- **INoiseGate**: Central interface for noise-gating operations
|
||||
- **EdgeDeduplicator**: Collapses semantically equivalent edges (uses StellaOps.ReachGraph)
|
||||
- **StabilityDampingGate**: Hysteresis-based damping (uses StellaOps.Policy.Engine.Gates)
|
||||
- **DeltaReport**: Typed sections (New, Resolved, ConfidenceUp/Down, PolicyImpact, Damped, EvidenceChanged)
|
||||
- **DeltaReportBuilder**: Fluent builder for change reports with deterministic output
|
||||
|
||||
## Principles
|
||||
1. **Evidence preserving** – never edit or merge raw VEX docs; link via evidence IDs and maintain provenance.
|
||||
|
||||
205
src/VexLens/StellaOps.VexLens/Api/NoiseGatingApiModels.cs
Normal file
205
src/VexLens/StellaOps.VexLens/Api/NoiseGatingApiModels.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using StellaOps.VexLens.Delta;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
|
||||
namespace StellaOps.VexLens.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute delta between two snapshots.
|
||||
/// </summary>
|
||||
public sealed record ComputeDeltaRequest(
|
||||
string FromSnapshotId,
|
||||
string ToSnapshotId,
|
||||
string? TenantId,
|
||||
DeltaReportOptionsRequest? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Options for delta report computation.
|
||||
/// </summary>
|
||||
public sealed record DeltaReportOptionsRequest(
|
||||
double? ConfidenceChangeThreshold,
|
||||
bool? IncludeDamped,
|
||||
bool? IncludeEvidenceChanges);
|
||||
|
||||
/// <summary>
|
||||
/// Response from delta computation.
|
||||
/// </summary>
|
||||
public sealed record DeltaReportResponse(
|
||||
string ReportId,
|
||||
string FromSnapshotDigest,
|
||||
string ToSnapshotDigest,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<DeltaEntryResponse> Entries,
|
||||
DeltaSummaryResponse Summary,
|
||||
bool HasActionableChanges);
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for delta report.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummaryResponse(
|
||||
int TotalCount,
|
||||
int NewCount,
|
||||
int ResolvedCount,
|
||||
int ConfidenceUpCount,
|
||||
int ConfidenceDownCount,
|
||||
int PolicyImpactCount,
|
||||
int DampedCount,
|
||||
int EvidenceChangedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Single delta entry in API format.
|
||||
/// </summary>
|
||||
public sealed record DeltaEntryResponse(
|
||||
string Section,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? FromStatus,
|
||||
string? ToStatus,
|
||||
double? FromConfidence,
|
||||
double? ToConfidence,
|
||||
string? Justification,
|
||||
string? FromRationaleClass,
|
||||
string? ToRationaleClass,
|
||||
string? Summary,
|
||||
IReadOnlyList<string>? ContributingSources,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to gate a graph snapshot.
|
||||
/// </summary>
|
||||
public sealed record GateSnapshotRequest(
|
||||
string SnapshotId,
|
||||
string? TenantId,
|
||||
NoiseGateOptionsRequest? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Options for noise-gating.
|
||||
/// </summary>
|
||||
public sealed record NoiseGateOptionsRequest(
|
||||
bool? EdgeDeduplicationEnabled,
|
||||
bool? StabilityDampingEnabled,
|
||||
double? MinConfidenceThreshold,
|
||||
double? ConfidenceChangeThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Response from gating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record GatedSnapshotResponse(
|
||||
string SnapshotId,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAt,
|
||||
int EdgeCount,
|
||||
int VerdictCount,
|
||||
GatingStatisticsResponse Statistics);
|
||||
|
||||
/// <summary>
|
||||
/// Gating statistics for API response.
|
||||
/// </summary>
|
||||
public sealed record GatingStatisticsResponse(
|
||||
int OriginalEdgeCount,
|
||||
int DeduplicatedEdgeCount,
|
||||
double EdgeReductionPercent,
|
||||
int TotalVerdictCount,
|
||||
int SurfacedVerdictCount,
|
||||
int DampedVerdictCount,
|
||||
string Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Request to get aggregated gating statistics.
|
||||
/// </summary>
|
||||
public sealed record GatingStatisticsQueryRequest(
|
||||
string? TenantId,
|
||||
DateTimeOffset? FromDate,
|
||||
DateTimeOffset? ToDate);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated gating statistics response.
|
||||
/// </summary>
|
||||
public sealed record AggregatedGatingStatisticsResponse(
|
||||
int TotalSnapshots,
|
||||
int TotalEdgesProcessed,
|
||||
int TotalEdgesAfterDedup,
|
||||
double AverageEdgeReductionPercent,
|
||||
int TotalVerdicts,
|
||||
int TotalSurfaced,
|
||||
int TotalDamped,
|
||||
double AverageDampingPercent,
|
||||
DateTimeOffset ComputedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Maps internal delta models to API responses.
|
||||
/// </summary>
|
||||
internal static class NoiseGatingApiMapper
|
||||
{
|
||||
public static DeltaReportResponse MapToResponse(DeltaReport report)
|
||||
{
|
||||
return new DeltaReportResponse(
|
||||
ReportId: report.ReportId,
|
||||
FromSnapshotDigest: report.FromSnapshotDigest,
|
||||
ToSnapshotDigest: report.ToSnapshotDigest,
|
||||
GeneratedAt: report.GeneratedAt,
|
||||
Entries: report.Entries.Select(MapEntry).ToList(),
|
||||
Summary: MapSummary(report.Summary),
|
||||
HasActionableChanges: report.HasActionableChanges);
|
||||
}
|
||||
|
||||
public static DeltaSummaryResponse MapSummary(DeltaSummary summary)
|
||||
{
|
||||
return new DeltaSummaryResponse(
|
||||
TotalCount: summary.TotalCount,
|
||||
NewCount: summary.NewCount,
|
||||
ResolvedCount: summary.ResolvedCount,
|
||||
ConfidenceUpCount: summary.ConfidenceUpCount,
|
||||
ConfidenceDownCount: summary.ConfidenceDownCount,
|
||||
PolicyImpactCount: summary.PolicyImpactCount,
|
||||
DampedCount: summary.DampedCount,
|
||||
EvidenceChangedCount: summary.EvidenceChangedCount);
|
||||
}
|
||||
|
||||
public static DeltaEntryResponse MapEntry(DeltaEntry entry)
|
||||
{
|
||||
return new DeltaEntryResponse(
|
||||
Section: entry.Section.ToString().ToLowerInvariant(),
|
||||
VulnerabilityId: entry.VulnerabilityId,
|
||||
ProductKey: entry.ProductKey,
|
||||
FromStatus: entry.FromStatus?.ToString(),
|
||||
ToStatus: entry.ToStatus?.ToString(),
|
||||
FromConfidence: entry.FromConfidence,
|
||||
ToConfidence: entry.ToConfidence,
|
||||
Justification: entry.Justification?.ToString(),
|
||||
FromRationaleClass: entry.FromRationaleClass,
|
||||
ToRationaleClass: entry.ToRationaleClass,
|
||||
Summary: entry.Summary,
|
||||
ContributingSources: entry.ContributingSources?.ToList(),
|
||||
CreatedAt: entry.Timestamp);
|
||||
}
|
||||
|
||||
public static GatingStatisticsResponse MapStatistics(GatingStatistics stats)
|
||||
{
|
||||
return new GatingStatisticsResponse(
|
||||
OriginalEdgeCount: stats.OriginalEdgeCount,
|
||||
DeduplicatedEdgeCount: stats.DeduplicatedEdgeCount,
|
||||
EdgeReductionPercent: stats.EdgeReductionPercent,
|
||||
TotalVerdictCount: stats.TotalVerdictCount,
|
||||
SurfacedVerdictCount: stats.SurfacedVerdictCount,
|
||||
DampedVerdictCount: stats.DampedVerdictCount,
|
||||
Duration: stats.Duration.ToString("c"));
|
||||
}
|
||||
|
||||
public static DeltaReportOptions MapOptions(DeltaReportOptionsRequest? request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return new DeltaReportOptions();
|
||||
}
|
||||
|
||||
return new DeltaReportOptions
|
||||
{
|
||||
ConfidenceChangeThreshold = request.ConfidenceChangeThreshold ?? 0.15,
|
||||
IncludeDamped = request.IncludeDamped ?? true,
|
||||
IncludeEvidenceChanges = request.IncludeEvidenceChanges ?? true
|
||||
};
|
||||
}
|
||||
}
|
||||
88
src/VexLens/StellaOps.VexLens/Delta/DeltaEntry.cs
Normal file
88
src/VexLens/StellaOps.VexLens/Delta/DeltaEntry.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in a delta report representing a change between snapshots.
|
||||
/// </summary>
|
||||
public sealed record DeltaEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this delta entry.
|
||||
/// </summary>
|
||||
public required string DeltaId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability identifier.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the product/component key (typically PURL).
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the section this delta belongs to.
|
||||
/// </summary>
|
||||
public required DeltaSection Section { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous VEX status, if any.
|
||||
/// </summary>
|
||||
public VexStatus? FromStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current VEX status.
|
||||
/// </summary>
|
||||
public required VexStatus ToStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous confidence score, if any.
|
||||
/// </summary>
|
||||
public double? FromConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current confidence score.
|
||||
/// </summary>
|
||||
public required double ToConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous rationale class, if any.
|
||||
/// </summary>
|
||||
public string? FromRationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current rationale class.
|
||||
/// </summary>
|
||||
public string? ToRationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the justification for the current status.
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable summary of the change.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of this delta.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sources that contributed to this change.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContributingSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
183
src/VexLens/StellaOps.VexLens/Delta/DeltaReport.cs
Normal file
183
src/VexLens/StellaOps.VexLens/Delta/DeltaReport.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.VexLens.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// A report summarizing changes between two vulnerability graph snapshots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DeltaReport groups changes by section for efficient triage:
|
||||
/// - Users can focus on New findings first
|
||||
/// - Resolved items can be quickly acknowledged
|
||||
/// - Confidence changes help reprioritize existing findings
|
||||
/// - Policy impacts highlight workflow-affecting changes
|
||||
/// </remarks>
|
||||
public sealed record DeltaReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this report.
|
||||
/// </summary>
|
||||
public required string ReportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest of the previous snapshot.
|
||||
/// </summary>
|
||||
public required string FromSnapshotDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest of the current snapshot.
|
||||
/// </summary>
|
||||
public required string ToSnapshotDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the report was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all delta entries in this report.
|
||||
/// </summary>
|
||||
public required ImmutableArray<DeltaEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the summary counts by section.
|
||||
/// </summary>
|
||||
public required DeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries grouped by section for UI consumption.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<DeltaSection, ImmutableArray<DeltaEntry>> BySection =>
|
||||
Entries
|
||||
.GroupBy(e => e.Section)
|
||||
.ToImmutableDictionary(
|
||||
g => g.Key,
|
||||
g => g.ToImmutableArray());
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries for a specific section.
|
||||
/// </summary>
|
||||
public ImmutableArray<DeltaEntry> GetSection(DeltaSection section) =>
|
||||
BySection.TryGetValue(section, out var entries)
|
||||
? entries
|
||||
: [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this report has any actionable changes.
|
||||
/// </summary>
|
||||
public bool HasActionableChanges =>
|
||||
Summary.NewCount > 0 ||
|
||||
Summary.PolicyImpactCount > 0 ||
|
||||
Summary.ConfidenceUpCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a one-line summary suitable for notifications.
|
||||
/// </summary>
|
||||
public string ToNotificationSummary()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (Summary.NewCount > 0)
|
||||
{
|
||||
parts.Add(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Summary.NewCount} new"));
|
||||
}
|
||||
|
||||
if (Summary.ResolvedCount > 0)
|
||||
{
|
||||
parts.Add(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Summary.ResolvedCount} resolved"));
|
||||
}
|
||||
|
||||
if (Summary.PolicyImpactCount > 0)
|
||||
{
|
||||
parts.Add(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Summary.PolicyImpactCount} policy impact"));
|
||||
}
|
||||
|
||||
if (Summary.ConfidenceUpCount > 0)
|
||||
{
|
||||
parts.Add(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Summary.ConfidenceUpCount} confidence up"));
|
||||
}
|
||||
|
||||
if (Summary.ConfidenceDownCount > 0)
|
||||
{
|
||||
parts.Add(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Summary.ConfidenceDownCount} confidence down"));
|
||||
}
|
||||
|
||||
return parts.Count == 0
|
||||
? "No significant changes"
|
||||
: string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for a delta report.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of entries.
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of new findings.
|
||||
/// </summary>
|
||||
public required int NewCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of resolved findings.
|
||||
/// </summary>
|
||||
public required int ResolvedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of confidence increases.
|
||||
/// </summary>
|
||||
public required int ConfidenceUpCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of confidence decreases.
|
||||
/// </summary>
|
||||
public required int ConfidenceDownCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of policy impact changes.
|
||||
/// </summary>
|
||||
public required int PolicyImpactCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of damped (suppressed) changes.
|
||||
/// </summary>
|
||||
public int DampedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of evidence-only changes.
|
||||
/// </summary>
|
||||
public int EvidenceChangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a summary from a list of entries.
|
||||
/// </summary>
|
||||
public static DeltaSummary FromEntries(IEnumerable<DeltaEntry> entries)
|
||||
{
|
||||
var list = entries.ToList();
|
||||
|
||||
return new DeltaSummary
|
||||
{
|
||||
TotalCount = list.Count,
|
||||
NewCount = list.Count(e => e.Section == DeltaSection.New),
|
||||
ResolvedCount = list.Count(e => e.Section == DeltaSection.Resolved),
|
||||
ConfidenceUpCount = list.Count(e => e.Section == DeltaSection.ConfidenceUp),
|
||||
ConfidenceDownCount = list.Count(e => e.Section == DeltaSection.ConfidenceDown),
|
||||
PolicyImpactCount = list.Count(e => e.Section == DeltaSection.PolicyImpact),
|
||||
DampedCount = list.Count(e => e.Section == DeltaSection.Damped),
|
||||
EvidenceChangedCount = list.Count(e => e.Section == DeltaSection.EvidenceChanged)
|
||||
};
|
||||
}
|
||||
}
|
||||
347
src/VexLens/StellaOps.VexLens/Delta/DeltaReportBuilder.cs
Normal file
347
src/VexLens/StellaOps.VexLens/Delta/DeltaReportBuilder.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Options for delta report generation.
|
||||
/// </summary>
|
||||
public sealed record DeltaReportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the confidence change threshold for triggering ConfidenceUp/Down sections.
|
||||
/// </summary>
|
||||
public double ConfidenceChangeThreshold { get; init; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include damped entries in the report.
|
||||
/// </summary>
|
||||
public bool IncludeDamped { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include evidence-only changes.
|
||||
/// </summary>
|
||||
public bool IncludeEvidenceChanges { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating <see cref="DeltaReport"/> instances.
|
||||
/// </summary>
|
||||
public sealed class DeltaReportBuilder
|
||||
{
|
||||
private readonly List<DeltaEntry> _entries = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private string _fromDigest = string.Empty;
|
||||
private string _toDigest = string.Empty;
|
||||
private DeltaReportOptions _options = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new delta report builder.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the snapshot digests.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder WithSnapshots(string fromDigest, string toDigest)
|
||||
{
|
||||
_fromDigest = fromDigest;
|
||||
_toDigest = toDigest;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the report options.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder WithOptions(DeltaReportOptions options)
|
||||
{
|
||||
_options = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new finding entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddNew(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
double confidence,
|
||||
string? rationaleClass = null,
|
||||
VexJustification? justification = null,
|
||||
IEnumerable<string>? sources = null)
|
||||
{
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
DeltaSection.New,
|
||||
null,
|
||||
status,
|
||||
null,
|
||||
confidence,
|
||||
null,
|
||||
rationaleClass,
|
||||
justification,
|
||||
$"New {status} finding",
|
||||
sources);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a resolved finding entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddResolved(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus fromStatus,
|
||||
VexStatus toStatus,
|
||||
double fromConfidence,
|
||||
double toConfidence,
|
||||
VexJustification? justification = null,
|
||||
IEnumerable<string>? sources = null)
|
||||
{
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
DeltaSection.Resolved,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
fromConfidence,
|
||||
toConfidence,
|
||||
null,
|
||||
null,
|
||||
justification,
|
||||
$"Resolved: {fromStatus} -> {toStatus}",
|
||||
sources);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a confidence change entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddConfidenceChange(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
double fromConfidence,
|
||||
double toConfidence,
|
||||
IEnumerable<string>? sources = null)
|
||||
{
|
||||
var delta = toConfidence - fromConfidence;
|
||||
var section = delta > 0 ? DeltaSection.ConfidenceUp : DeltaSection.ConfidenceDown;
|
||||
|
||||
if (Math.Abs(delta) < _options.ConfidenceChangeThreshold)
|
||||
{
|
||||
return this; // Below threshold, don't add
|
||||
}
|
||||
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
section,
|
||||
status,
|
||||
status,
|
||||
fromConfidence,
|
||||
toConfidence,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence {(delta > 0 ? "increased" : "decreased")}: {fromConfidence:P0} -> {toConfidence:P0}"),
|
||||
sources);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a policy impact entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddPolicyImpact(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
double confidence,
|
||||
string impactDescription,
|
||||
IEnumerable<string>? sources = null)
|
||||
{
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
DeltaSection.PolicyImpact,
|
||||
status,
|
||||
status,
|
||||
confidence,
|
||||
confidence,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$"Policy impact: {impactDescription}",
|
||||
sources);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a damped (suppressed) entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddDamped(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus fromStatus,
|
||||
VexStatus toStatus,
|
||||
double fromConfidence,
|
||||
double toConfidence,
|
||||
string dampReason)
|
||||
{
|
||||
if (!_options.IncludeDamped)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
DeltaSection.Damped,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
fromConfidence,
|
||||
toConfidence,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$"Damped: {dampReason}",
|
||||
null);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an evidence change entry.
|
||||
/// </summary>
|
||||
public DeltaReportBuilder AddEvidenceChange(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
double confidence,
|
||||
string fromRationaleClass,
|
||||
string toRationaleClass,
|
||||
IEnumerable<string>? sources = null)
|
||||
{
|
||||
if (!_options.IncludeEvidenceChanges)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var entry = CreateEntry(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
DeltaSection.EvidenceChanged,
|
||||
status,
|
||||
status,
|
||||
confidence,
|
||||
confidence,
|
||||
fromRationaleClass,
|
||||
toRationaleClass,
|
||||
null,
|
||||
$"Evidence changed: {fromRationaleClass} -> {toRationaleClass}",
|
||||
sources);
|
||||
|
||||
_entries.Add(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the delta report.
|
||||
/// </summary>
|
||||
public DeltaReport Build()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var reportId = GenerateReportId(now);
|
||||
|
||||
// Sort entries for deterministic output
|
||||
var sortedEntries = _entries
|
||||
.OrderBy(e => (int)e.Section)
|
||||
.ThenBy(e => e.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.ProductKey, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new DeltaReport
|
||||
{
|
||||
ReportId = reportId,
|
||||
FromSnapshotDigest = _fromDigest,
|
||||
ToSnapshotDigest = _toDigest,
|
||||
GeneratedAt = now,
|
||||
Entries = sortedEntries,
|
||||
Summary = DeltaSummary.FromEntries(sortedEntries)
|
||||
};
|
||||
}
|
||||
|
||||
private DeltaEntry CreateEntry(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
DeltaSection section,
|
||||
VexStatus? fromStatus,
|
||||
VexStatus toStatus,
|
||||
double? fromConfidence,
|
||||
double toConfidence,
|
||||
string? fromRationaleClass,
|
||||
string? toRationaleClass,
|
||||
VexJustification? justification,
|
||||
string summary,
|
||||
IEnumerable<string>? sources)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var deltaId = ComputeDeltaId(vulnerabilityId, productKey, section, now);
|
||||
|
||||
return new DeltaEntry
|
||||
{
|
||||
DeltaId = deltaId,
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
ProductKey = productKey,
|
||||
Section = section,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = toStatus,
|
||||
FromConfidence = fromConfidence,
|
||||
ToConfidence = toConfidence,
|
||||
FromRationaleClass = fromRationaleClass,
|
||||
ToRationaleClass = toRationaleClass,
|
||||
Justification = justification,
|
||||
Summary = summary,
|
||||
Timestamp = now,
|
||||
ContributingSources = sources?.ToImmutableArray() ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDeltaId(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
DeltaSection section,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{vulnerabilityId}|{productKey}|{section}|{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash)[..16];
|
||||
}
|
||||
|
||||
private string GenerateReportId(DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{_fromDigest}|{_toDigest}|{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"delta-{Convert.ToHexStringLower(hash)[..12]}";
|
||||
}
|
||||
}
|
||||
86
src/VexLens/StellaOps.VexLens/Delta/DeltaSection.cs
Normal file
86
src/VexLens/StellaOps.VexLens/Delta/DeltaSection.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.VexLens.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Categorizes a delta entry for UI presentation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delta sections enable the UI to present changes in a structured way:
|
||||
/// - New: First-time findings that require attention
|
||||
/// - Resolved: Issues that are now fixed or determined not to affect
|
||||
/// - ConfidenceUp/Down: Changes in certainty that may affect prioritization
|
||||
/// - PolicyImpact: Changes that affect gate decisions or workflow
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DeltaSection>))]
|
||||
public enum DeltaSection
|
||||
{
|
||||
/// <summary>
|
||||
/// A new finding that was not present in the previous snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Status was not present or was under_investigation
|
||||
/// and is now affected.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("new")]
|
||||
New,
|
||||
|
||||
/// <summary>
|
||||
/// A finding that has been resolved (no longer actionable).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Status changed from affected to not_affected or fixed.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resolved")]
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in an existing finding has increased significantly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Same status but confidence increased by threshold amount.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("confidence_up")]
|
||||
ConfidenceUp,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in an existing finding has decreased significantly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Same status but confidence decreased by threshold amount.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("confidence_down")]
|
||||
ConfidenceDown,
|
||||
|
||||
/// <summary>
|
||||
/// A change that affects policy gate decisions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Gate decision changed (pass -> fail, warn -> block, etc.)
|
||||
/// even if underlying status/confidence didn't change significantly.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("policy_impact")]
|
||||
PolicyImpact,
|
||||
|
||||
/// <summary>
|
||||
/// A finding that was damped and not surfaced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Change would normally surface but was suppressed by
|
||||
/// stability damping. Only included when verbose mode is enabled.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("damped")]
|
||||
Damped,
|
||||
|
||||
/// <summary>
|
||||
/// A finding where the rationale class changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use when: Evidence authority changed (e.g., heuristic -> authoritative)
|
||||
/// even if status didn't change.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("evidence_changed")]
|
||||
EvidenceChanged
|
||||
}
|
||||
@@ -4,16 +4,19 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.ReachGraph.Deduplication;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Caching;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Export;
|
||||
using StellaOps.VexLens.Integration;
|
||||
using StellaOps.VexLens.Orchestration;
|
||||
using StellaOps.VexLens.Mapping;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
using StellaOps.VexLens.Normalization;
|
||||
using StellaOps.VexLens.Observability;
|
||||
using StellaOps.VexLens.Options;
|
||||
using StellaOps.VexLens.Orchestration;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using StellaOps.VexLens.Trust;
|
||||
using StellaOps.VexLens.Trust.SourceTrust;
|
||||
@@ -110,6 +113,9 @@ public static class VexLensServiceCollectionExtensions
|
||||
// Consensus engine
|
||||
services.TryAddSingleton<IVexConsensusEngine, VexConsensusEngine>();
|
||||
|
||||
// Noise-gating services (Sprint: NG-001)
|
||||
RegisterNoiseGating(services, options);
|
||||
|
||||
// Storage
|
||||
RegisterStorage(services, options.Storage);
|
||||
|
||||
@@ -292,4 +298,70 @@ public static class VexLensServiceCollectionExtensions
|
||||
dualWriteLogger);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers noise-gating services.
|
||||
/// Sprint: SPRINT_20260104_001_BE_adaptive_noise_gating (NG-001)
|
||||
/// </summary>
|
||||
private static void RegisterNoiseGating(
|
||||
IServiceCollection services,
|
||||
VexLensOptions options)
|
||||
{
|
||||
// Configure NoiseGateOptions
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var noiseGateOptions = new NoiseGateOptions();
|
||||
return Microsoft.Extensions.Options.Options.Create(noiseGateOptions);
|
||||
});
|
||||
|
||||
// TimeProvider for deterministic time handling
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Edge deduplication
|
||||
services.TryAddSingleton<IEdgeDeduplicator, EdgeDeduplicator>();
|
||||
|
||||
// Stability damping gate
|
||||
services.TryAddSingleton<IOptionsMonitor<StabilityDampingOptions>>(sp =>
|
||||
{
|
||||
var dampingOptions = new StabilityDampingOptions();
|
||||
return new OptionsMonitorAdapter<StabilityDampingOptions>(dampingOptions);
|
||||
});
|
||||
services.TryAddSingleton<IStabilityDampingGate, StabilityDampingGate>();
|
||||
|
||||
// Noise gate service
|
||||
services.TryAddSingleton<IOptionsMonitor<NoiseGateOptions>>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<NoiseGateOptions>>();
|
||||
return new OptionsMonitorAdapter<NoiseGateOptions>(opts.Value);
|
||||
});
|
||||
services.TryAddSingleton<INoiseGate, NoiseGateService>();
|
||||
|
||||
// Snapshot and statistics storage (Sprint: NG-FE-001)
|
||||
services.TryAddSingleton<ISnapshotStore, InMemorySnapshotStore>();
|
||||
services.TryAddSingleton<IGatingStatisticsStore>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new InMemoryGatingStatisticsStore(timeProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple adapter to convert IOptions to IOptionsMonitor for singleton services.
|
||||
/// </summary>
|
||||
internal sealed class OptionsMonitorAdapter<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public OptionsMonitorAdapter(T value)
|
||||
{
|
||||
_value = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
310
src/VexLens/StellaOps.VexLens/NoiseGate/INoiseGate.cs
Normal file
310
src/VexLens/StellaOps.VexLens/NoiseGate/INoiseGate.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.ReachGraph.Deduplication;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.VexLens.Delta;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.NoiseGate;
|
||||
|
||||
/// <summary>
|
||||
/// Central interface for noise-gating operations on vulnerability graphs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The noise gate provides three core capabilities:
|
||||
/// <list type="bullet">
|
||||
/// <item>Edge deduplication: Collapses semantically equivalent edges from multiple sources</item>
|
||||
/// <item>Verdict resolution: Applies stability damping to prevent flip-flopping</item>
|
||||
/// <item>Delta reporting: Computes meaningful changes between snapshots</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public interface INoiseGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates edges based on semantic equivalence.
|
||||
/// </summary>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Deduplicated edges with merged provenance.</returns>
|
||||
Task<IReadOnlyList<DeduplicatedEdge>> DedupeEdgesAsync(
|
||||
IReadOnlyList<ReachGraphEdge> edges,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a verdict by applying stability damping.
|
||||
/// </summary>
|
||||
/// <param name="request">The verdict resolution request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved verdict with damping decision.</returns>
|
||||
Task<ResolvedVerdict> ResolveVerdictAsync(
|
||||
VerdictResolutionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applies noise-gating to a graph snapshot.
|
||||
/// </summary>
|
||||
/// <param name="request">The gating request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The gated graph snapshot.</returns>
|
||||
Task<GatedGraphSnapshot> GateAsync(
|
||||
NoiseGateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a delta report between two snapshots.
|
||||
/// </summary>
|
||||
/// <param name="fromSnapshot">The previous snapshot.</param>
|
||||
/// <param name="toSnapshot">The current snapshot.</param>
|
||||
/// <param name="options">Optional report options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The delta report.</returns>
|
||||
Task<DeltaReport> DiffAsync(
|
||||
GatedGraphSnapshot fromSnapshot,
|
||||
GatedGraphSnapshot toSnapshot,
|
||||
DeltaReportOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve a verdict with stability damping.
|
||||
/// </summary>
|
||||
public sealed record VerdictResolutionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique key for this verdict (e.g., "artifact:cve").
|
||||
/// </summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the product key (PURL or other identifier).
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed VEX status.
|
||||
/// </summary>
|
||||
public required VexStatus ProposedStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed confidence score.
|
||||
/// </summary>
|
||||
public required double ProposedConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale class for the verdict.
|
||||
/// </summary>
|
||||
public string? RationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the justification for the verdict.
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the contributing sources.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ContributingSources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenant deployments.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verdict resolution with damping decision.
|
||||
/// </summary>
|
||||
public sealed record ResolvedVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the product key.
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the final VEX status.
|
||||
/// </summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the final confidence score.
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale class.
|
||||
/// </summary>
|
||||
public string? RationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the justification.
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the verdict was surfaced (not damped).
|
||||
/// </summary>
|
||||
public required bool WasSurfaced { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the damping reason if applicable.
|
||||
/// </summary>
|
||||
public string? DampingReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous status if available.
|
||||
/// </summary>
|
||||
public VexStatus? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous confidence if available.
|
||||
/// </summary>
|
||||
public double? PreviousConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the contributing sources.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContributingSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of resolution.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to apply noise-gating to a graph.
|
||||
/// </summary>
|
||||
public sealed record NoiseGateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the reachability graph to gate.
|
||||
/// </summary>
|
||||
public required ReachGraphMinimal Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdicts to include.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VerdictResolutionRequest> Verdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the snapshot ID.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to compute a previous snapshot diff.
|
||||
/// </summary>
|
||||
public bool ComputeDiff { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous snapshot ID for diff computation.
|
||||
/// </summary>
|
||||
public string? PreviousSnapshotId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gated graph snapshot with deduplicated edges and resolved verdicts.
|
||||
/// </summary>
|
||||
public sealed record GatedGraphSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique snapshot identifier.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the snapshot digest for integrity verification.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact this snapshot describes.
|
||||
/// </summary>
|
||||
public required ReachGraphArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the deduplicated edges.
|
||||
/// </summary>
|
||||
public required ImmutableArray<DeduplicatedEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved verdicts.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ResolvedVerdict> Verdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdicts that were damped (not surfaced).
|
||||
/// </summary>
|
||||
public ImmutableArray<ResolvedVerdict> DampedVerdicts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this snapshot was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the gating statistics.
|
||||
/// </summary>
|
||||
public required GatingStatistics Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics from a noise-gating operation.
|
||||
/// </summary>
|
||||
public sealed record GatingStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the original edge count before deduplication.
|
||||
/// </summary>
|
||||
public required int OriginalEdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the edge count after deduplication.
|
||||
/// </summary>
|
||||
public required int DeduplicatedEdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the edge reduction percentage.
|
||||
/// </summary>
|
||||
public double EdgeReductionPercent =>
|
||||
OriginalEdgeCount > 0
|
||||
? (1.0 - (double)DeduplicatedEdgeCount / OriginalEdgeCount) * 100.0
|
||||
: 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total verdict count.
|
||||
/// </summary>
|
||||
public required int TotalVerdictCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the surfaced verdict count.
|
||||
/// </summary>
|
||||
public required int SurfacedVerdictCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the damped verdict count.
|
||||
/// </summary>
|
||||
public required int DampedVerdictCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the duration of the gating operation.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
}
|
||||
122
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateOptions.cs
Normal file
122
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateOptions.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
namespace StellaOps.VexLens.NoiseGate;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for noise-gating in VexLens.
|
||||
/// </summary>
|
||||
public sealed class NoiseGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "VexLens:NoiseGate";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether noise-gating is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether edge deduplication is enabled.
|
||||
/// </summary>
|
||||
public bool EdgeDeduplicationEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether stability damping is enabled.
|
||||
/// </summary>
|
||||
public bool StabilityDampingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether delta reports should include damped entries.
|
||||
/// </summary>
|
||||
public bool IncludeDampedInDelta { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum confidence threshold for including verdicts.
|
||||
/// Verdicts below this threshold are excluded from output.
|
||||
/// </summary>
|
||||
public double MinConfidenceThreshold { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the confidence change threshold for triggering delta sections.
|
||||
/// </summary>
|
||||
public double ConfidenceChangeThreshold { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to collapse semantically equivalent edges.
|
||||
/// </summary>
|
||||
public bool CollapseEquivalentEdges { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of edges to process per operation.
|
||||
/// </summary>
|
||||
public int MaxEdgesPerOperation { get; set; } = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of verdicts to process per operation.
|
||||
/// </summary>
|
||||
public int MaxVerdictsPerOperation { get; set; } = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to log noise-gating decisions.
|
||||
/// </summary>
|
||||
public bool LogDecisions { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the snapshot retention for delta computation.
|
||||
/// </summary>
|
||||
public TimeSpan SnapshotRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets options specific to edge deduplication.
|
||||
/// </summary>
|
||||
public EdgeDeduplicationOptions EdgeDeduplication { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets options specific to stability damping.
|
||||
/// </summary>
|
||||
public StabilityDampingSettings StabilityDamping { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for edge deduplication.
|
||||
/// </summary>
|
||||
public sealed class EdgeDeduplicationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum provenance count to consider an edge reliable.
|
||||
/// </summary>
|
||||
public int MinProvenanceCount { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to merge provenance from deduplicated edges.
|
||||
/// </summary>
|
||||
public bool MergeProvenance { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use the highest confidence from merged edges.
|
||||
/// </summary>
|
||||
public bool UseHighestConfidence { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for stability damping within noise-gating.
|
||||
/// </summary>
|
||||
public sealed class StabilityDampingSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum duration before a state change is surfaced.
|
||||
/// </summary>
|
||||
public TimeSpan MinDurationBeforeChange { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum confidence delta for immediate surfacing.
|
||||
/// </summary>
|
||||
public double MinConfidenceDeltaPercent { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only damp downgrades (not upgrades).
|
||||
/// </summary>
|
||||
public bool OnlyDampDowngrades { get; set; } = true;
|
||||
}
|
||||
471
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateService.cs
Normal file
471
src/VexLens/StellaOps.VexLens/NoiseGate/NoiseGateService.cs
Normal file
@@ -0,0 +1,471 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.ReachGraph.Deduplication;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.VexLens.Delta;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.NoiseGate;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="INoiseGate"/> that integrates edge deduplication,
|
||||
/// stability damping, and delta report generation.
|
||||
/// </summary>
|
||||
public sealed class NoiseGateService : INoiseGate
|
||||
{
|
||||
private readonly IEdgeDeduplicator _edgeDeduplicator;
|
||||
private readonly IStabilityDampingGate _stabilityDampingGate;
|
||||
private readonly IOptionsMonitor<NoiseGateOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NoiseGateService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NoiseGateService"/> class.
|
||||
/// </summary>
|
||||
public NoiseGateService(
|
||||
IEdgeDeduplicator edgeDeduplicator,
|
||||
IStabilityDampingGate stabilityDampingGate,
|
||||
IOptionsMonitor<NoiseGateOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NoiseGateService> logger)
|
||||
{
|
||||
_edgeDeduplicator = edgeDeduplicator ?? throw new ArgumentNullException(nameof(edgeDeduplicator));
|
||||
_stabilityDampingGate = stabilityDampingGate ?? throw new ArgumentNullException(nameof(stabilityDampingGate));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<DeduplicatedEdge>> DedupeEdgesAsync(
|
||||
IReadOnlyList<ReachGraphEdge> edges,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edges);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.EdgeDeduplicationEnabled || !opts.Enabled)
|
||||
{
|
||||
// Return edges without deduplication - create minimal deduplicated wrappers
|
||||
var passthrough = edges.Select(e => new DeduplicatedEdgeBuilder(
|
||||
e.From, e.To, e.Why.Type, e.Why.Loc)
|
||||
.WithConfidence(e.Why.Confidence)
|
||||
.Build())
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DeduplicatedEdge>>(passthrough);
|
||||
}
|
||||
|
||||
var result = _edgeDeduplicator.Deduplicate(edges);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ResolvedVerdict> ResolveVerdictAsync(
|
||||
VerdictResolutionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// If stability damping is disabled, pass through
|
||||
if (!opts.StabilityDampingEnabled || !opts.Enabled)
|
||||
{
|
||||
return new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
ProductKey = request.ProductKey,
|
||||
Status = request.ProposedStatus,
|
||||
Confidence = request.ProposedConfidence,
|
||||
RationaleClass = request.RationaleClass,
|
||||
Justification = request.Justification,
|
||||
WasSurfaced = true,
|
||||
DampingReason = null,
|
||||
ContributingSources = request.ContributingSources?.ToImmutableArray() ?? [],
|
||||
ResolvedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate with stability damping gate
|
||||
var dampingRequest = new StabilityDampingRequest
|
||||
{
|
||||
Key = request.Key,
|
||||
TenantId = request.TenantId,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = VexStatusToString(request.ProposedStatus),
|
||||
Confidence = request.ProposedConfidence,
|
||||
Timestamp = now,
|
||||
RationaleClass = request.RationaleClass,
|
||||
SourceId = request.ContributingSources?.FirstOrDefault()
|
||||
}
|
||||
};
|
||||
|
||||
var decision = await _stabilityDampingGate.EvaluateAsync(dampingRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Record the new state if surfaced
|
||||
if (decision.ShouldSurface)
|
||||
{
|
||||
await _stabilityDampingGate.RecordStateAsync(
|
||||
request.Key,
|
||||
dampingRequest.ProposedState,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (opts.LogDecisions)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Verdict resolution for {Key}: surfaced={Surfaced}, reason={Reason}",
|
||||
request.Key,
|
||||
decision.ShouldSurface,
|
||||
decision.Reason);
|
||||
}
|
||||
|
||||
return new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
ProductKey = request.ProductKey,
|
||||
Status = request.ProposedStatus,
|
||||
Confidence = request.ProposedConfidence,
|
||||
RationaleClass = request.RationaleClass,
|
||||
Justification = request.Justification,
|
||||
WasSurfaced = decision.ShouldSurface,
|
||||
DampingReason = decision.ShouldSurface ? null : decision.Reason,
|
||||
PreviousStatus = decision.PreviousState != null
|
||||
? ParseVexStatus(decision.PreviousState.Status)
|
||||
: null,
|
||||
PreviousConfidence = decision.PreviousState?.Confidence,
|
||||
ContributingSources = request.ContributingSources?.ToImmutableArray() ?? [],
|
||||
ResolvedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<GatedGraphSnapshot> GateAsync(
|
||||
NoiseGateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Validate limits
|
||||
if (request.Graph.Edges.Length > opts.MaxEdgesPerOperation)
|
||||
{
|
||||
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
|
||||
$"Edge count {request.Graph.Edges.Length} exceeds maximum {opts.MaxEdgesPerOperation}"));
|
||||
}
|
||||
|
||||
if (request.Verdicts.Count > opts.MaxVerdictsPerOperation)
|
||||
{
|
||||
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
|
||||
$"Verdict count {request.Verdicts.Count} exceeds maximum {opts.MaxVerdictsPerOperation}"));
|
||||
}
|
||||
|
||||
// Deduplicate edges
|
||||
var edges = request.Graph.Edges.ToList();
|
||||
var deduplicatedEdges = await DedupeEdgesAsync(edges, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Resolve verdicts
|
||||
var surfacedVerdicts = new List<ResolvedVerdict>();
|
||||
var dampedVerdicts = new List<ResolvedVerdict>();
|
||||
|
||||
foreach (var verdictRequest in request.Verdicts)
|
||||
{
|
||||
var resolved = await ResolveVerdictAsync(verdictRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (resolved.WasSurfaced)
|
||||
{
|
||||
// Apply confidence threshold
|
||||
if (resolved.Confidence >= opts.MinConfidenceThreshold)
|
||||
{
|
||||
surfacedVerdicts.Add(resolved);
|
||||
}
|
||||
else if (opts.IncludeDampedInDelta)
|
||||
{
|
||||
dampedVerdicts.Add(resolved with
|
||||
{
|
||||
WasSurfaced = false,
|
||||
DampingReason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence {resolved.Confidence:P1} below threshold {opts.MinConfidenceThreshold:P1}")
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dampedVerdicts.Add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Compute snapshot digest
|
||||
var digest = ComputeSnapshotDigest(
|
||||
request.SnapshotId,
|
||||
deduplicatedEdges,
|
||||
surfacedVerdicts,
|
||||
now);
|
||||
|
||||
var statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = edges.Count,
|
||||
DeduplicatedEdgeCount = deduplicatedEdges.Count,
|
||||
TotalVerdictCount = request.Verdicts.Count,
|
||||
SurfacedVerdictCount = surfacedVerdicts.Count,
|
||||
DampedVerdictCount = dampedVerdicts.Count,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gated snapshot {SnapshotId}: edges {OriginalEdges}->{DeduplicatedEdges} ({Reduction:F1}% reduction), " +
|
||||
"verdicts {Surfaced}/{Total} surfaced, {Damped} damped in {Duration}ms",
|
||||
request.SnapshotId,
|
||||
statistics.OriginalEdgeCount,
|
||||
statistics.DeduplicatedEdgeCount,
|
||||
statistics.EdgeReductionPercent,
|
||||
statistics.SurfacedVerdictCount,
|
||||
statistics.TotalVerdictCount,
|
||||
statistics.DampedVerdictCount,
|
||||
statistics.Duration.TotalMilliseconds);
|
||||
|
||||
return new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = request.SnapshotId,
|
||||
Digest = digest,
|
||||
Artifact = request.Graph.Artifact,
|
||||
Edges = deduplicatedEdges.ToImmutableArray(),
|
||||
Verdicts = surfacedVerdicts.ToImmutableArray(),
|
||||
DampedVerdicts = opts.IncludeDampedInDelta
|
||||
? dampedVerdicts.ToImmutableArray()
|
||||
: [],
|
||||
CreatedAt = now,
|
||||
Statistics = statistics
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<DeltaReport> DiffAsync(
|
||||
GatedGraphSnapshot fromSnapshot,
|
||||
GatedGraphSnapshot toSnapshot,
|
||||
DeltaReportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromSnapshot);
|
||||
ArgumentNullException.ThrowIfNull(toSnapshot);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
var reportOptions = options ?? new DeltaReportOptions
|
||||
{
|
||||
ConfidenceChangeThreshold = opts.ConfidenceChangeThreshold,
|
||||
IncludeDamped = opts.IncludeDampedInDelta,
|
||||
IncludeEvidenceChanges = true
|
||||
};
|
||||
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots(fromSnapshot.Digest, toSnapshot.Digest)
|
||||
.WithOptions(reportOptions);
|
||||
|
||||
// Index previous verdicts by key
|
||||
var previousVerdicts = fromSnapshot.Verdicts
|
||||
.ToDictionary(
|
||||
v => $"{v.VulnerabilityId}|{v.ProductKey}",
|
||||
v => v,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Process current verdicts
|
||||
foreach (var current in toSnapshot.Verdicts)
|
||||
{
|
||||
var key = $"{current.VulnerabilityId}|{current.ProductKey}";
|
||||
|
||||
if (!previousVerdicts.TryGetValue(key, out var previous))
|
||||
{
|
||||
// New finding
|
||||
builder.AddNew(
|
||||
current.VulnerabilityId,
|
||||
current.ProductKey,
|
||||
current.Status,
|
||||
current.Confidence,
|
||||
current.RationaleClass,
|
||||
current.Justification,
|
||||
current.ContributingSources);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Existing finding - check for changes
|
||||
previousVerdicts.Remove(key); // Mark as processed
|
||||
|
||||
var statusChanged = current.Status != previous.Status;
|
||||
var confidenceChanged = Math.Abs(current.Confidence - previous.Confidence) >= reportOptions.ConfidenceChangeThreshold;
|
||||
var rationaleChanged = !string.Equals(current.RationaleClass, previous.RationaleClass, StringComparison.Ordinal);
|
||||
|
||||
if (statusChanged)
|
||||
{
|
||||
// Check if resolved
|
||||
if (IsResolved(current.Status) && !IsResolved(previous.Status))
|
||||
{
|
||||
builder.AddResolved(
|
||||
current.VulnerabilityId,
|
||||
current.ProductKey,
|
||||
previous.Status,
|
||||
current.Status,
|
||||
previous.Confidence,
|
||||
current.Confidence,
|
||||
current.Justification,
|
||||
current.ContributingSources);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Status change but not resolved - treat as policy impact
|
||||
builder.AddPolicyImpact(
|
||||
current.VulnerabilityId,
|
||||
current.ProductKey,
|
||||
current.Status,
|
||||
current.Confidence,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"Status changed: {previous.Status} -> {current.Status}"),
|
||||
current.ContributingSources);
|
||||
}
|
||||
}
|
||||
else if (confidenceChanged)
|
||||
{
|
||||
builder.AddConfidenceChange(
|
||||
current.VulnerabilityId,
|
||||
current.ProductKey,
|
||||
current.Status,
|
||||
previous.Confidence,
|
||||
current.Confidence,
|
||||
current.ContributingSources);
|
||||
}
|
||||
else if (rationaleChanged && !string.IsNullOrEmpty(current.RationaleClass))
|
||||
{
|
||||
builder.AddEvidenceChange(
|
||||
current.VulnerabilityId,
|
||||
current.ProductKey,
|
||||
current.Status,
|
||||
current.Confidence,
|
||||
previous.RationaleClass ?? "unknown",
|
||||
current.RationaleClass,
|
||||
current.ContributingSources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining previous verdicts are no longer present - they resolved
|
||||
foreach (var (key, previous) in previousVerdicts)
|
||||
{
|
||||
// Treat as resolved (no longer affected)
|
||||
builder.AddResolved(
|
||||
previous.VulnerabilityId,
|
||||
previous.ProductKey,
|
||||
previous.Status,
|
||||
VexStatus.NotAffected, // Assumed resolved
|
||||
previous.Confidence,
|
||||
1.0, // High confidence in removal
|
||||
VexJustification.VulnerableCodeNotPresent,
|
||||
null);
|
||||
}
|
||||
|
||||
// Add damped entries if configured
|
||||
if (reportOptions.IncludeDamped)
|
||||
{
|
||||
foreach (var damped in toSnapshot.DampedVerdicts)
|
||||
{
|
||||
if (damped.PreviousStatus.HasValue)
|
||||
{
|
||||
builder.AddDamped(
|
||||
damped.VulnerabilityId,
|
||||
damped.ProductKey,
|
||||
damped.PreviousStatus.Value,
|
||||
damped.Status,
|
||||
damped.PreviousConfidence ?? 0.0,
|
||||
damped.Confidence,
|
||||
damped.DampingReason ?? "Unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(builder.Build());
|
||||
}
|
||||
|
||||
private static bool IsResolved(VexStatus status) =>
|
||||
status == VexStatus.NotAffected || status == VexStatus.Fixed;
|
||||
|
||||
private static string VexStatusToString(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static VexStatus? ParseVexStatus(string status) =>
|
||||
status?.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string ComputeSnapshotDigest(
|
||||
string snapshotId,
|
||||
IReadOnlyList<DeduplicatedEdge> edges,
|
||||
IReadOnlyList<ResolvedVerdict> verdicts,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
// Build deterministic input for digest
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(snapshotId);
|
||||
sb.Append('|');
|
||||
sb.Append(timestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
// Add sorted edges
|
||||
var sortedEdges = edges
|
||||
.OrderBy(e => e.SemanticKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var edge in sortedEdges)
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(edge.SemanticKey);
|
||||
}
|
||||
|
||||
// Add sorted verdicts
|
||||
var sortedVerdicts = verdicts
|
||||
.OrderBy(v => v.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.ProductKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var verdict in sortedVerdicts)
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(verdict.VulnerabilityId);
|
||||
sb.Append(':');
|
||||
sb.Append(verdict.ProductKey);
|
||||
sb.Append(':');
|
||||
sb.Append(VexStatusToString(verdict.Status));
|
||||
sb.Append(':');
|
||||
sb.Append(verdict.Confidence.ToString("F4", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@
|
||||
<!-- VEX delta repository and models from Excititor -->
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
|
||||
<!-- NG-001: Noise-gating dependencies -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
<ProjectReference Include="..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude legacy folders with external dependencies -->
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Store for aggregated gating statistics.
|
||||
/// </summary>
|
||||
public interface IGatingStatisticsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Records statistics from a gating operation.
|
||||
/// </summary>
|
||||
/// <param name="snapshotId">The snapshot ID.</param>
|
||||
/// <param name="statistics">The gating statistics.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordAsync(
|
||||
string snapshotId,
|
||||
GatingStatistics statistics,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated statistics for a time range.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="fromDate">Start date filter.</param>
|
||||
/// <param name="toDate">End date filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated statistics.</returns>
|
||||
Task<AggregatedGatingStatistics> GetAggregatedAsync(
|
||||
string? tenantId = null,
|
||||
DateTimeOffset? fromDate = null,
|
||||
DateTimeOffset? toDate = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated gating statistics across multiple snapshots.
|
||||
/// </summary>
|
||||
public sealed record AggregatedGatingStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of snapshots processed.
|
||||
/// </summary>
|
||||
public required int TotalSnapshots { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total edges processed.
|
||||
/// </summary>
|
||||
public required int TotalEdgesProcessed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total edges after deduplication.
|
||||
/// </summary>
|
||||
public required int TotalEdgesAfterDedup { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average edge reduction percentage.
|
||||
/// </summary>
|
||||
public required double AverageEdgeReductionPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total verdicts processed.
|
||||
/// </summary>
|
||||
public required int TotalVerdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total surfaced verdicts.
|
||||
/// </summary>
|
||||
public required int TotalSurfaced { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total damped verdicts.
|
||||
/// </summary>
|
||||
public required int TotalDamped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average damping percentage.
|
||||
/// </summary>
|
||||
public required double AverageDampingPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty statistics for when no data exists.
|
||||
/// </summary>
|
||||
public static AggregatedGatingStatistics Empty => new()
|
||||
{
|
||||
TotalSnapshots = 0,
|
||||
TotalEdgesProcessed = 0,
|
||||
TotalEdgesAfterDedup = 0,
|
||||
AverageEdgeReductionPercent = 0,
|
||||
TotalVerdicts = 0,
|
||||
TotalSurfaced = 0,
|
||||
TotalDamped = 0,
|
||||
AverageDampingPercent = 0
|
||||
};
|
||||
}
|
||||
96
src/VexLens/StellaOps.VexLens/Storage/ISnapshotStore.cs
Normal file
96
src/VexLens/StellaOps.VexLens/Storage/ISnapshotStore.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Store for managing graph snapshots (both raw and gated).
|
||||
/// </summary>
|
||||
public interface ISnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a gated snapshot by ID.
|
||||
/// </summary>
|
||||
/// <param name="snapshotId">The snapshot ID.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The gated snapshot, or null if not found.</returns>
|
||||
Task<GatedGraphSnapshot?> GetAsync(
|
||||
string snapshotId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a raw (ungated) snapshot by ID.
|
||||
/// </summary>
|
||||
/// <param name="snapshotId">The snapshot ID.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw snapshot, or null if not found.</returns>
|
||||
Task<RawGraphSnapshot?> GetRawAsync(
|
||||
string snapshotId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a gated snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The snapshot to store.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StoreAsync(
|
||||
GatedGraphSnapshot snapshot,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a raw snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The raw snapshot to store.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task StoreRawAsync(
|
||||
RawGraphSnapshot snapshot,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshot IDs for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="limit">Maximum number of results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of snapshot IDs.</returns>
|
||||
Task<IReadOnlyList<string>> ListAsync(
|
||||
string? tenantId = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A raw (ungated) graph snapshot.
|
||||
/// </summary>
|
||||
public sealed record RawGraphSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the snapshot ID.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reachability graph.
|
||||
/// </summary>
|
||||
public required ReachGraphMinimal Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdict requests.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VerdictResolutionRequest> Verdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this snapshot was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IGatingStatisticsStore"/> for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryGatingStatisticsStore : IGatingStatisticsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, StatisticsEntry> _entries = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of in-memory statistics store.
|
||||
/// </summary>
|
||||
public InMemoryGatingStatisticsStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordAsync(
|
||||
string snapshotId,
|
||||
GatingStatistics statistics,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(snapshotId, tenantId);
|
||||
_entries[key] = new StatisticsEntry(statistics, _timeProvider.GetUtcNow());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AggregatedGatingStatistics> GetAggregatedAsync(
|
||||
string? tenantId = null,
|
||||
DateTimeOffset? fromDate = null,
|
||||
DateTimeOffset? toDate = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prefix = tenantId is null ? string.Empty : $"{tenantId}:";
|
||||
|
||||
var entries = _entries
|
||||
.Where(kvp => string.IsNullOrEmpty(prefix) || kvp.Key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Where(kvp => fromDate is null || kvp.Value.RecordedAt >= fromDate)
|
||||
.Where(kvp => toDate is null || kvp.Value.RecordedAt <= toDate)
|
||||
.Select(kvp => kvp.Value.Statistics)
|
||||
.ToList();
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return Task.FromResult(AggregatedGatingStatistics.Empty);
|
||||
}
|
||||
|
||||
var totalEdgesProcessed = entries.Sum(s => s.OriginalEdgeCount);
|
||||
var totalEdgesAfterDedup = entries.Sum(s => s.DeduplicatedEdgeCount);
|
||||
var totalVerdicts = entries.Sum(s => s.TotalVerdictCount);
|
||||
var totalSurfaced = entries.Sum(s => s.SurfacedVerdictCount);
|
||||
var totalDamped = entries.Sum(s => s.DampedVerdictCount);
|
||||
|
||||
var avgEdgeReduction = totalEdgesProcessed > 0
|
||||
? (1.0 - (double)totalEdgesAfterDedup / totalEdgesProcessed) * 100.0
|
||||
: 0.0;
|
||||
|
||||
var avgDampingPercent = totalVerdicts > 0
|
||||
? (double)totalDamped / totalVerdicts * 100.0
|
||||
: 0.0;
|
||||
|
||||
return Task.FromResult(new AggregatedGatingStatistics
|
||||
{
|
||||
TotalSnapshots = entries.Count,
|
||||
TotalEdgesProcessed = totalEdgesProcessed,
|
||||
TotalEdgesAfterDedup = totalEdgesAfterDedup,
|
||||
AverageEdgeReductionPercent = avgEdgeReduction,
|
||||
TotalVerdicts = totalVerdicts,
|
||||
TotalSurfaced = totalSurfaced,
|
||||
TotalDamped = totalDamped,
|
||||
AverageDampingPercent = avgDampingPercent
|
||||
});
|
||||
}
|
||||
|
||||
private static string MakeKey(string snapshotId, string? tenantId) =>
|
||||
tenantId is null ? snapshotId : $"{tenantId}:{snapshotId}";
|
||||
|
||||
private sealed record StatisticsEntry(GatingStatistics Statistics, DateTimeOffset RecordedAt);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISnapshotStore"/> for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GatedGraphSnapshot> _gated = new();
|
||||
private readonly ConcurrentDictionary<string, RawGraphSnapshot> _raw = new();
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _timestamps = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GatedGraphSnapshot?> GetAsync(
|
||||
string snapshotId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(snapshotId, tenantId);
|
||||
_gated.TryGetValue(key, out var snapshot);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RawGraphSnapshot?> GetRawAsync(
|
||||
string snapshotId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(snapshotId, tenantId);
|
||||
_raw.TryGetValue(key, out var snapshot);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(
|
||||
GatedGraphSnapshot snapshot,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(snapshot.SnapshotId, tenantId);
|
||||
_gated[key] = snapshot;
|
||||
_timestamps[key] = snapshot.CreatedAt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreRawAsync(
|
||||
RawGraphSnapshot snapshot,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(snapshot.SnapshotId, tenantId);
|
||||
_raw[key] = snapshot;
|
||||
_timestamps[key] = snapshot.CreatedAt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListAsync(
|
||||
string? tenantId = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prefix = tenantId is null ? string.Empty : $"{tenantId}:";
|
||||
|
||||
var ids = _gated.Keys
|
||||
.Where(k => string.IsNullOrEmpty(prefix) || k.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(k => string.IsNullOrEmpty(prefix) ? k : k[prefix.Length..])
|
||||
.OrderByDescending(id => _timestamps.TryGetValue(MakeKey(id, tenantId), out var ts) ? ts : DateTimeOffset.MinValue)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(ids);
|
||||
}
|
||||
|
||||
private static string MakeKey(string snapshotId, string? tenantId) =>
|
||||
tenantId is null ? snapshotId : $"{tenantId}:{snapshotId}";
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.VexLens.Delta;
|
||||
using StellaOps.VexLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DeltaReportBuilder"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class DeltaReportBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public DeltaReportBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EmptyReport_ShouldHaveZeroCounts()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Act
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.TotalCount.Should().Be(0);
|
||||
report.Summary.NewCount.Should().Be(0);
|
||||
report.Summary.ResolvedCount.Should().Be(0);
|
||||
report.HasActionableChanges.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNew_ShouldAddNewEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Act
|
||||
builder.AddNew(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.85,
|
||||
"binary",
|
||||
null,
|
||||
["nvd", "github"]);
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.NewCount.Should().Be(1);
|
||||
report.HasActionableChanges.Should().BeTrue();
|
||||
report.GetSection(DeltaSection.New).Should().HaveCount(1);
|
||||
|
||||
var entry = report.GetSection(DeltaSection.New)[0];
|
||||
entry.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
entry.ProductKey.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
entry.ToStatus.Should().Be(VexStatus.Affected);
|
||||
entry.ContributingSources.Should().Contain("nvd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddResolved_ShouldAddResolvedEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Act
|
||||
builder.AddResolved(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
VexStatus.NotAffected,
|
||||
0.80,
|
||||
0.95,
|
||||
VexJustification.VulnerableCodeNotPresent);
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.ResolvedCount.Should().Be(1);
|
||||
var entry = report.GetSection(DeltaSection.Resolved)[0];
|
||||
entry.FromStatus.Should().Be(VexStatus.Affected);
|
||||
entry.ToStatus.Should().Be(VexStatus.NotAffected);
|
||||
entry.Justification.Should().Be(VexJustification.VulnerableCodeNotPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfidenceChange_AboveThreshold_ShouldAddEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
|
||||
|
||||
// Act
|
||||
builder.AddConfidenceChange(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.50,
|
||||
0.90); // 40% increase
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.ConfidenceUpCount.Should().Be(1);
|
||||
var entry = report.GetSection(DeltaSection.ConfidenceUp)[0];
|
||||
entry.FromConfidence.Should().Be(0.50);
|
||||
entry.ToConfidence.Should().Be(0.90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfidenceChange_BelowThreshold_ShouldNotAddEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
|
||||
|
||||
// Act
|
||||
builder.AddConfidenceChange(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.80,
|
||||
0.85); // Only 5% increase
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.ConfidenceUpCount.Should().Be(0);
|
||||
report.Entries.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfidenceChange_Decrease_ShouldAddConfidenceDownEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { ConfidenceChangeThreshold = 0.15 });
|
||||
|
||||
// Act
|
||||
builder.AddConfidenceChange(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.90,
|
||||
0.50); // 40% decrease
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.ConfidenceDownCount.Should().Be(1);
|
||||
report.GetSection(DeltaSection.ConfidenceDown).Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPolicyImpact_ShouldAddPolicyImpactEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Act
|
||||
builder.AddPolicyImpact(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.85,
|
||||
"Gate decision changed: pass -> fail");
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.PolicyImpactCount.Should().Be(1);
|
||||
report.HasActionableChanges.Should().BeTrue();
|
||||
var entry = report.GetSection(DeltaSection.PolicyImpact)[0];
|
||||
entry.Summary.Should().Contain("Gate decision changed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDamped_WhenExcluded_ShouldNotAddEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { IncludeDamped = false });
|
||||
|
||||
// Act
|
||||
builder.AddDamped(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
VexStatus.NotAffected,
|
||||
0.80,
|
||||
0.75,
|
||||
"Duration threshold not met");
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.DampedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDamped_WhenIncluded_ShouldAddEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { IncludeDamped = true });
|
||||
|
||||
// Act
|
||||
builder.AddDamped(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
VexStatus.NotAffected,
|
||||
0.80,
|
||||
0.75,
|
||||
"Duration threshold not met");
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.DampedCount.Should().Be(1);
|
||||
report.GetSection(DeltaSection.Damped).Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEvidenceChange_ShouldAddEvidenceChangedEntry()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.WithOptions(new DeltaReportOptions { IncludeEvidenceChanges = true });
|
||||
|
||||
// Act
|
||||
builder.AddEvidenceChange(
|
||||
"CVE-2024-1234",
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
VexStatus.Affected,
|
||||
0.85,
|
||||
"heuristic",
|
||||
"binary");
|
||||
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert
|
||||
report.Summary.EvidenceChangedCount.Should().Be(1);
|
||||
var entry = report.GetSection(DeltaSection.EvidenceChanged)[0];
|
||||
entry.FromRationaleClass.Should().Be("heuristic");
|
||||
entry.ToRationaleClass.Should().Be("binary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldSortEntriesDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Add entries in non-sorted order
|
||||
builder.AddNew("CVE-2024-0002", "pkg:b", VexStatus.Affected, 0.8);
|
||||
builder.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8);
|
||||
builder.AddResolved("CVE-2024-0003", "pkg:c", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
|
||||
|
||||
// Act
|
||||
var report = builder.Build();
|
||||
|
||||
// Assert - entries should be sorted by section then vuln ID then product key
|
||||
report.Entries[0].Section.Should().Be(DeltaSection.New);
|
||||
report.Entries[0].VulnerabilityId.Should().Be("CVE-2024-0001");
|
||||
report.Entries[1].VulnerabilityId.Should().Be("CVE-2024-0002");
|
||||
report.Entries[2].Section.Should().Be(DeltaSection.Resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReportId_ShouldBeDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var builder1 = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.AddNew("CVE-2024-1234", "pkg:npm/lodash", VexStatus.Affected, 0.8);
|
||||
|
||||
var builder2 = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.AddNew("CVE-2024-1234", "pkg:npm/lodash", VexStatus.Affected, 0.8);
|
||||
|
||||
// Act
|
||||
var report1 = builder1.Build();
|
||||
var report2 = builder2.Build();
|
||||
|
||||
// Assert - same inputs should produce same report ID
|
||||
report1.ReportId.Should().Be(report2.ReportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNotificationSummary_WithMultipleChanges_ShouldFormatCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8)
|
||||
.AddNew("CVE-2024-0002", "pkg:b", VexStatus.Affected, 0.8)
|
||||
.AddResolved("CVE-2024-0003", "pkg:c", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
|
||||
|
||||
// Act
|
||||
var report = builder.Build();
|
||||
var summary = report.ToNotificationSummary();
|
||||
|
||||
// Assert
|
||||
summary.Should().Contain("2 new");
|
||||
summary.Should().Contain("1 resolved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNotificationSummary_NoChanges_ShouldReturnNoSignificantChanges()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to");
|
||||
|
||||
// Act
|
||||
var report = builder.Build();
|
||||
var summary = report.ToNotificationSummary();
|
||||
|
||||
// Assert
|
||||
summary.Should().Be("No significant changes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BySection_ShouldGroupEntriesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new DeltaReportBuilder(_timeProvider)
|
||||
.WithSnapshots("sha256:from", "sha256:to")
|
||||
.AddNew("CVE-2024-0001", "pkg:a", VexStatus.Affected, 0.8)
|
||||
.AddResolved("CVE-2024-0002", "pkg:b", VexStatus.Affected, VexStatus.Fixed, 0.8, 0.9);
|
||||
|
||||
// Act
|
||||
var report = builder.Build();
|
||||
var bySection = report.BySection;
|
||||
|
||||
// Assert
|
||||
bySection.Should().ContainKey(DeltaSection.New);
|
||||
bySection.Should().ContainKey(DeltaSection.Resolved);
|
||||
bySection[DeltaSection.New].Should().HaveCount(1);
|
||||
bySection[DeltaSection.Resolved].Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.ReachGraph.Deduplication;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.NoiseGate;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.NoiseGate;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="NoiseGateService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class NoiseGateServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly NoiseGateOptions _defaultOptions;
|
||||
|
||||
public NoiseGateServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_defaultOptions = new NoiseGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
EdgeDeduplicationEnabled = true,
|
||||
StabilityDampingEnabled = true,
|
||||
MinConfidenceThreshold = 0.0,
|
||||
ConfidenceChangeThreshold = 0.15
|
||||
};
|
||||
}
|
||||
|
||||
private NoiseGateService CreateService(
|
||||
IEdgeDeduplicator? edgeDeduplicator = null,
|
||||
IStabilityDampingGate? dampingGate = null,
|
||||
NoiseGateOptions? options = null)
|
||||
{
|
||||
var opts = options ?? _defaultOptions;
|
||||
var optionsMonitor = new TestOptionsMonitor<NoiseGateOptions>(opts);
|
||||
|
||||
edgeDeduplicator ??= new EdgeDeduplicator();
|
||||
|
||||
if (dampingGate is null)
|
||||
{
|
||||
var dampingOptions = new StabilityDampingOptions { Enabled = true };
|
||||
var dampingOptionsMonitor = new TestOptionsMonitor<StabilityDampingOptions>(dampingOptions);
|
||||
dampingGate = new StabilityDampingGate(
|
||||
dampingOptionsMonitor,
|
||||
_timeProvider,
|
||||
NullLogger<StabilityDampingGate>.Instance);
|
||||
}
|
||||
|
||||
return new NoiseGateService(
|
||||
edgeDeduplicator,
|
||||
dampingGate,
|
||||
optionsMonitor,
|
||||
_timeProvider,
|
||||
NullLogger<NoiseGateService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DedupeEdgesAsync_WithDuplicateEdges_ShouldDeduplicates()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var edges = new List<ReachGraphEdge>
|
||||
{
|
||||
new()
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9, Loc = "file1.cs:10" }
|
||||
},
|
||||
new()
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85, Loc = "file2.cs:20" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.DedupeEdgesAsync(edges);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].EntryPointId.Should().Be("node-a");
|
||||
result[0].SinkId.Should().Be("node-b");
|
||||
result[0].ProvenanceCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DedupeEdgesAsync_WhenDisabled_ShouldPassThrough()
|
||||
{
|
||||
// Arrange
|
||||
var options = new NoiseGateOptions { Enabled = false };
|
||||
var service = CreateService(options: options);
|
||||
var edges = new List<ReachGraphEdge>
|
||||
{
|
||||
new()
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9 }
|
||||
},
|
||||
new()
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85 }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.DedupeEdgesAsync(edges);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2); // Not deduplicated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveVerdictAsync_NewVerdict_ShouldSurface()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = new VerdictResolutionRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
ProposedStatus = VexStatus.Affected,
|
||||
ProposedConfidence = 0.85
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.ResolveVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.WasSurfaced.Should().BeTrue();
|
||||
result.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
result.Status.Should().Be(VexStatus.Affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveVerdictAsync_WhenDampingDisabled_ShouldAlwaysSurface()
|
||||
{
|
||||
// Arrange
|
||||
var options = new NoiseGateOptions { StabilityDampingEnabled = false };
|
||||
var service = CreateService(options: options);
|
||||
var request = new VerdictResolutionRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
ProposedStatus = VexStatus.Affected,
|
||||
ProposedConfidence = 0.85
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.ResolveVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.WasSurfaced.Should().BeTrue();
|
||||
result.DampingReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateAsync_ShouldDeduplicateAndResolve()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var graph = new ReachGraphMinimal
|
||||
{
|
||||
SchemaVersion = "reachgraph.min@v1",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Scope = new ReachGraphScope(["main"], ["*"]),
|
||||
Nodes = [new ReachGraphNode { Id = "node-a" }, new ReachGraphNode { Id = "node-b" }],
|
||||
Edges =
|
||||
[
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.9 }
|
||||
},
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "node-a",
|
||||
To = "node-b",
|
||||
Why = new EdgeExplanation { Type = EdgeExplanationType.DirectCall, Confidence = 0.85 }
|
||||
}
|
||||
],
|
||||
Provenance = new ReachGraphProvenance("scanner", "1.0", _timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
var request = new NoiseGateRequest
|
||||
{
|
||||
Graph = graph,
|
||||
SnapshotId = "snapshot-001",
|
||||
Verdicts =
|
||||
[
|
||||
new VerdictResolutionRequest
|
||||
{
|
||||
Key = "artifact:CVE-2024-1234",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
ProposedStatus = VexStatus.Affected,
|
||||
ProposedConfidence = 0.85
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.GateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.SnapshotId.Should().Be("snapshot-001");
|
||||
result.Edges.Should().HaveCount(1); // Deduplicated
|
||||
result.Verdicts.Should().HaveCount(1);
|
||||
result.Statistics.EdgeReductionPercent.Should().Be(50.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffAsync_ShouldDetectNewFindings()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
var fromSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-001",
|
||||
Digest = "sha256:from",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts = [],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 0,
|
||||
SurfacedVerdictCount = 0,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
var toSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-002",
|
||||
Digest = "sha256:to",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts =
|
||||
[
|
||||
new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.85,
|
||||
WasSurfaced = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 1,
|
||||
SurfacedVerdictCount = 1,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
|
||||
|
||||
// Assert
|
||||
delta.Summary.NewCount.Should().Be(1);
|
||||
delta.Summary.ResolvedCount.Should().Be(0);
|
||||
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.New);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffAsync_ShouldDetectResolvedFindings()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
var fromSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-001",
|
||||
Digest = "sha256:from",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts =
|
||||
[
|
||||
new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.85,
|
||||
WasSurfaced = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 1,
|
||||
SurfacedVerdictCount = 1,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
var toSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-002",
|
||||
Digest = "sha256:to",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts =
|
||||
[
|
||||
new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.95,
|
||||
WasSurfaced = true,
|
||||
Justification = VexJustification.VulnerableCodeNotPresent,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 1,
|
||||
SurfacedVerdictCount = 1,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
|
||||
|
||||
// Assert
|
||||
delta.Summary.ResolvedCount.Should().Be(1);
|
||||
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.Resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffAsync_ShouldDetectConfidenceChanges()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
var fromSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-001",
|
||||
Digest = "sha256:from",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts =
|
||||
[
|
||||
new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.50,
|
||||
WasSurfaced = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 1,
|
||||
SurfacedVerdictCount = 1,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
var toSnapshot = new GatedGraphSnapshot
|
||||
{
|
||||
SnapshotId = "snapshot-002",
|
||||
Digest = "sha256:to",
|
||||
Artifact = new ReachGraphArtifact("test", "sha256:abc123", []),
|
||||
Edges = [],
|
||||
Verdicts =
|
||||
[
|
||||
new ResolvedVerdict
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.90, // Large increase
|
||||
WasSurfaced = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
}
|
||||
],
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Statistics = new GatingStatistics
|
||||
{
|
||||
OriginalEdgeCount = 0,
|
||||
DeduplicatedEdgeCount = 0,
|
||||
TotalVerdictCount = 1,
|
||||
SurfacedVerdictCount = 1,
|
||||
DampedVerdictCount = 0,
|
||||
Duration = TimeSpan.Zero
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await service.DiffAsync(fromSnapshot, toSnapshot);
|
||||
|
||||
// Assert
|
||||
delta.Summary.ConfidenceUpCount.Should().Be(1);
|
||||
delta.Entries.Should().ContainSingle(e => e.Section == Delta.DeltaSection.ConfidenceUp);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
public TestOptionsMonitor(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string? name) => CurrentValue;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.VexLens.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.VexLens/StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
302
src/Web/StellaOps.Web/src/app/core/api/noise-gating.client.ts
Normal file
302
src/Web/StellaOps.Web/src/app/core/api/noise-gating.client.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Noise-Gating API client.
|
||||
* Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui (NG-FE-003)
|
||||
* Description: API client for noise-gating delta reports from VexLens.
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
|
||||
import { Observable, throwError, of, shareReplay } from 'rxjs';
|
||||
import { map, catchError, tap } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import {
|
||||
NoiseGatingDeltaReport,
|
||||
ComputeDeltaRequest,
|
||||
GatedSnapshotResponse,
|
||||
GateSnapshotRequest,
|
||||
AggregatedGatingStatistics,
|
||||
GatingStatisticsQuery,
|
||||
} from './noise-gating.models';
|
||||
|
||||
/**
|
||||
* Query options for noise-gating API calls.
|
||||
*/
|
||||
export interface NoiseGatingQueryOptions {
|
||||
traceId?: string;
|
||||
bypassCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Noise-gating API interface.
|
||||
*/
|
||||
export interface NoiseGatingApi {
|
||||
// Delta operations
|
||||
computeDelta(request: ComputeDeltaRequest, options?: NoiseGatingQueryOptions): Observable<NoiseGatingDeltaReport>;
|
||||
|
||||
// Snapshot operations
|
||||
gateSnapshot(snapshotId: string, request: GateSnapshotRequest, options?: NoiseGatingQueryOptions): Observable<GatedSnapshotResponse>;
|
||||
|
||||
// Statistics
|
||||
getGatingStatistics(query?: GatingStatisticsQuery, options?: NoiseGatingQueryOptions): Observable<AggregatedGatingStatistics>;
|
||||
}
|
||||
|
||||
export const NOISE_GATING_API = new InjectionToken<NoiseGatingApi>('NOISE_GATING_API');
|
||||
export const NOISE_GATING_API_BASE_URL = new InjectionToken<string>('NOISE_GATING_API_BASE_URL');
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string): string =>
|
||||
baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
|
||||
/**
|
||||
* HTTP implementation of noise-gating API client.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoiseGatingApiHttpClient implements NoiseGatingApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = normalizeBaseUrl(
|
||||
inject(NOISE_GATING_API_BASE_URL, { optional: true }) ?? '/api/v1/vexlens'
|
||||
);
|
||||
|
||||
// Cache for delta reports (key: fromId|toId)
|
||||
private readonly deltaCache = new Map<string, Observable<NoiseGatingDeltaReport>>();
|
||||
|
||||
// Signal-based state for current delta report
|
||||
private readonly _currentReport = signal<NoiseGatingDeltaReport | null>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
|
||||
/** Current delta report signal */
|
||||
readonly currentReport = this._currentReport.asReadonly();
|
||||
|
||||
/** Loading state signal */
|
||||
readonly loading = this._loading.asReadonly();
|
||||
|
||||
/** Error state signal */
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
/** Computed: whether current report has actionable changes */
|
||||
readonly hasActionableChanges = computed(() => this._currentReport()?.hasActionableChanges ?? false);
|
||||
|
||||
/** Computed: summary from current report */
|
||||
readonly summary = computed(() => this._currentReport()?.summary ?? null);
|
||||
|
||||
computeDelta(
|
||||
request: ComputeDeltaRequest,
|
||||
options: NoiseGatingQueryOptions = {}
|
||||
): Observable<NoiseGatingDeltaReport> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
const cacheKey = `${request.fromSnapshotId}|${request.toSnapshotId}`;
|
||||
|
||||
// Check cache unless bypass requested
|
||||
if (!options.bypassCache && this.deltaCache.has(cacheKey)) {
|
||||
return this.deltaCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
const obs = this.http
|
||||
.post<NoiseGatingDeltaReport>(`${this.baseUrl}/deltas/compute`, request, { headers })
|
||||
.pipe(
|
||||
tap((report) => {
|
||||
this._currentReport.set(report);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError((err) => {
|
||||
this._loading.set(false);
|
||||
this._error.set(this.extractErrorMessage(err, traceId));
|
||||
return throwError(() => this.mapError(err, traceId));
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
this.deltaCache.set(cacheKey, obs);
|
||||
return obs;
|
||||
}
|
||||
|
||||
gateSnapshot(
|
||||
snapshotId: string,
|
||||
request: GateSnapshotRequest,
|
||||
options: NoiseGatingQueryOptions = {}
|
||||
): Observable<GatedSnapshotResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
|
||||
return this.http
|
||||
.post<GatedSnapshotResponse>(
|
||||
`${this.baseUrl}/gating/snapshots/${encodeURIComponent(snapshotId)}/gate`,
|
||||
request,
|
||||
{ headers }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getGatingStatistics(
|
||||
query: GatingStatisticsQuery = {},
|
||||
options: NoiseGatingQueryOptions = {}
|
||||
): Observable<AggregatedGatingStatistics> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const headers = this.buildHeaders(traceId);
|
||||
let params = new HttpParams();
|
||||
|
||||
if (query.tenantId) params = params.set('tenantId', query.tenantId);
|
||||
if (query.fromDate) params = params.set('fromDate', query.fromDate);
|
||||
if (query.toDate) params = params.set('toDate', query.toDate);
|
||||
|
||||
return this.http
|
||||
.get<AggregatedGatingStatistics>(`${this.baseUrl}/gating/statistics`, { headers, params })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
/** Clear the delta cache */
|
||||
clearCache(): void {
|
||||
this.deltaCache.clear();
|
||||
this._currentReport.set(null);
|
||||
this._error.set(null);
|
||||
}
|
||||
|
||||
/** Clear current report state */
|
||||
clearCurrentReport(): void {
|
||||
this._currentReport.set(null);
|
||||
this._error.set(null);
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private extractErrorMessage(err: unknown, traceId: string): string {
|
||||
if (err && typeof err === 'object' && 'error' in err) {
|
||||
const httpError = err as { error?: { message?: string } };
|
||||
if (httpError.error?.message) {
|
||||
return httpError.error.message;
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return `Request failed (trace: ${traceId})`;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Noise-gating error: ${err.message}`);
|
||||
}
|
||||
return new Error(`[${traceId}] Noise-gating error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockNoiseGatingClient implements NoiseGatingApi {
|
||||
private readonly mockReport: NoiseGatingDeltaReport = {
|
||||
reportId: 'delta-mock-001',
|
||||
fromSnapshotDigest: 'sha256:abc123',
|
||||
toSnapshotDigest: 'sha256:def456',
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: [
|
||||
{
|
||||
section: 'new',
|
||||
vulnerabilityId: 'CVE-2024-12345',
|
||||
productKey: 'pkg:npm/lodash@4.17.20',
|
||||
toStatus: 'affected',
|
||||
toConfidence: 0.85,
|
||||
summary: 'New affected finding',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
section: 'resolved',
|
||||
vulnerabilityId: 'CVE-2024-11111',
|
||||
productKey: 'pkg:npm/axios@1.6.0',
|
||||
fromStatus: 'affected',
|
||||
toStatus: 'not_affected',
|
||||
fromConfidence: 0.75,
|
||||
toConfidence: 0.95,
|
||||
justification: 'vulnerable_code_not_present',
|
||||
summary: 'Resolved: affected -> not_affected',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
section: 'confidence_up',
|
||||
vulnerabilityId: 'CVE-2024-22222',
|
||||
productKey: 'pkg:npm/express@4.18.2',
|
||||
fromStatus: 'affected',
|
||||
toStatus: 'affected',
|
||||
fromConfidence: 0.6,
|
||||
toConfidence: 0.9,
|
||||
summary: 'Confidence increased: 60% -> 90%',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalCount: 3,
|
||||
newCount: 1,
|
||||
resolvedCount: 1,
|
||||
confidenceUpCount: 1,
|
||||
confidenceDownCount: 0,
|
||||
policyImpactCount: 0,
|
||||
dampedCount: 0,
|
||||
evidenceChangedCount: 0,
|
||||
},
|
||||
hasActionableChanges: true,
|
||||
};
|
||||
|
||||
computeDelta(
|
||||
_request: ComputeDeltaRequest,
|
||||
_options?: NoiseGatingQueryOptions
|
||||
): Observable<NoiseGatingDeltaReport> {
|
||||
return of(this.mockReport);
|
||||
}
|
||||
|
||||
gateSnapshot(
|
||||
snapshotId: string,
|
||||
_request: GateSnapshotRequest,
|
||||
_options?: NoiseGatingQueryOptions
|
||||
): Observable<GatedSnapshotResponse> {
|
||||
return of({
|
||||
snapshotId,
|
||||
digest: 'sha256:mock123',
|
||||
createdAt: new Date().toISOString(),
|
||||
edgeCount: 150,
|
||||
verdictCount: 45,
|
||||
statistics: {
|
||||
originalEdgeCount: 200,
|
||||
deduplicatedEdgeCount: 150,
|
||||
edgeReductionPercent: 25,
|
||||
totalVerdictCount: 50,
|
||||
surfacedVerdictCount: 45,
|
||||
dampedVerdictCount: 5,
|
||||
duration: '00:00:01.234',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getGatingStatistics(
|
||||
_query?: GatingStatisticsQuery,
|
||||
_options?: NoiseGatingQueryOptions
|
||||
): Observable<AggregatedGatingStatistics> {
|
||||
return of({
|
||||
totalSnapshots: 100,
|
||||
totalEdgesProcessed: 15000,
|
||||
totalEdgesAfterDedup: 12000,
|
||||
averageEdgeReductionPercent: 20,
|
||||
totalVerdicts: 5000,
|
||||
totalSurfaced: 4500,
|
||||
totalDamped: 500,
|
||||
averageDampingPercent: 10,
|
||||
computedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
266
src/Web/StellaOps.Web/src/app/core/api/noise-gating.models.ts
Normal file
266
src/Web/StellaOps.Web/src/app/core/api/noise-gating.models.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Noise-Gating Delta Report Models
|
||||
* Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
* Description: TypeScript models for noise-gating delta reports from VexLens.
|
||||
*/
|
||||
|
||||
import { VexStatementStatus } from './vex-hub.models';
|
||||
|
||||
// Delta section types - matches backend DeltaSection enum
|
||||
export type NoiseGatingDeltaSection =
|
||||
| 'new'
|
||||
| 'resolved'
|
||||
| 'confidence_up'
|
||||
| 'confidence_down'
|
||||
| 'policy_impact'
|
||||
| 'damped'
|
||||
| 'evidence_changed';
|
||||
|
||||
// VEX justification types for delta entries
|
||||
export type VexJustification =
|
||||
| 'component_not_present'
|
||||
| 'vulnerable_code_not_present'
|
||||
| 'vulnerable_code_not_in_execute_path'
|
||||
| 'vulnerable_code_cannot_be_controlled_by_adversary'
|
||||
| 'inline_mitigations_already_exist';
|
||||
|
||||
/**
|
||||
* Single delta entry in API format.
|
||||
*/
|
||||
export interface NoiseGatingDeltaEntry {
|
||||
readonly section: NoiseGatingDeltaSection;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly productKey: string;
|
||||
readonly fromStatus?: VexStatementStatus;
|
||||
readonly toStatus?: VexStatementStatus;
|
||||
readonly fromConfidence?: number;
|
||||
readonly toConfidence?: number;
|
||||
readonly justification?: VexJustification;
|
||||
readonly fromRationaleClass?: string;
|
||||
readonly toRationaleClass?: string;
|
||||
readonly summary?: string;
|
||||
readonly contributingSources?: readonly string[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary counts for delta report.
|
||||
*/
|
||||
export interface NoiseGatingDeltaSummary {
|
||||
readonly totalCount: number;
|
||||
readonly newCount: number;
|
||||
readonly resolvedCount: number;
|
||||
readonly confidenceUpCount: number;
|
||||
readonly confidenceDownCount: number;
|
||||
readonly policyImpactCount: number;
|
||||
readonly dampedCount: number;
|
||||
readonly evidenceChangedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta report response from backend.
|
||||
*/
|
||||
export interface NoiseGatingDeltaReport {
|
||||
readonly reportId: string;
|
||||
readonly fromSnapshotDigest: string;
|
||||
readonly toSnapshotDigest: string;
|
||||
readonly generatedAt: string;
|
||||
readonly entries: readonly NoiseGatingDeltaEntry[];
|
||||
readonly summary: NoiseGatingDeltaSummary;
|
||||
readonly hasActionableChanges: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to compute delta between two snapshots.
|
||||
*/
|
||||
export interface ComputeDeltaRequest {
|
||||
readonly fromSnapshotId: string;
|
||||
readonly toSnapshotId: string;
|
||||
readonly tenantId?: string;
|
||||
readonly options?: DeltaReportOptionsRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for delta report computation.
|
||||
*/
|
||||
export interface DeltaReportOptionsRequest {
|
||||
readonly confidenceChangeThreshold?: number;
|
||||
readonly includeDamped?: boolean;
|
||||
readonly includeEvidenceChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gating statistics for API response.
|
||||
*/
|
||||
export interface GatingStatistics {
|
||||
readonly originalEdgeCount: number;
|
||||
readonly deduplicatedEdgeCount: number;
|
||||
readonly edgeReductionPercent: number;
|
||||
readonly totalVerdictCount: number;
|
||||
readonly surfacedVerdictCount: number;
|
||||
readonly dampedVerdictCount: number;
|
||||
readonly duration: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from gating a snapshot.
|
||||
*/
|
||||
export interface GatedSnapshotResponse {
|
||||
readonly snapshotId: string;
|
||||
readonly digest: string;
|
||||
readonly createdAt: string;
|
||||
readonly edgeCount: number;
|
||||
readonly verdictCount: number;
|
||||
readonly statistics: GatingStatistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to gate a graph snapshot.
|
||||
*/
|
||||
export interface GateSnapshotRequest {
|
||||
readonly snapshotId: string;
|
||||
readonly tenantId?: string;
|
||||
readonly options?: NoiseGateOptionsRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for noise-gating.
|
||||
*/
|
||||
export interface NoiseGateOptionsRequest {
|
||||
readonly edgeDeduplicationEnabled?: boolean;
|
||||
readonly stabilityDampingEnabled?: boolean;
|
||||
readonly minConfidenceThreshold?: number;
|
||||
readonly confidenceChangeThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated gating statistics response.
|
||||
*/
|
||||
export interface AggregatedGatingStatistics {
|
||||
readonly totalSnapshots: number;
|
||||
readonly totalEdgesProcessed: number;
|
||||
readonly totalEdgesAfterDedup: number;
|
||||
readonly averageEdgeReductionPercent: number;
|
||||
readonly totalVerdicts: number;
|
||||
readonly totalSurfaced: number;
|
||||
readonly totalDamped: number;
|
||||
readonly averageDampingPercent: number;
|
||||
readonly computedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for gating statistics.
|
||||
*/
|
||||
export interface GatingStatisticsQuery {
|
||||
readonly tenantId?: string;
|
||||
readonly fromDate?: string;
|
||||
readonly toDate?: string;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
/**
|
||||
* Get display label for delta section.
|
||||
*/
|
||||
export function getSectionLabel(section: NoiseGatingDeltaSection): string {
|
||||
switch (section) {
|
||||
case 'new': return 'New';
|
||||
case 'resolved': return 'Resolved';
|
||||
case 'confidence_up': return 'Confidence Up';
|
||||
case 'confidence_down': return 'Confidence Down';
|
||||
case 'policy_impact': return 'Policy Impact';
|
||||
case 'damped': return 'Damped';
|
||||
case 'evidence_changed': return 'Evidence Changed';
|
||||
default: return section;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS color class for delta section.
|
||||
*/
|
||||
export function getSectionColorClass(section: NoiseGatingDeltaSection): string {
|
||||
switch (section) {
|
||||
case 'new': return 'section-new';
|
||||
case 'resolved': return 'section-resolved';
|
||||
case 'confidence_up': return 'section-confidence-up';
|
||||
case 'confidence_down': return 'section-confidence-down';
|
||||
case 'policy_impact': return 'section-policy-impact';
|
||||
case 'damped': return 'section-damped';
|
||||
case 'evidence_changed': return 'section-evidence';
|
||||
default: return 'section-unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for delta section (ASCII-only per CLAUDE.md rules).
|
||||
*/
|
||||
export function getSectionIcon(section: NoiseGatingDeltaSection): string {
|
||||
switch (section) {
|
||||
case 'new': return '+';
|
||||
case 'resolved': return '-';
|
||||
case 'confidence_up': return '^';
|
||||
case 'confidence_down': return 'v';
|
||||
case 'policy_impact': return '!';
|
||||
case 'damped': return '~';
|
||||
case 'evidence_changed': return '*';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format confidence as percentage.
|
||||
*/
|
||||
export function formatConfidence(confidence?: number): string {
|
||||
if (confidence === undefined || confidence === null) return '--';
|
||||
return (confidence * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format confidence delta.
|
||||
*/
|
||||
export function formatConfidenceDelta(from?: number, to?: number): string {
|
||||
if (from === undefined || to === undefined) return '--';
|
||||
const delta = (to - from) * 100;
|
||||
const sign = delta >= 0 ? '+' : '';
|
||||
return sign + delta.toFixed(0) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a section represents an actionable change.
|
||||
*/
|
||||
export function isActionableSection(section: NoiseGatingDeltaSection): boolean {
|
||||
return section === 'new' || section === 'policy_impact' || section === 'confidence_up';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the priority order for section sorting.
|
||||
*/
|
||||
export function getSectionPriority(section: NoiseGatingDeltaSection): number {
|
||||
switch (section) {
|
||||
case 'new': return 1;
|
||||
case 'policy_impact': return 2;
|
||||
case 'confidence_up': return 3;
|
||||
case 'confidence_down': return 4;
|
||||
case 'resolved': return 5;
|
||||
case 'evidence_changed': return 6;
|
||||
case 'damped': return 7;
|
||||
default: return 99;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group entries by section.
|
||||
*/
|
||||
export function groupEntriesBySection(
|
||||
entries: readonly NoiseGatingDeltaEntry[]
|
||||
): Map<NoiseGatingDeltaSection, NoiseGatingDeltaEntry[]> {
|
||||
const grouped = new Map<NoiseGatingDeltaSection, NoiseGatingDeltaEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const existing = grouped.get(entry.section) ?? [];
|
||||
existing.push(entry);
|
||||
grouped.set(entry.section, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
@@ -58,3 +58,11 @@ export { VexTrustDisplayComponent } from './vex-trust-display/vex-trust-display.
|
||||
export { ReplayCommandComponent } from './replay-command/replay-command.component';
|
||||
export { VerdictLadderComponent } from './verdict-ladder/verdict-ladder.component';
|
||||
export { CaseHeaderComponent } from './case-header/case-header.component';
|
||||
|
||||
// Noise-Gating Delta Report (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
export {
|
||||
NoiseGatingSummaryStripComponent,
|
||||
DeltaEntryCardComponent,
|
||||
NoiseGatingDeltaReportComponent,
|
||||
GatingStatisticsCardComponent,
|
||||
} from './noise-gating';
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// delta-entry-card.component.ts
|
||||
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
// Task: NG-FE-005 - DeltaEntryCardComponent
|
||||
// Description: Card component for displaying individual delta entries.
|
||||
// Shows CVE ID, package, status transition, and confidence changes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, input, output, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
NoiseGatingDeltaEntry,
|
||||
getSectionLabel,
|
||||
getSectionColorClass,
|
||||
formatConfidence,
|
||||
formatConfidenceDelta,
|
||||
isActionableSection,
|
||||
} from '../../../../core/api/noise-gating.models';
|
||||
|
||||
/**
|
||||
* Card component for individual delta entries.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-delta-entry-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="delta-entry-card"
|
||||
[class]="sectionClass()"
|
||||
[class.actionable]="isActionable()"
|
||||
(click)="onCardClick()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(keydown.enter)="onCardClick()"
|
||||
(keydown.space)="onCardClick()"
|
||||
>
|
||||
<!-- Section badge -->
|
||||
<div class="delta-entry-card__section">
|
||||
<span class="delta-entry-card__section-badge">
|
||||
{{ sectionLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="delta-entry-card__content">
|
||||
<!-- Vulnerability ID -->
|
||||
<div class="delta-entry-card__vuln">
|
||||
<span class="delta-entry-card__vuln-id">{{ entry().vulnerabilityId }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Product/Package -->
|
||||
<div class="delta-entry-card__product" [title]="entry().productKey">
|
||||
{{ shortProductKey() }}
|
||||
</div>
|
||||
|
||||
<!-- Status transition -->
|
||||
@if (hasStatusChange()) {
|
||||
<div class="delta-entry-card__status-change">
|
||||
<span class="delta-entry-card__status delta-entry-card__status--from">
|
||||
{{ entry().fromStatus ?? '-' }}
|
||||
</span>
|
||||
<span class="delta-entry-card__arrow">-></span>
|
||||
<span class="delta-entry-card__status delta-entry-card__status--to">
|
||||
{{ entry().toStatus ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Confidence change -->
|
||||
@if (hasConfidenceChange()) {
|
||||
<div class="delta-entry-card__confidence">
|
||||
<span class="delta-entry-card__confidence-from">{{ fromConfidence() }}</span>
|
||||
<span class="delta-entry-card__arrow">-></span>
|
||||
<span class="delta-entry-card__confidence-to">{{ toConfidence() }}</span>
|
||||
<span class="delta-entry-card__confidence-delta" [class.positive]="isConfidenceUp()" [class.negative]="!isConfidenceUp()">
|
||||
({{ confidenceDelta() }})
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Summary -->
|
||||
@if (entry().summary) {
|
||||
<div class="delta-entry-card__summary">{{ entry().summary }}</div>
|
||||
}
|
||||
|
||||
<!-- Justification -->
|
||||
@if (entry().justification) {
|
||||
<div class="delta-entry-card__justification">
|
||||
<span class="delta-entry-card__justification-label">Justification:</span>
|
||||
{{ formatJustification(entry().justification) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contributing sources -->
|
||||
@if (entry().contributingSources?.length) {
|
||||
<div class="delta-entry-card__sources">
|
||||
<span class="delta-entry-card__sources-label">Sources:</span>
|
||||
@for (source of entry().contributingSources; track source) {
|
||||
<span class="delta-entry-card__source-tag">{{ source }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="delta-entry-card__timestamp">
|
||||
{{ formattedTimestamp() }}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.delta-entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.delta-entry-card:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.delta-entry-card:focus {
|
||||
outline: 2px solid var(--primary-color, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.delta-entry-card.actionable {
|
||||
border-left: 3px solid var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
/* Section-specific left border colors */
|
||||
.delta-entry-card.section-new { border-left-color: #22c55e; }
|
||||
.delta-entry-card.section-resolved { border-left-color: #3b82f6; }
|
||||
.delta-entry-card.section-confidence-up { border-left-color: #14b8a6; }
|
||||
.delta-entry-card.section-confidence-down { border-left-color: #f97316; }
|
||||
.delta-entry-card.section-policy-impact { border-left-color: #ef4444; }
|
||||
.delta-entry-card.section-damped { border-left-color: #9ca3af; }
|
||||
.delta-entry-card.section-evidence { border-left-color: #8b5cf6; }
|
||||
|
||||
.delta-entry-card__section {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.delta-entry-card__section-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.delta-entry-card__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.delta-entry-card__vuln-id {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary, #111827);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.delta-entry-card__product {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delta-entry-card__status-change,
|
||||
.delta-entry-card__confidence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.delta-entry-card__arrow {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: monospace;
|
||||
}
|
||||
.delta-entry-card__status {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-entry-card__status--from {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
.delta-entry-card__status--to {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.delta-entry-card__confidence-from,
|
||||
.delta-entry-card__confidence-to {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-entry-card__confidence-delta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delta-entry-card__confidence-delta.positive { color: #15803d; }
|
||||
.delta-entry-card__confidence-delta.negative { color: #dc2626; }
|
||||
|
||||
.delta-entry-card__summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.delta-entry-card__justification {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
.delta-entry-card__justification-label {
|
||||
font-weight: 500;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.delta-entry-card__sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.delta-entry-card__sources-label {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-entry-card__source-tag {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.delta-entry-card__timestamp {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
text-align: right;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class DeltaEntryCardComponent {
|
||||
/** The delta entry to display */
|
||||
readonly entry = input.required<NoiseGatingDeltaEntry>();
|
||||
|
||||
/** Emits when card is clicked */
|
||||
readonly cardClick = output<NoiseGatingDeltaEntry>();
|
||||
|
||||
/** Section label */
|
||||
readonly sectionLabel = computed(() => getSectionLabel(this.entry().section));
|
||||
|
||||
/** Section CSS class */
|
||||
readonly sectionClass = computed(() => getSectionColorClass(this.entry().section));
|
||||
|
||||
/** Whether this is an actionable section */
|
||||
readonly isActionable = computed(() => isActionableSection(this.entry().section));
|
||||
|
||||
/** Short product key (last part of PURL) */
|
||||
readonly shortProductKey = computed(() => {
|
||||
const purl = this.entry().productKey;
|
||||
// Extract name@version from PURL like pkg:npm/name@version
|
||||
const match = purl.match(/\/([^/]+)$/);
|
||||
return match ? match[1] : purl;
|
||||
});
|
||||
|
||||
/** Whether there's a status change */
|
||||
readonly hasStatusChange = computed(() => {
|
||||
const e = this.entry();
|
||||
return e.fromStatus !== undefined && e.toStatus !== undefined && e.fromStatus !== e.toStatus;
|
||||
});
|
||||
|
||||
/** Whether there's a confidence change */
|
||||
readonly hasConfidenceChange = computed(() => {
|
||||
const e = this.entry();
|
||||
return e.fromConfidence !== undefined && e.toConfidence !== undefined;
|
||||
});
|
||||
|
||||
/** Formatted from confidence */
|
||||
readonly fromConfidence = computed(() => formatConfidence(this.entry().fromConfidence));
|
||||
|
||||
/** Formatted to confidence */
|
||||
readonly toConfidence = computed(() => formatConfidence(this.entry().toConfidence));
|
||||
|
||||
/** Formatted confidence delta */
|
||||
readonly confidenceDelta = computed(() =>
|
||||
formatConfidenceDelta(this.entry().fromConfidence, this.entry().toConfidence)
|
||||
);
|
||||
|
||||
/** Whether confidence increased */
|
||||
readonly isConfidenceUp = computed(() => {
|
||||
const e = this.entry();
|
||||
if (e.fromConfidence === undefined || e.toConfidence === undefined) return false;
|
||||
return e.toConfidence > e.fromConfidence;
|
||||
});
|
||||
|
||||
/** Formatted timestamp */
|
||||
readonly formattedTimestamp = computed(() => {
|
||||
const ts = this.entry().createdAt;
|
||||
if (!ts) return '';
|
||||
try {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
});
|
||||
|
||||
/** Handle card click */
|
||||
onCardClick(): void {
|
||||
this.cardClick.emit(this.entry());
|
||||
}
|
||||
|
||||
/** Format justification for display */
|
||||
formatJustification(j: string | undefined): string {
|
||||
if (!j) return '';
|
||||
// Convert snake_case to Title Case
|
||||
return j.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// gating-statistics-card.component.ts
|
||||
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
// Task: NG-FE-007 - GatingStatisticsCardComponent
|
||||
// Description: Card component displaying noise-gating statistics.
|
||||
// Shows edge reduction and verdict damping metrics.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
GatingStatistics,
|
||||
AggregatedGatingStatistics,
|
||||
} from '../../../../core/api/noise-gating.models';
|
||||
|
||||
/**
|
||||
* Card component for displaying gating statistics.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-gating-statistics-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="gating-stats-card">
|
||||
<header class="gating-stats-card__header">
|
||||
<h3 class="gating-stats-card__title">{{ title() }}</h3>
|
||||
@if (computedAt()) {
|
||||
<span class="gating-stats-card__timestamp">{{ formattedComputedAt() }}</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="gating-stats-card__body">
|
||||
<!-- Edge Reduction Section -->
|
||||
<div class="gating-stats-card__section">
|
||||
<h4 class="gating-stats-card__section-title">Edge Deduplication</h4>
|
||||
<div class="gating-stats-card__metrics">
|
||||
<div class="gating-stats-card__metric">
|
||||
<span class="gating-stats-card__metric-label">Original</span>
|
||||
<span class="gating-stats-card__metric-value">{{ edgesOriginal() }}</span>
|
||||
</div>
|
||||
<div class="gating-stats-card__metric-arrow">-></div>
|
||||
<div class="gating-stats-card__metric">
|
||||
<span class="gating-stats-card__metric-label">After Dedup</span>
|
||||
<span class="gating-stats-card__metric-value">{{ edgesDeduped() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gating-stats-card__progress-container">
|
||||
<div class="gating-stats-card__progress">
|
||||
<div
|
||||
class="gating-stats-card__progress-bar gating-stats-card__progress-bar--reduction"
|
||||
[style.width.%]="edgeReductionPercent()"
|
||||
></div>
|
||||
</div>
|
||||
<span class="gating-stats-card__progress-label">
|
||||
{{ edgeReductionPercent().toFixed(1) }}% reduction
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verdict Damping Section -->
|
||||
<div class="gating-stats-card__section">
|
||||
<h4 class="gating-stats-card__section-title">Verdict Damping</h4>
|
||||
<div class="gating-stats-card__verdict-stats">
|
||||
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--surfaced">
|
||||
<span class="gating-stats-card__verdict-icon">+</span>
|
||||
<span class="gating-stats-card__verdict-value">{{ verdictsSurfaced() }}</span>
|
||||
<span class="gating-stats-card__verdict-label">Surfaced</span>
|
||||
</div>
|
||||
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--damped">
|
||||
<span class="gating-stats-card__verdict-icon">~</span>
|
||||
<span class="gating-stats-card__verdict-value">{{ verdictsDamped() }}</span>
|
||||
<span class="gating-stats-card__verdict-label">Damped</span>
|
||||
</div>
|
||||
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--total">
|
||||
<span class="gating-stats-card__verdict-icon">=</span>
|
||||
<span class="gating-stats-card__verdict-value">{{ verdictsTotal() }}</span>
|
||||
<span class="gating-stats-card__verdict-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (verdictsTotal() > 0) {
|
||||
<div class="gating-stats-card__ratio-bar">
|
||||
<div
|
||||
class="gating-stats-card__ratio-segment gating-stats-card__ratio-segment--surfaced"
|
||||
[style.flex-grow]="verdictsSurfaced()"
|
||||
[title]="verdictsSurfaced() + ' surfaced'"
|
||||
></div>
|
||||
<div
|
||||
class="gating-stats-card__ratio-segment gating-stats-card__ratio-segment--damped"
|
||||
[style.flex-grow]="verdictsDamped()"
|
||||
[title]="verdictsDamped() + ' damped'"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Duration (if single snapshot) -->
|
||||
@if (duration()) {
|
||||
<div class="gating-stats-card__duration">
|
||||
<span class="gating-stats-card__duration-label">Processing time:</span>
|
||||
<span class="gating-stats-card__duration-value">{{ duration() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aggregated stats (if available) -->
|
||||
@if (showAggregated() && aggregatedStats()) {
|
||||
<div class="gating-stats-card__aggregated">
|
||||
<h4 class="gating-stats-card__section-title">Aggregated</h4>
|
||||
<div class="gating-stats-card__agg-stats">
|
||||
<div class="gating-stats-card__agg-stat">
|
||||
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.totalSnapshots }}</span>
|
||||
<span class="gating-stats-card__agg-label">snapshots</span>
|
||||
</div>
|
||||
<div class="gating-stats-card__agg-stat">
|
||||
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.averageEdgeReductionPercent.toFixed(1) }}%</span>
|
||||
<span class="gating-stats-card__agg-label">avg reduction</span>
|
||||
</div>
|
||||
<div class="gating-stats-card__agg-stat">
|
||||
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.averageDampingPercent.toFixed(1) }}%</span>
|
||||
<span class="gating-stats-card__agg-label">avg damping</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gating-stats-card {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gating-stats-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
.gating-stats-card__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
.gating-stats-card__timestamp {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.gating-stats-card__body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gating-stats-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gating-stats-card__section-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Edge metrics */
|
||||
.gating-stats-card__metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gating-stats-card__metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.gating-stats-card__metric-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
.gating-stats-card__metric-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
.gating-stats-card__metric-arrow {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.gating-stats-card__progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gating-stats-card__progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary, #e5e7eb);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gating-stats-card__progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.gating-stats-card__progress-bar--reduction {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
}
|
||||
.gating-stats-card__progress-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #15803d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Verdict stats */
|
||||
.gating-stats-card__verdict-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.gating-stats-card__verdict-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
min-width: 60px;
|
||||
}
|
||||
.gating-stats-card__verdict-stat--surfaced {
|
||||
background: #dcfce7;
|
||||
}
|
||||
.gating-stats-card__verdict-stat--damped {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.gating-stats-card__verdict-stat--total {
|
||||
background: #dbeafe;
|
||||
}
|
||||
.gating-stats-card__verdict-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.gating-stats-card__verdict-stat--surfaced .gating-stats-card__verdict-icon { color: #15803d; }
|
||||
.gating-stats-card__verdict-stat--damped .gating-stats-card__verdict-icon { color: #6b7280; }
|
||||
.gating-stats-card__verdict-stat--total .gating-stats-card__verdict-icon { color: #1e40af; }
|
||||
.gating-stats-card__verdict-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gating-stats-card__verdict-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Ratio bar */
|
||||
.gating-stats-card__ratio-bar {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gating-stats-card__ratio-segment {
|
||||
min-width: 2px;
|
||||
transition: flex-grow 0.3s ease;
|
||||
}
|
||||
.gating-stats-card__ratio-segment--surfaced {
|
||||
background: #22c55e;
|
||||
}
|
||||
.gating-stats-card__ratio-segment--damped {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Duration */
|
||||
.gating-stats-card__duration {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.gating-stats-card__duration-label {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
.gating-stats-card__duration-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Aggregated */
|
||||
.gating-stats-card__aggregated {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.gating-stats-card__agg-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.gating-stats-card__agg-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.gating-stats-card__agg-value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gating-stats-card__agg-label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class GatingStatisticsCardComponent {
|
||||
/** Single snapshot statistics */
|
||||
readonly statistics = input<GatingStatistics | null>(null);
|
||||
|
||||
/** Aggregated statistics (for overview) */
|
||||
readonly aggregatedStats = input<AggregatedGatingStatistics | null>(null);
|
||||
|
||||
/** Card title */
|
||||
readonly title = input('Gating Statistics');
|
||||
|
||||
/** Whether to show aggregated section */
|
||||
readonly showAggregated = input(false);
|
||||
|
||||
// Computed values for single snapshot
|
||||
readonly edgesOriginal = computed(() => this.statistics()?.originalEdgeCount ?? 0);
|
||||
readonly edgesDeduped = computed(() => this.statistics()?.deduplicatedEdgeCount ?? 0);
|
||||
readonly edgeReductionPercent = computed(() => this.statistics()?.edgeReductionPercent ?? 0);
|
||||
readonly verdictsTotal = computed(() => this.statistics()?.totalVerdictCount ?? 0);
|
||||
readonly verdictsSurfaced = computed(() => this.statistics()?.surfacedVerdictCount ?? 0);
|
||||
readonly verdictsDamped = computed(() => this.statistics()?.dampedVerdictCount ?? 0);
|
||||
readonly duration = computed(() => this.statistics()?.duration);
|
||||
|
||||
/** Computed timestamp from aggregated stats */
|
||||
readonly computedAt = computed(() => this.aggregatedStats()?.computedAt);
|
||||
|
||||
/** Formatted computed at timestamp */
|
||||
readonly formattedComputedAt = computed(() => {
|
||||
const ts = this.computedAt();
|
||||
if (!ts) return '';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// index.ts
|
||||
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
// Description: Barrel exports for noise-gating components.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export { NoiseGatingSummaryStripComponent } from './noise-gating-summary-strip.component';
|
||||
export { DeltaEntryCardComponent } from './delta-entry-card.component';
|
||||
export { NoiseGatingDeltaReportComponent } from './noise-gating-delta-report.component';
|
||||
export { GatingStatisticsCardComponent } from './gating-statistics-card.component';
|
||||
@@ -0,0 +1,393 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// noise-gating-delta-report.component.ts
|
||||
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
// Task: NG-FE-006 - NoiseGatingDeltaReportComponent
|
||||
// Description: Container component for displaying noise-gating delta reports.
|
||||
// Uses tabs for section navigation with summary strip and entry cards.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
NoiseGatingDeltaReport,
|
||||
NoiseGatingDeltaEntry,
|
||||
NoiseGatingDeltaSection,
|
||||
getSectionLabel,
|
||||
groupEntriesBySection,
|
||||
getSectionPriority,
|
||||
} from '../../../../core/api/noise-gating.models';
|
||||
import { NoiseGatingSummaryStripComponent } from './noise-gating-summary-strip.component';
|
||||
import { DeltaEntryCardComponent } from './delta-entry-card.component';
|
||||
|
||||
/** Tab definition for section navigation */
|
||||
interface SectionTab {
|
||||
section: NoiseGatingDeltaSection;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container component for noise-gating delta reports.
|
||||
* Provides tabbed navigation through delta sections with entry cards.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-noise-gating-delta-report',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NoiseGatingSummaryStripComponent,
|
||||
DeltaEntryCardComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ng-delta-report">
|
||||
<!-- Header with summary -->
|
||||
<header class="ng-delta-report__header">
|
||||
<div class="ng-delta-report__title">
|
||||
<h2>Delta Report</h2>
|
||||
@if (report()) {
|
||||
<span class="ng-delta-report__id">{{ report()!.reportId }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (report()?.generatedAt) {
|
||||
<div class="ng-delta-report__meta">
|
||||
Generated: {{ formattedGeneratedAt() }}
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Summary strip -->
|
||||
@if (report()) {
|
||||
<app-noise-gating-summary-strip
|
||||
[summary]="report()!.summary"
|
||||
[hasActionableChanges]="report()!.hasActionableChanges"
|
||||
[activeSection]="activeSection()"
|
||||
[showDamped]="showDamped()"
|
||||
[showEvidenceChanges]="showEvidenceChanges()"
|
||||
(sectionClick)="onSectionSelect($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<nav class="ng-delta-report__tabs" role="tablist">
|
||||
@for (tab of tabs(); track tab.section) {
|
||||
<button
|
||||
type="button"
|
||||
class="ng-delta-report__tab"
|
||||
[class.active]="activeSection() === tab.section"
|
||||
[class.empty]="tab.count === 0"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeSection() === tab.section"
|
||||
[attr.aria-controls]="'panel-' + tab.section"
|
||||
(click)="onSectionSelect(tab.section)"
|
||||
>
|
||||
<span class="ng-delta-report__tab-label">{{ tab.label }}</span>
|
||||
<span class="ng-delta-report__tab-count">{{ tab.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="ng-delta-report__content">
|
||||
@if (loading()) {
|
||||
<div class="ng-delta-report__loading">
|
||||
<div class="ng-delta-report__spinner"></div>
|
||||
<span>Loading delta report...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="ng-delta-report__error">
|
||||
<span class="ng-delta-report__error-icon">!</span>
|
||||
<span>{{ error() }}</span>
|
||||
</div>
|
||||
} @else if (!report()) {
|
||||
<div class="ng-delta-report__empty">
|
||||
<span>No delta report available</span>
|
||||
<span class="ng-delta-report__empty-hint">
|
||||
Select two snapshots to compare
|
||||
</span>
|
||||
</div>
|
||||
} @else if (filteredEntries().length === 0) {
|
||||
<div class="ng-delta-report__no-entries">
|
||||
<span>No entries in this section</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="ng-delta-report__entries"
|
||||
role="tabpanel"
|
||||
[attr.id]="'panel-' + activeSection()"
|
||||
>
|
||||
@for (entry of filteredEntries(); track entry.vulnerabilityId + entry.productKey) {
|
||||
<app-delta-entry-card
|
||||
[entry]="entry"
|
||||
(cardClick)="onEntryClick($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer with snapshot info -->
|
||||
@if (report()) {
|
||||
<footer class="ng-delta-report__footer">
|
||||
<div class="ng-delta-report__snapshot-info">
|
||||
<span class="ng-delta-report__snapshot">
|
||||
From: <code>{{ truncateDigest(report()!.fromSnapshotDigest) }}</code>
|
||||
</span>
|
||||
<span class="ng-delta-report__snapshot">
|
||||
To: <code>{{ truncateDigest(report()!.toSnapshotDigest) }}</code>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.ng-delta-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ng-delta-report__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ng-delta-report__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ng-delta-report__title h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
.ng-delta-report__id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.ng-delta-report__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.ng-delta-report__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
padding-bottom: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ng-delta-report__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ng-delta-report__tab:hover:not(.empty) {
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
.ng-delta-report__tab.active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
.ng-delta-report__tab.empty {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.ng-delta-report__tab-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.ng-delta-report__tab-count {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ng-delta-report__tab.active .ng-delta-report__tab-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.ng-delta-report__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
.ng-delta-report__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.ng-delta-report__loading,
|
||||
.ng-delta-report__error,
|
||||
.ng-delta-report__empty,
|
||||
.ng-delta-report__no-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
.ng-delta-report__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color, #e5e7eb);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.ng-delta-report__error {
|
||||
color: var(--error-color, #dc2626);
|
||||
}
|
||||
.ng-delta-report__error-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ng-delta-report__empty-hint {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.ng-delta-report__footer {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.ng-delta-report__snapshot-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
.ng-delta-report__snapshot code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class NoiseGatingDeltaReportComponent {
|
||||
/** The delta report to display */
|
||||
readonly report = input<NoiseGatingDeltaReport | null>(null);
|
||||
|
||||
/** Loading state */
|
||||
readonly loading = input(false);
|
||||
|
||||
/** Error message */
|
||||
readonly error = input<string | null>(null);
|
||||
|
||||
/** Whether to show damped section */
|
||||
readonly showDamped = input(true);
|
||||
|
||||
/** Whether to show evidence changes section */
|
||||
readonly showEvidenceChanges = input(true);
|
||||
|
||||
/** Emits when an entry card is clicked */
|
||||
readonly entryClick = output<NoiseGatingDeltaEntry>();
|
||||
|
||||
/** Currently active section */
|
||||
readonly activeSection = signal<NoiseGatingDeltaSection>('new');
|
||||
|
||||
/** Available tabs based on report content */
|
||||
readonly tabs = computed<SectionTab[]>(() => {
|
||||
const r = this.report();
|
||||
if (!r) return [];
|
||||
|
||||
const allSections: SectionTab[] = [
|
||||
{ section: 'new', label: 'New', count: r.summary.newCount },
|
||||
{ section: 'resolved', label: 'Resolved', count: r.summary.resolvedCount },
|
||||
{ section: 'confidence_up', label: 'Conf+', count: r.summary.confidenceUpCount },
|
||||
{ section: 'confidence_down', label: 'Conf-', count: r.summary.confidenceDownCount },
|
||||
{ section: 'policy_impact', label: 'Policy', count: r.summary.policyImpactCount },
|
||||
];
|
||||
|
||||
if (this.showDamped()) {
|
||||
allSections.push({ section: 'damped', label: 'Damped', count: r.summary.dampedCount });
|
||||
}
|
||||
if (this.showEvidenceChanges()) {
|
||||
allSections.push({ section: 'evidence_changed', label: 'Evidence', count: r.summary.evidenceChangedCount });
|
||||
}
|
||||
|
||||
// Sort by priority and filter out empty if configured
|
||||
return allSections.sort((a, b) => getSectionPriority(a.section) - getSectionPriority(b.section));
|
||||
});
|
||||
|
||||
/** Entries filtered by active section */
|
||||
readonly filteredEntries = computed<NoiseGatingDeltaEntry[]>(() => {
|
||||
const r = this.report();
|
||||
if (!r) return [];
|
||||
|
||||
const grouped = groupEntriesBySection(r.entries);
|
||||
return grouped.get(this.activeSection()) ?? [];
|
||||
});
|
||||
|
||||
/** Formatted generated timestamp */
|
||||
readonly formattedGeneratedAt = computed(() => {
|
||||
const ts = this.report()?.generatedAt;
|
||||
if (!ts) return '';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
});
|
||||
|
||||
/** Handle section selection */
|
||||
onSectionSelect(section: NoiseGatingDeltaSection): void {
|
||||
this.activeSection.set(section);
|
||||
}
|
||||
|
||||
/** Handle entry card click */
|
||||
onEntryClick(entry: NoiseGatingDeltaEntry): void {
|
||||
this.entryClick.emit(entry);
|
||||
}
|
||||
|
||||
/** Truncate digest for display */
|
||||
truncateDigest(digest: string): string {
|
||||
if (!digest) return '';
|
||||
if (digest.length <= 20) return digest;
|
||||
// Format: sha256:abc123...def456
|
||||
const prefix = digest.slice(0, 12);
|
||||
const suffix = digest.slice(-6);
|
||||
return `${prefix}...${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// noise-gating-summary-strip.component.ts
|
||||
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
|
||||
// Task: NG-FE-004 - NoiseGatingSummaryStripComponent
|
||||
// Description: Summary strip showing delta section counts for noise-gating reports.
|
||||
// Follows DeltaSummaryStripComponent pattern with noise-gating-specific sections.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, input, computed, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
NoiseGatingDeltaSummary,
|
||||
NoiseGatingDeltaSection,
|
||||
getSectionLabel,
|
||||
} from '../../../../core/api/noise-gating.models';
|
||||
|
||||
/**
|
||||
* Summary strip component for noise-gating delta reports.
|
||||
* Displays section counts as interactive badges.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-noise-gating-summary-strip',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="ng-summary-strip" role="status" aria-live="polite">
|
||||
<div class="ng-summary-strip__counts">
|
||||
<!-- New findings -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--new"
|
||||
[class.empty]="!summary()?.newCount"
|
||||
[class.active]="activeSection() === 'new'"
|
||||
(click)="onSectionClick('new')"
|
||||
[attr.aria-label]="summary()?.newCount + ' new findings'"
|
||||
>
|
||||
<span class="ng-badge__icon">+</span>
|
||||
<span class="ng-badge__count">{{ summary()?.newCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">new</span>
|
||||
</button>
|
||||
|
||||
<!-- Resolved -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--resolved"
|
||||
[class.empty]="!summary()?.resolvedCount"
|
||||
[class.active]="activeSection() === 'resolved'"
|
||||
(click)="onSectionClick('resolved')"
|
||||
[attr.aria-label]="summary()?.resolvedCount + ' resolved findings'"
|
||||
>
|
||||
<span class="ng-badge__icon">-</span>
|
||||
<span class="ng-badge__count">{{ summary()?.resolvedCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">resolved</span>
|
||||
</button>
|
||||
|
||||
<!-- Confidence Up -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--confidence-up"
|
||||
[class.empty]="!summary()?.confidenceUpCount"
|
||||
[class.active]="activeSection() === 'confidence_up'"
|
||||
(click)="onSectionClick('confidence_up')"
|
||||
[attr.aria-label]="summary()?.confidenceUpCount + ' confidence increased'"
|
||||
>
|
||||
<span class="ng-badge__icon">^</span>
|
||||
<span class="ng-badge__count">{{ summary()?.confidenceUpCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">conf+</span>
|
||||
</button>
|
||||
|
||||
<!-- Confidence Down -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--confidence-down"
|
||||
[class.empty]="!summary()?.confidenceDownCount"
|
||||
[class.active]="activeSection() === 'confidence_down'"
|
||||
(click)="onSectionClick('confidence_down')"
|
||||
[attr.aria-label]="summary()?.confidenceDownCount + ' confidence decreased'"
|
||||
>
|
||||
<span class="ng-badge__icon">v</span>
|
||||
<span class="ng-badge__count">{{ summary()?.confidenceDownCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">conf-</span>
|
||||
</button>
|
||||
|
||||
<!-- Policy Impact -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--policy"
|
||||
[class.empty]="!summary()?.policyImpactCount"
|
||||
[class.active]="activeSection() === 'policy_impact'"
|
||||
(click)="onSectionClick('policy_impact')"
|
||||
[attr.aria-label]="summary()?.policyImpactCount + ' policy impacts'"
|
||||
>
|
||||
<span class="ng-badge__icon">!</span>
|
||||
<span class="ng-badge__count">{{ summary()?.policyImpactCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">policy</span>
|
||||
</button>
|
||||
|
||||
@if (showDamped()) {
|
||||
<!-- Damped -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--damped"
|
||||
[class.empty]="!summary()?.dampedCount"
|
||||
[class.active]="activeSection() === 'damped'"
|
||||
(click)="onSectionClick('damped')"
|
||||
[attr.aria-label]="summary()?.dampedCount + ' damped'"
|
||||
>
|
||||
<span class="ng-badge__icon">~</span>
|
||||
<span class="ng-badge__count">{{ summary()?.dampedCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">damped</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showEvidenceChanges()) {
|
||||
<!-- Evidence Changed -->
|
||||
<button
|
||||
type="button"
|
||||
class="ng-badge ng-badge--evidence"
|
||||
[class.empty]="!summary()?.evidenceChangedCount"
|
||||
[class.active]="activeSection() === 'evidence_changed'"
|
||||
(click)="onSectionClick('evidence_changed')"
|
||||
[attr.aria-label]="summary()?.evidenceChangedCount + ' evidence changes'"
|
||||
>
|
||||
<span class="ng-badge__icon">*</span>
|
||||
<span class="ng-badge__count">{{ summary()?.evidenceChangedCount ?? 0 }}</span>
|
||||
<span class="ng-badge__label">evidence</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ng-summary-strip__meta">
|
||||
<span class="ng-summary-strip__total">
|
||||
<span class="ng-summary-strip__total-label">Total:</span>
|
||||
<span class="ng-summary-strip__total-count">{{ totalCount() }}</span>
|
||||
</span>
|
||||
@if (hasActionableChanges()) {
|
||||
<span class="ng-summary-strip__actionable" title="Has actionable changes">
|
||||
Action needed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.ng-summary-strip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ng-summary-strip__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ng-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: opacity 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ng-badge:hover:not(.empty) { transform: scale(1.03); }
|
||||
.ng-badge.empty { opacity: 0.4; cursor: default; }
|
||||
.ng-badge.active {
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #3b82f6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.ng-badge__icon {
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
.ng-badge__count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.25ch;
|
||||
text-align: center;
|
||||
}
|
||||
.ng-badge__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* Section colors */
|
||||
.ng-badge--new {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-color: #86efac;
|
||||
}
|
||||
.ng-badge--resolved {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
.ng-badge--confidence-up {
|
||||
background: #ccfbf1;
|
||||
color: #0d9488;
|
||||
border-color: #5eead4;
|
||||
}
|
||||
.ng-badge--confidence-down {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
border-color: #fdba74;
|
||||
}
|
||||
.ng-badge--policy {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
.ng-badge--damped {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.ng-badge--evidence {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
.ng-summary-strip__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.ng-summary-strip__total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ng-summary-strip__total-label { color: var(--text-muted, #6b7280); }
|
||||
.ng-summary-strip__total-count { font-weight: 600; }
|
||||
.ng-summary-strip__actionable {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class NoiseGatingSummaryStripComponent {
|
||||
/** The delta summary to display */
|
||||
readonly summary = input<NoiseGatingDeltaSummary | null>(null);
|
||||
|
||||
/** Whether to show damped section */
|
||||
readonly showDamped = input(true);
|
||||
|
||||
/** Whether to show evidence changes section */
|
||||
readonly showEvidenceChanges = input(true);
|
||||
|
||||
/** Whether the report has actionable changes */
|
||||
readonly hasActionableChanges = input(false);
|
||||
|
||||
/** Currently active/selected section */
|
||||
readonly activeSection = input<NoiseGatingDeltaSection | null>(null);
|
||||
|
||||
/** Emits when a section badge is clicked */
|
||||
readonly sectionClick = output<NoiseGatingDeltaSection>();
|
||||
|
||||
/** Total count of all entries */
|
||||
readonly totalCount = computed(() => {
|
||||
const s = this.summary();
|
||||
if (!s) return 0;
|
||||
return s.totalCount;
|
||||
});
|
||||
|
||||
/** Handle section badge click */
|
||||
onSectionClick(section: NoiseGatingDeltaSection): void {
|
||||
const count = this.getSectionCount(section);
|
||||
if (count > 0) {
|
||||
this.sectionClick.emit(section);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get count for a specific section */
|
||||
private getSectionCount(section: NoiseGatingDeltaSection): number {
|
||||
const s = this.summary();
|
||||
if (!s) return 0;
|
||||
|
||||
switch (section) {
|
||||
case 'new': return s.newCount;
|
||||
case 'resolved': return s.resolvedCount;
|
||||
case 'confidence_up': return s.confidenceUpCount;
|
||||
case 'confidence_down': return s.confidenceDownCount;
|
||||
case 'policy_impact': return s.policyImpactCount;
|
||||
case 'damped': return s.dampedCount;
|
||||
case 'evidence_changed': return s.evidenceChangedCount;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,21 @@ import { VulnerabilityListService, type Vulnerability, type VulnerabilityFilter
|
||||
import { AdvisoryAiService, type AiRecommendation } from '../../services/advisory-ai.service';
|
||||
import { VexDecisionService, type VexDecision } from '../../services/vex-decision.service';
|
||||
|
||||
// Noise-gating delta report integration (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
import { NoiseGatingDeltaReportComponent } from '../noise-gating/noise-gating-delta-report.component';
|
||||
import { GatingStatisticsCardComponent } from '../noise-gating/gating-statistics-card.component';
|
||||
import {
|
||||
NoiseGatingApiClient,
|
||||
NOISE_GATING_API_CLIENT,
|
||||
} from '../../../../core/api/noise-gating.client';
|
||||
import {
|
||||
NoiseGatingDeltaReport,
|
||||
NoiseGatingDeltaEntry,
|
||||
GatingStatistics,
|
||||
} from '../../../../core/api/noise-gating.models';
|
||||
|
||||
export type CanvasPaneMode = 'list' | 'split' | 'detail';
|
||||
export type CanvasDetailTab = 'overview' | 'reachability' | 'ai' | 'history' | 'evidence';
|
||||
export type CanvasDetailTab = 'overview' | 'reachability' | 'ai' | 'history' | 'evidence' | 'delta';
|
||||
|
||||
interface CanvasLayout {
|
||||
leftPaneWidth: number;
|
||||
@@ -34,7 +47,7 @@ interface CanvasLayout {
|
||||
@Component({
|
||||
selector: 'app-triage-canvas',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, RouterLink, NoiseGatingDeltaReportComponent, GatingStatisticsCardComponent],
|
||||
template: `
|
||||
<div class="triage-canvas" [class.triage-canvas--split]="layout().mode === 'split'" [class.triage-canvas--detail]="layout().mode === 'detail'">
|
||||
<!-- Header -->
|
||||
@@ -413,6 +426,26 @@ interface CanvasLayout {
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@case ('delta') {
|
||||
<section class="detail-panel detail-panel--delta" id="panel-delta" role="tabpanel" aria-labelledby="tab-delta">
|
||||
<div class="delta-panel-layout">
|
||||
<div class="delta-panel-layout__main">
|
||||
<app-noise-gating-delta-report
|
||||
[report]="deltaReport()"
|
||||
[loading]="deltaLoading()"
|
||||
[error]="deltaError()"
|
||||
(entryClick)="onDeltaEntryClick($event)"
|
||||
/>
|
||||
</div>
|
||||
<aside class="delta-panel-layout__sidebar">
|
||||
<app-gating-statistics-card
|
||||
[statistics]="gatingStatistics()"
|
||||
title="Gating Statistics"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1137,6 +1170,41 @@ interface CanvasLayout {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Delta Panel Layout (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui) */
|
||||
.detail-panel--delta {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delta-panel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.delta-panel-layout__main {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.delta-panel-layout__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.delta-panel-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.delta-panel-layout__sidebar {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.triage-canvas--split .triage-canvas__list-pane {
|
||||
@@ -1162,6 +1230,9 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// Noise-gating delta integration (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
private readonly noiseGatingClient = inject(NOISE_GATING_API_CLIENT, { optional: true });
|
||||
|
||||
private subscriptions: Subscription[] = [];
|
||||
private resizing = false;
|
||||
|
||||
@@ -1178,6 +1249,7 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
{ id: 'ai', label: 'AI Analysis' },
|
||||
{ id: 'history', label: 'VEX History' },
|
||||
{ id: 'evidence', label: 'Evidence' },
|
||||
{ id: 'delta', label: 'Delta' },
|
||||
];
|
||||
|
||||
readonly layout = signal<CanvasLayout>({
|
||||
@@ -1198,6 +1270,12 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
return this.aiService.getCachedRecommendations(selected.id) ?? [];
|
||||
});
|
||||
|
||||
// Delta report signals (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
readonly deltaReport = signal<NoiseGatingDeltaReport | null>(null);
|
||||
readonly deltaLoading = signal(false);
|
||||
readonly deltaError = signal<string | null>(null);
|
||||
readonly gatingStatistics = signal<GatingStatistics | null>(null);
|
||||
|
||||
private keyboardStatusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -1265,8 +1343,13 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
case 'Escape':
|
||||
this.clearBulkSelection();
|
||||
break;
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
this.setDetailTab('delta');
|
||||
this.announceStatus('Delta Report');
|
||||
break;
|
||||
case '?':
|
||||
this.announceStatus('N: Next, P: Prev, M: Mark Not Affected, A: Analyze, V: VEX');
|
||||
this.announceStatus('N: Next, P: Prev, M: Mark Not Affected, A: Analyze, V: VEX, D: Delta');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1291,6 +1374,11 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
|
||||
setDetailTab(tab: CanvasDetailTab): void {
|
||||
this.activeDetailTab.set(tab);
|
||||
|
||||
// Load delta report when switching to delta tab (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
if (tab === 'delta' && !this.deltaReport() && !this.deltaLoading()) {
|
||||
this.loadDeltaReport();
|
||||
}
|
||||
}
|
||||
|
||||
isSeverityActive(severity: string): boolean {
|
||||
@@ -1464,6 +1552,65 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// Delta report methods (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
|
||||
onDeltaEntryClick(entry: NoiseGatingDeltaEntry): void {
|
||||
// Navigate to the specific vulnerability if it's in our list
|
||||
const items = this.vulnService.items();
|
||||
const matchingVuln = items.find(v => v.cveId === entry.vulnerabilityId);
|
||||
if (matchingVuln) {
|
||||
this.selectVulnerability(matchingVuln);
|
||||
this.activeDetailTab.set('overview');
|
||||
} else {
|
||||
// Show the entry details in a status message
|
||||
this.announceStatus(`${entry.vulnerabilityId}: ${entry.section}`);
|
||||
}
|
||||
}
|
||||
|
||||
loadDeltaReport(): void {
|
||||
if (!this.noiseGatingClient) {
|
||||
this.deltaError.set('Delta report API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.deltaLoading.set(true);
|
||||
this.deltaError.set(null);
|
||||
|
||||
// For demo: use mock snapshot IDs - in real usage these would come from scan context
|
||||
const fromSnapshotId = 'snapshot-previous';
|
||||
const toSnapshotId = 'snapshot-current';
|
||||
|
||||
this.subscriptions.push(
|
||||
this.noiseGatingClient.computeDelta(fromSnapshotId, toSnapshotId).subscribe({
|
||||
next: (report) => {
|
||||
this.deltaReport.set(report);
|
||||
this.deltaLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.deltaError.set(err?.message ?? 'Failed to load delta report');
|
||||
this.deltaLoading.set(false);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Also load gating statistics
|
||||
this.loadGatingStatistics();
|
||||
}
|
||||
|
||||
private loadGatingStatistics(): void {
|
||||
if (!this.noiseGatingClient) return;
|
||||
|
||||
this.subscriptions.push(
|
||||
this.noiseGatingClient.getGatingStatistics().subscribe({
|
||||
next: (stats) => {
|
||||
this.gatingStatistics.set(stats);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load gating statistics:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private selectNextVuln(): void {
|
||||
const items = this.vulnService.items();
|
||||
const current = this.vulnService.selectedItem();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// An edge that has been deduplicated from multiple source edges.
|
||||
/// Preserves provenance by tracking all contributing sources.
|
||||
/// </summary>
|
||||
public sealed record DeduplicatedEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the semantic key for this edge.
|
||||
/// </summary>
|
||||
public required EdgeSemanticKey Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source node ID (from entry point).
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target node ID (to sink).
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the aggregated explanation for this edge.
|
||||
/// </summary>
|
||||
public required EdgeExplanation Why { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the set of source identifiers that contributed this edge.
|
||||
/// </summary>
|
||||
public required ImmutableHashSet<string> Sources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum strength (weight) among all contributing sources.
|
||||
/// </summary>
|
||||
public required double Strength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the most recent observation of this edge.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of contributing sources.
|
||||
/// </summary>
|
||||
public int SourceCount => Sources.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this edge has multiple confirming sources.
|
||||
/// </summary>
|
||||
public bool IsCorroborated => Sources.Count > 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating <see cref="DeduplicatedEdge"/> instances by merging multiple source edges.
|
||||
/// </summary>
|
||||
public sealed class DeduplicatedEdgeBuilder
|
||||
{
|
||||
private readonly EdgeSemanticKey _key;
|
||||
private readonly string _from;
|
||||
private readonly string _to;
|
||||
private readonly HashSet<string> _sources = new(StringComparer.Ordinal);
|
||||
private EdgeExplanation? _explanation;
|
||||
private double _maxStrength;
|
||||
private DateTimeOffset _lastSeen = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new builder for the given semantic key.
|
||||
/// </summary>
|
||||
public DeduplicatedEdgeBuilder(EdgeSemanticKey key, string from, string to)
|
||||
{
|
||||
_key = key;
|
||||
_from = from;
|
||||
_to = to;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source edge to this builder.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source identifier (e.g., feed name, analyzer ID).</param>
|
||||
/// <param name="explanation">The edge explanation from this source.</param>
|
||||
/// <param name="strength">The strength/weight from this source.</param>
|
||||
/// <param name="observedAt">When this source observed the edge.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public DeduplicatedEdgeBuilder AddSource(
|
||||
string sourceId,
|
||||
EdgeExplanation explanation,
|
||||
double strength,
|
||||
DateTimeOffset observedAt)
|
||||
{
|
||||
_sources.Add(sourceId);
|
||||
|
||||
// Keep the strongest explanation
|
||||
if (strength > _maxStrength || _explanation is null)
|
||||
{
|
||||
_maxStrength = strength;
|
||||
_explanation = explanation;
|
||||
}
|
||||
|
||||
// Track most recent observation
|
||||
if (observedAt > _lastSeen)
|
||||
{
|
||||
_lastSeen = observedAt;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the deduplicated edge.
|
||||
/// </summary>
|
||||
/// <returns>The deduplicated edge with merged provenance.</returns>
|
||||
/// <exception cref="InvalidOperationException">If no sources were added.</exception>
|
||||
public DeduplicatedEdge Build()
|
||||
{
|
||||
if (_sources.Count == 0 || _explanation is null)
|
||||
{
|
||||
throw new InvalidOperationException("At least one source must be added before building.");
|
||||
}
|
||||
|
||||
return new DeduplicatedEdge
|
||||
{
|
||||
Key = _key,
|
||||
From = _from,
|
||||
To = _to,
|
||||
Why = _explanation,
|
||||
Sources = _sources.ToImmutableHashSet(StringComparer.Ordinal),
|
||||
Strength = _maxStrength,
|
||||
LastSeen = _lastSeen
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// Service for deduplicating edges from multiple sources into semantically unique edges.
|
||||
/// </summary>
|
||||
public interface IEdgeDeduplicator
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates a collection of edges by their semantic keys.
|
||||
/// </summary>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="keyExtractor">Function to extract semantic key from an edge.</param>
|
||||
/// <param name="sourceExtractor">Function to extract source ID from an edge.</param>
|
||||
/// <param name="strengthExtractor">Function to extract strength/weight from an edge.</param>
|
||||
/// <param name="timestampExtractor">Function to extract observation timestamp.</param>
|
||||
/// <returns>Deduplicated edges with merged provenance.</returns>
|
||||
IReadOnlyList<DeduplicatedEdge> Deduplicate(
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
|
||||
Func<ReachGraphEdge, string> sourceExtractor,
|
||||
Func<ReachGraphEdge, double> strengthExtractor,
|
||||
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IEdgeDeduplicator"/>.
|
||||
/// </summary>
|
||||
public sealed class EdgeDeduplicator : IEdgeDeduplicator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance.
|
||||
/// </summary>
|
||||
public static IEdgeDeduplicator Instance { get; } = new EdgeDeduplicator();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<DeduplicatedEdge> Deduplicate(
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
|
||||
Func<ReachGraphEdge, string> sourceExtractor,
|
||||
Func<ReachGraphEdge, double> strengthExtractor,
|
||||
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edges);
|
||||
ArgumentNullException.ThrowIfNull(keyExtractor);
|
||||
ArgumentNullException.ThrowIfNull(sourceExtractor);
|
||||
ArgumentNullException.ThrowIfNull(strengthExtractor);
|
||||
ArgumentNullException.ThrowIfNull(timestampExtractor);
|
||||
|
||||
// Group edges by semantic key
|
||||
var builders = new Dictionary<EdgeSemanticKey, DeduplicatedEdgeBuilder>();
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var key = keyExtractor(edge);
|
||||
|
||||
if (!builders.TryGetValue(key, out var builder))
|
||||
{
|
||||
builder = new DeduplicatedEdgeBuilder(key, edge.From, edge.To);
|
||||
builders[key] = builder;
|
||||
}
|
||||
|
||||
builder.AddSource(
|
||||
sourceExtractor(edge),
|
||||
edge.Why,
|
||||
strengthExtractor(edge),
|
||||
timestampExtractor(edge));
|
||||
}
|
||||
|
||||
// Build deduplicated edges, sorted by strength descending for stability
|
||||
return builders.Values
|
||||
.Select(b => b.Build())
|
||||
.OrderByDescending(e => e.Strength)
|
||||
.ThenBy(e => e.Key.ComputeKey(), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for edge deduplication.
|
||||
/// </summary>
|
||||
public static class EdgeDeduplicatorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates edges using default extractors based on edge properties.
|
||||
/// </summary>
|
||||
/// <param name="deduplicator">The deduplicator instance.</param>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="vulnerabilityId">The vulnerability ID to associate with edges.</param>
|
||||
/// <param name="defaultSource">Default source ID if not specified.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>Deduplicated edges.</returns>
|
||||
public static IReadOnlyList<DeduplicatedEdge> DeduplicateWithDefaults(
|
||||
this IEdgeDeduplicator deduplicator,
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
string vulnerabilityId,
|
||||
string defaultSource = "unknown",
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var now = time.GetUtcNow();
|
||||
|
||||
return deduplicator.Deduplicate(
|
||||
edges,
|
||||
keyExtractor: e => new EdgeSemanticKey(e.From, e.To, vulnerabilityId),
|
||||
sourceExtractor: _ => defaultSource,
|
||||
strengthExtractor: e => GetEdgeStrength(e.Why),
|
||||
timestampExtractor: _ => now);
|
||||
}
|
||||
|
||||
private static double GetEdgeStrength(EdgeExplanation explanation)
|
||||
{
|
||||
// Use the explanation's confidence as the base strength
|
||||
// Map edge explanation type to a multiplier
|
||||
var typeMultiplier = explanation.Type switch
|
||||
{
|
||||
EdgeExplanationType.DirectCall => 1.0,
|
||||
EdgeExplanationType.Import => 0.95,
|
||||
EdgeExplanationType.DynamicLoad => 0.9,
|
||||
EdgeExplanationType.Ffi => 0.85,
|
||||
EdgeExplanationType.Reflection => 0.8,
|
||||
EdgeExplanationType.LoaderRule => 0.75,
|
||||
EdgeExplanationType.TaintGate => 0.7,
|
||||
EdgeExplanationType.EnvGuard => 0.65,
|
||||
EdgeExplanationType.FeatureFlag => 0.6,
|
||||
EdgeExplanationType.PlatformArch => 0.6,
|
||||
EdgeExplanationType.Unknown => 0.5,
|
||||
_ => 0.5
|
||||
};
|
||||
|
||||
return explanation.Confidence * typeMultiplier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic key for edge deduplication that identifies edges with equivalent meaning
|
||||
/// regardless of their source or representation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Edges from different sources (reachability analysis, call graph, binary analysis)
|
||||
/// may represent the same semantic relationship. This key normalizes them for deduplication.
|
||||
///
|
||||
/// Two edges are semantically equivalent if they have the same:
|
||||
/// - Entry point node ID
|
||||
/// - Sink node ID
|
||||
/// - Vulnerability ID (if applicable)
|
||||
/// - Applied gate (if any)
|
||||
/// </remarks>
|
||||
public readonly record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the entry point node identifier.
|
||||
/// </summary>
|
||||
public string EntryPointId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sink (vulnerable) node identifier.
|
||||
/// </summary>
|
||||
public string SinkId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability identifier, if this edge is associated with one.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applied gate identifier, if any gate was applied to this edge.
|
||||
/// </summary>
|
||||
public string? GateApplied { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EdgeSemanticKey"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="entryPointId">The entry point node ID.</param>
|
||||
/// <param name="sinkId">The sink node ID.</param>
|
||||
/// <param name="vulnerabilityId">Optional vulnerability ID.</param>
|
||||
/// <param name="gateApplied">Optional gate identifier.</param>
|
||||
public EdgeSemanticKey(
|
||||
string entryPointId,
|
||||
string sinkId,
|
||||
string? vulnerabilityId = null,
|
||||
string? gateApplied = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryPointId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sinkId);
|
||||
|
||||
EntryPointId = entryPointId;
|
||||
SinkId = sinkId;
|
||||
VulnerabilityId = NormalizeId(vulnerabilityId);
|
||||
GateApplied = gateApplied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a canonical string key for this semantic key.
|
||||
/// </summary>
|
||||
/// <returns>A canonical string representation suitable for dictionary keys.</returns>
|
||||
public string ComputeKey()
|
||||
{
|
||||
var builder = new StringBuilder(256);
|
||||
builder.Append(EntryPointId);
|
||||
builder.Append('|');
|
||||
builder.Append(SinkId);
|
||||
builder.Append('|');
|
||||
builder.Append(VulnerabilityId ?? string.Empty);
|
||||
builder.Append('|');
|
||||
builder.Append(GateApplied ?? string.Empty);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a SHA-256 hash of the canonical key for compact storage.
|
||||
/// </summary>
|
||||
/// <returns>A lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var key = ComputeKey();
|
||||
var bytes = Encoding.UTF8.GetBytes(key);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(
|
||||
EntryPointId,
|
||||
SinkId,
|
||||
VulnerabilityId ?? string.Empty,
|
||||
GateApplied ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(EdgeSemanticKey other)
|
||||
{
|
||||
return string.Equals(EntryPointId, other.EntryPointId, StringComparison.Ordinal) &&
|
||||
string.Equals(SinkId, other.SinkId, StringComparison.Ordinal) &&
|
||||
string.Equals(VulnerabilityId, other.VulnerabilityId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(GateApplied, other.GateApplied, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => ComputeKey();
|
||||
|
||||
private static string? NormalizeId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize CVE IDs to uppercase for consistent comparison
|
||||
if (id.StartsWith("cve-", StringComparison.OrdinalIgnoreCase) ||
|
||||
id.StartsWith("CVE-", StringComparison.Ordinal))
|
||||
{
|
||||
return id.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user