Add post-quantum cryptography support with PqSoftCryptoProvider
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled

- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle.
- Added PqSoftProviderOptions and PqSoftKeyOptions for configuration.
- Created unit tests for Dilithium3 and Falcon512 signing and verification.
- Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists.
- Added KcmvpHashOnlyProvider for KCMVP baseline compliance.
- Updated project files and dependencies for new libraries and testing frameworks.
This commit is contained in:
StellaOps Bot
2025-12-07 15:04:19 +02:00
parent 862bb6ed80
commit 98e6b76584
119 changed files with 11436 additions and 1732 deletions

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Infrastructure.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.SmSoft;
using Xunit;
namespace StellaOps.Attestor.Tests.Signing;
public class Sm2AttestorTests
{
private readonly string? _gate;
public Sm2AttestorTests()
{
_gate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
}
[Fact]
public void Registry_ResolvesSm2_WhenGateEnabled()
{
var keyPath = Sm2TestKeyFactory.WriteTempPem();
var options = Options.Create(new AttestorOptions
{
Signing = new AttestorOptions.SigningOptions
{
PreferredProviders = new[] { "cn.sm.soft" },
Keys = new List<AttestorOptions.SigningKeyOptions>
{
new()
{
KeyId = "sm2-key",
Algorithm = SignatureAlgorithms.Sm2,
KeyPath = keyPath,
MaterialFormat = "pem",
Enabled = true,
Provider = "cn.sm.soft"
}
}
}
});
var registry = new AttestorSigningKeyRegistry(
options,
TimeProvider.System,
NullLogger<AttestorSigningKeyRegistry>.Instance);
var entry = registry.GetRequired("sm2-key");
Assert.Equal(SignatureAlgorithms.Sm2, entry.Algorithm);
Assert.Equal("cn.sm.soft", entry.ProviderName);
var signer = registry.Registry.ResolveSigner(CryptoCapability.Signing, SignatureAlgorithms.Sm2, entry.Key.Reference).Signer;
var payload = System.Text.Encoding.UTF8.GetBytes("sm2-attestor-test");
var sig = signer.SignAsync(payload, CancellationToken.None).Result;
Assert.True(signer.VerifyAsync(payload, sig, CancellationToken.None).Result);
}
[Fact]
public void Registry_Throws_WhenGateDisabled()
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", null);
var keyPath = Sm2TestKeyFactory.WriteTempPem();
var options = Options.Create(new AttestorOptions
{
Signing = new AttestorOptions.SigningOptions
{
PreferredProviders = new[] { "cn.sm.soft" },
Keys = new List<AttestorOptions.SigningKeyOptions>
{
new()
{
KeyId = "sm2-key",
Algorithm = SignatureAlgorithms.Sm2,
KeyPath = keyPath,
MaterialFormat = "pem",
Enabled = true,
Provider = "cn.sm.soft"
}
}
}
});
Assert.Throws<InvalidOperationException>(() =>
new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance));
}
public void Dispose()
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", _gate);
}
}
internal static class Sm2TestKeyFactory
{
public static string WriteTempPem()
{
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
var domain = new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
var generator = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("EC");
generator.Init(new Org.BouncyCastle.Crypto.Generators.ECKeyGenerationParameters(domain, new Org.BouncyCastle.Security.SecureRandom()));
var pair = generator.GenerateKeyPair();
var privInfo = Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private);
var pem = Convert.ToBase64String(privInfo.GetDerEncoded());
var path = System.IO.Path.GetTempFileName();
System.IO.File.WriteAllText(path, "-----BEGIN PRIVATE KEY-----\n" + pem + "\n-----END PRIVATE KEY-----\n");
return path;
}
}

View File

@@ -8,6 +8,7 @@
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />

View File

@@ -64,18 +64,18 @@ internal static class CommandFactory
root.Add(BuildPromotionCommand(services, verboseOption, cancellationToken));
root.Add(BuildDetscoreCommand(services, verboseOption, cancellationToken));
root.Add(BuildObsCommand(services, verboseOption, cancellationToken));
root.Add(BuildPackCommand(services, verboseOption, cancellationToken));
root.Add(BuildExceptionsCommand(services, verboseOption, cancellationToken));
root.Add(BuildOrchCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomCommand(services, verboseOption, cancellationToken));
root.Add(BuildNotifyCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomerCommand(services, verboseOption, cancellationToken));
root.Add(BuildCvssCommand(services, verboseOption, cancellationToken));
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
root.Add(BuildPackCommand(services, verboseOption, cancellationToken));
root.Add(BuildExceptionsCommand(services, verboseOption, cancellationToken));
root.Add(BuildOrchCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomCommand(services, verboseOption, cancellationToken));
root.Add(BuildNotifyCommand(services, verboseOption, cancellationToken));
root.Add(BuildSbomerCommand(services, verboseOption, cancellationToken));
root.Add(BuildCvssCommand(services, verboseOption, cancellationToken));
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
@@ -127,79 +127,79 @@ internal static class CommandFactory
return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken);
});
scanner.Add(download);
return scanner;
}
private static Command BuildCvssCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var cvss = new Command("cvss", "CVSS v4.0 receipt operations (score, show, history, export)." );
var score = new Command("score", "Create a CVSS v4 receipt for a vulnerability.");
var vulnOption = new Option<string>("--vuln") { Description = "Vulnerability identifier (e.g., CVE).", IsRequired = true };
var policyFileOption = new Option<string>("--policy-file") { Description = "Path to CvssPolicy JSON file.", IsRequired = true };
var vectorOption = new Option<string>("--vector") { Description = "CVSS:4.0 vector string.", IsRequired = true };
var jsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
score.Add(vulnOption);
score.Add(policyFileOption);
score.Add(vectorOption);
score.Add(jsonOption);
score.SetAction((parseResult, _) =>
{
var vuln = parseResult.GetValue(vulnOption) ?? string.Empty;
var policyPath = parseResult.GetValue(policyFileOption) ?? string.Empty;
var vector = parseResult.GetValue(vectorOption) ?? string.Empty;
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssScoreAsync(services, vuln, policyPath, vector, json, verbose, cancellationToken);
});
var show = new Command("show", "Fetch a CVSS receipt by ID.");
var receiptArg = new Argument<string>("receipt-id") { Description = "Receipt identifier." };
show.Add(receiptArg);
var showJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
show.Add(showJsonOption);
show.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var json = parseResult.GetValue(showJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssShowAsync(services, receiptId, json, verbose, cancellationToken);
});
var history = new Command("history", "Show receipt amendment history.");
history.Add(receiptArg);
var historyJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
history.Add(historyJsonOption);
history.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var json = parseResult.GetValue(historyJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssHistoryAsync(services, receiptId, json, verbose, cancellationToken);
});
var export = new Command("export", "Export a CVSS receipt to JSON (pdf not yet supported).");
export.Add(receiptArg);
var formatOption = new Option<string>("--format") { Description = "json|pdf (json default)." };
var outOption = new Option<string>("--out") { Description = "Output file path." };
export.Add(formatOption);
export.Add(outOption);
export.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "json";
var output = parseResult.GetValue(outOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssExportAsync(services, receiptId, format, output, verbose, cancellationToken);
});
cvss.Add(score);
cvss.Add(show);
cvss.Add(history);
cvss.Add(export);
return cvss;
}
scanner.Add(download);
return scanner;
}
private static Command BuildCvssCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var cvss = new Command("cvss", "CVSS v4.0 receipt operations (score, show, history, export)." );
var score = new Command("score", "Create a CVSS v4 receipt for a vulnerability.");
var vulnOption = new Option<string>("--vuln") { Description = "Vulnerability identifier (e.g., CVE).", Required = true };
var policyFileOption = new Option<string>("--policy-file") { Description = "Path to CvssPolicy JSON file.", Required = true };
var vectorOption = new Option<string>("--vector") { Description = "CVSS:4.0 vector string.", Required = true };
var jsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
score.Add(vulnOption);
score.Add(policyFileOption);
score.Add(vectorOption);
score.Add(jsonOption);
score.SetAction((parseResult, _) =>
{
var vuln = parseResult.GetValue(vulnOption) ?? string.Empty;
var policyPath = parseResult.GetValue(policyFileOption) ?? string.Empty;
var vector = parseResult.GetValue(vectorOption) ?? string.Empty;
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssScoreAsync(services, vuln, policyPath, vector, json, verbose, cancellationToken);
});
var show = new Command("show", "Fetch a CVSS receipt by ID.");
var receiptArg = new Argument<string>("receipt-id") { Description = "Receipt identifier." };
show.Add(receiptArg);
var showJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
show.Add(showJsonOption);
show.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var json = parseResult.GetValue(showJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssShowAsync(services, receiptId, json, verbose, cancellationToken);
});
var history = new Command("history", "Show receipt amendment history.");
history.Add(receiptArg);
var historyJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
history.Add(historyJsonOption);
history.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var json = parseResult.GetValue(historyJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssHistoryAsync(services, receiptId, json, verbose, cancellationToken);
});
var export = new Command("export", "Export a CVSS receipt to JSON (pdf not yet supported).");
export.Add(receiptArg);
var formatOption = new Option<string>("--format") { Description = "json|pdf (json default)." };
var outOption = new Option<string>("--out") { Description = "Output file path." };
export.Add(formatOption);
export.Add(outOption);
export.SetAction((parseResult, _) =>
{
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "json";
var output = parseResult.GetValue(outOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleCvssExportAsync(services, receiptId, format, output, verbose, cancellationToken);
});
cvss.Add(score);
cvss.Add(show);
cvss.Add(history);
cvss.Add(export);
return cvss;
}
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
@@ -3672,24 +3672,20 @@ internal static class CommandFactory
};
var expFormatOption = new Option<string>("--format")
{
Description = "Export format (ndjson, json).",
DefaultValueFactory = _ => "ndjson"
};
Description = "Export format (ndjson, json)."
}.SetDefaultValue("ndjson");
var expIncludeEvidenceOption = new Option<bool>("--include-evidence")
{
Description = "Include evidence data in export (default: true).",
DefaultValueFactory = _ => true
};
Description = "Include evidence data in export (default: true)."
}.SetDefaultValue(true);
var expIncludeLedgerOption = new Option<bool>("--include-ledger")
{
Description = "Include workflow ledger in export (default: true).",
DefaultValueFactory = _ => true
};
Description = "Include workflow ledger in export (default: true)."
}.SetDefaultValue(true);
var expSignedOption = new Option<bool>("--signed")
{
Description = "Request signed export bundle (default: true).",
DefaultValueFactory = _ => true
};
Description = "Request signed export bundle (default: true)."
}.SetDefaultValue(true);
var expOutputOption = new Option<string>("--output")
{
Description = "Output file path for the export bundle.",
@@ -10637,3 +10633,4 @@ internal static class CommandFactory
return airgap;
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.Cli.Commands;
internal sealed class CommandLineException : Exception
{
public CommandLineException(string message) : base(message)
{
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Services;
using StellaOps.Cli.Extensions;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.Cli.Commands;
@@ -32,30 +33,38 @@ internal static class SystemCommandBuilder
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var moduleOption = new Option<string?>(
"--module",
description: "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)");
var categoryOption = new Option<string?>(
"--category",
description: "Migration category (startup, release, seed, data)");
var dryRunOption = new Option<bool>("--dry-run", description: "List migrations without executing");
var connectionOption = new Option<string?>(
"--connection",
description: "PostgreSQL connection string override (otherwise uses STELLAOPS_POSTGRES_* env vars)");
var timeoutOption = new Option<int?>(
"--timeout",
description: "Command timeout in seconds for each migration (default 300).");
var forceOption = new Option<bool>(
"--force",
description: "Allow execution of release migrations without --dry-run.");
var moduleOption = new Option<string?>("--module")
{
Description = "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)"
};
var categoryOption = new Option<string?>("--category")
{
Description = "Migration category (startup, release, seed, data)"
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "List migrations without executing"
};
var connectionOption = new Option<string?>("--connection")
{
Description = "PostgreSQL connection string override (otherwise uses STELLAOPS_POSTGRES_* env vars)"
};
var timeoutOption = new Option<int?>("--timeout")
{
Description = "Command timeout in seconds for each migration (default 300)."
};
var forceOption = new Option<bool>("--force")
{
Description = "Allow execution of release migrations without --dry-run."
};
var run = new Command("migrations-run", "Run migrations for the selected module(s).");
run.AddOption(moduleOption);
run.AddOption(categoryOption);
run.AddOption(dryRunOption);
run.AddOption(connectionOption);
run.AddOption(timeoutOption);
run.AddOption(forceOption);
run.Add(moduleOption);
run.Add(categoryOption);
run.Add(dryRunOption);
run.Add(connectionOption);
run.Add(timeoutOption);
run.Add(forceOption);
run.SetAction(async parseResult =>
{
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();
@@ -91,8 +100,8 @@ internal static class SystemCommandBuilder
});
var status = new Command("migrations-status", "Show migration status for the selected module(s).");
status.AddOption(moduleOption);
status.AddOption(connectionOption);
status.Add(moduleOption);
status.Add(connectionOption);
status.SetAction(async parseResult =>
{
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();
@@ -117,8 +126,8 @@ internal static class SystemCommandBuilder
});
var verify = new Command("migrations-verify", "Verify migration checksums for the selected module(s).");
verify.AddOption(moduleOption);
verify.AddOption(connectionOption);
verify.Add(moduleOption);
verify.Add(connectionOption);
verify.SetAction(async parseResult =>
{
var modules = MigrationModuleRegistry.GetModules(parseResult.GetValue(moduleOption)).ToList();

View File

@@ -1,4 +1,5 @@
using System.CommandLine;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Output;
namespace StellaOps.Cli.Configuration;
@@ -54,23 +55,23 @@ public sealed class GlobalOptions
/// </summary>
public static IEnumerable<Option> CreateGlobalOptions()
{
yield return new Option<string?>("--profile", "-p")
yield return new Option<string?>("--profile", new[] { "-p" })
{
Description = "Profile name to use for this invocation"
};
yield return new Option<OutputFormat>("--output", "-o")
var outputOption = new Option<OutputFormat>("--output", new[] { "-o" })
{
Description = "Output format (table, json, yaml)",
DefaultValueFactory = _ => OutputFormat.Table
};
Description = "Output format (table, json, yaml)"
}.SetDefaultValue(OutputFormat.Table);
yield return outputOption;
yield return new Option<bool>("--verbose", "-v")
yield return new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose output"
};
yield return new Option<bool>("--quiet", "-q")
yield return new Option<bool>("--quiet", new[] { "-q" })
{
Description = "Quiet mode - suppress non-error output"
};

View File

@@ -1,32 +1,44 @@
using System;
using System.CommandLine;
namespace StellaOps.Cli.Extensions;
/// <summary>
/// Compatibility extensions for System.CommandLine 2.0.0-beta5+ API changes.
/// These restore the older extension method patterns that were used in earlier versions.
/// See: https://learn.microsoft.com/en-us/dotnet/standard/commandline/migration-guide-2.0.0-beta5
/// These restore the older helper methods the codebase relied on.
/// </summary>
public static class CommandLineExtensions
{
/// <summary>
/// Sets the default value for an option (compatibility shim for older API).
/// In beta5+, this maps to DefaultValueFactory.
/// Set a default value for an option.
/// </summary>
public static Option<T> SetDefaultValue<T>(this Option<T> option, T defaultValue)
{
ArgumentNullException.ThrowIfNull(option);
option.DefaultValueFactory = _ => defaultValue;
return option;
}
/// <summary>
/// Restricts the option to accept only the specified values (compatibility shim).
/// Works for both Option&lt;string&gt; and Option&lt;string?&gt;.
/// Restrict the option to a fixed set of values and add completions.
/// </summary>
public static Option<T> FromAmong<T>(this Option<T> option, params string[] allowedValues)
where T : class?
{
option.AcceptOnlyFromAmong(allowedValues);
ArgumentNullException.ThrowIfNull(option);
if (allowedValues is { Length: > 0 })
{
option.AcceptOnlyFromAmong(allowedValues);
}
return option;
}
/// <summary>
/// Mark the option as required (compatibility shim for the old Required property).
/// </summary>
public static Option<T> Required<T>(this Option<T> option, bool isRequired = true)
{
ArgumentNullException.ThrowIfNull(option);
option.Required = isRequired;
return option;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
using System.Net;
using System.Threading;
@@ -250,11 +251,11 @@ internal static class Program
};
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
var commandConfiguration = new CommandLineConfiguration(rootCommand);
int commandExit;
try
{
commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
var parseResult = rootCommand.Parse(args);
commandExit = await parseResult.InvokeAsync(cts.Token).ConfigureAwait(false);
}
catch (AirGapEgressBlockedException ex)
{

View File

@@ -9,6 +9,8 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Extensions;
using StellaOps.Policy.Scoring;
namespace StellaOps.Cli.Services;

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
using StellaOps.Policy.Scoring;
namespace StellaOps.Cli.Services;

View File

@@ -2,10 +2,10 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Models;
using StellaOps.AirGap.Importer.Repositories;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.Cli.Services.Models;
using ImportModels = StellaOps.AirGap.Importer.Models;
namespace StellaOps.Cli.Services;
@@ -238,10 +238,10 @@ public sealed class MirrorBundleImportService : IMirrorBundleImportService
try
{
var envelopeJson = await File.ReadAllTextAsync(dsseFile, cancellationToken).ConfigureAwait(false);
var envelope = DsseEnvelope.Parse(envelopeJson);
var envelope = ImportModels.DsseEnvelope.Parse(envelopeJson);
// Load trust roots if provided
TrustRootConfig trustRoots;
ImportModels.TrustRootConfig trustRoots;
if (!string.IsNullOrWhiteSpace(trustRootsPath) && File.Exists(trustRootsPath))
{
trustRoots = await LoadTrustRootsAsync(trustRootsPath, cancellationToken).ConfigureAwait(false);
@@ -287,7 +287,7 @@ public sealed class MirrorBundleImportService : IMirrorBundleImportService
}
}
private static async Task<TrustRootConfig> LoadTrustRootsAsync(string path, CancellationToken cancellationToken)
private static async Task<ImportModels.TrustRootConfig> LoadTrustRootsAsync(string path, CancellationToken cancellationToken)
{
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var doc = JsonDocument.Parse(json);
@@ -324,7 +324,7 @@ public sealed class MirrorBundleImportService : IMirrorBundleImportService
}
}
return new TrustRootConfig(path, fingerprints, algorithms, null, null, publicKeys);
return new ImportModels.TrustRootConfig(path, fingerprints, algorithms, null, null, publicKeys);
}
private async Task<List<string>> CopyArtifactsAsync(string bundleDir, string dataStorePath, MirrorBundle manifest, CancellationToken cancellationToken)

View File

@@ -14,6 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />

View File

@@ -1,43 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Policy.AuthSignals;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Maps advisory linksets into the shared Policy/Auth/Signals contract so policy enrichment tasks can start.
/// This is a minimal, fact-only projection (no weighting or merge logic).
/// </summary>
public static class PolicyAuthSignalFactory
{
public static PolicyAuthSignal ToPolicyAuthSignal(AdvisoryLinkset linkset)
{
ArgumentNullException.ThrowIfNull(linkset);
var firstPurl = linkset.Normalized?.Purls?.FirstOrDefault();
var evidence = new List<EvidenceRef>
{
new()
{
Kind = "linkset",
Uri = $"cas://linksets/{linkset.AdvisoryId}",
Digest = "sha256:pending" // real digest filled when CAS manifests are available
}
};
return new PolicyAuthSignal
{
Id = linkset.AdvisoryId,
Tenant = linkset.TenantId,
Subject = firstPurl ?? $"advisory:{linkset.Source}:{linkset.AdvisoryId}",
SignalType = "reachability",
Source = linkset.Source,
Confidence = linkset.Confidence,
Evidence = evidence,
Created = linkset.CreatedAt.UtcDateTime
};
}
}

View File

@@ -1,31 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using StellaOps.Policy.AuthSignals;
namespace StellaOps.Concelier.Core.Policy;
/// <summary>
/// Temporary bridge to consume the shared Policy/Auth/Signals contract package so downstream POLICY tasks can start.
/// </summary>
public static class AuthSignalsPackage
{
public static PolicyAuthSignal CreateSample() => new()
{
Id = "sample",
Tenant = "urn:tenant:sample",
Subject = "purl:pkg:maven/org.example/app@1.0.0",
SignalType = "reachability",
Source = "concelier",
Evidence = new List<EvidenceRef>
{
new()
{
Kind = "linkset",
Uri = "cas://linksets/sample",
Digest = "sha256:stub"
}
},
Created = DateTime.UtcNow
};
}

View File

@@ -12,7 +12,6 @@
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="StellaOps.Policy.AuthSignals" Version="0.1.0-alpha" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />

View File

@@ -0,0 +1,300 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Event types for policy profile notifications per docs/modules/policy/notifications.md.
/// </summary>
public static class PolicyProfileNotificationEventTypes
{
public const string ProfileCreated = "policy.profile.created";
public const string ProfileActivated = "policy.profile.activated";
public const string ProfileDeactivated = "policy.profile.deactivated";
public const string ThresholdChanged = "policy.profile.threshold_changed";
public const string OverrideAdded = "policy.profile.override_added";
public const string OverrideRemoved = "policy.profile.override_removed";
public const string SimulationReady = "policy.profile.simulation_ready";
}
/// <summary>
/// Notification event for policy profile lifecycle changes.
/// Follows the contract at docs/modules/policy/notifications.md.
/// </summary>
public sealed record PolicyProfileNotificationEvent
{
/// <summary>
/// Unique event identifier (UUIDv7 for time-ordered deduplication).
/// </summary>
[JsonPropertyName("event_id")]
public required string EventId { get; init; }
/// <summary>
/// Event type from PolicyProfileNotificationEventTypes.
/// </summary>
[JsonPropertyName("event_type")]
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp when the event was emitted.
/// </summary>
[JsonPropertyName("emitted_at")]
public required DateTimeOffset EmittedAt { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Profile identifier.
/// </summary>
[JsonPropertyName("profile_id")]
public required string ProfileId { get; init; }
/// <summary>
/// Profile version affected by this event.
/// </summary>
[JsonPropertyName("profile_version")]
public required string ProfileVersion { get; init; }
/// <summary>
/// Human-readable reason for the change.
/// </summary>
[JsonPropertyName("change_reason")]
public string? ChangeReason { get; init; }
/// <summary>
/// Actor who triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public NotificationActor? Actor { get; init; }
/// <summary>
/// Risk thresholds (populated for threshold_changed events).
/// </summary>
[JsonPropertyName("thresholds")]
public NotificationThresholds? Thresholds { get; init; }
/// <summary>
/// Effective scope for the profile.
/// </summary>
[JsonPropertyName("effective_scope")]
public NotificationEffectiveScope? EffectiveScope { get; init; }
/// <summary>
/// Hash of the profile bundle.
/// </summary>
[JsonPropertyName("hash")]
public NotificationHash? Hash { get; init; }
/// <summary>
/// Related URLs for profile, diff, and simulation.
/// </summary>
[JsonPropertyName("links")]
public NotificationLinks? Links { get; init; }
/// <summary>
/// Trace context for observability.
/// </summary>
[JsonPropertyName("trace")]
public NotificationTraceContext? Trace { get; init; }
/// <summary>
/// Override details (populated for override_added/removed events).
/// </summary>
[JsonPropertyName("override_details")]
public NotificationOverrideDetails? OverrideDetails { get; init; }
/// <summary>
/// Simulation details (populated for simulation_ready events).
/// </summary>
[JsonPropertyName("simulation_details")]
public NotificationSimulationDetails? SimulationDetails { get; init; }
}
/// <summary>
/// Actor information for notifications.
/// </summary>
public sealed record NotificationActor
{
/// <summary>
/// Actor type: "user" or "system".
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Actor identifier (email, service name, etc.).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
}
/// <summary>
/// Risk thresholds for notifications.
/// </summary>
public sealed record NotificationThresholds
{
[JsonPropertyName("info")]
public double? Info { get; init; }
[JsonPropertyName("low")]
public double? Low { get; init; }
[JsonPropertyName("medium")]
public double? Medium { get; init; }
[JsonPropertyName("high")]
public double? High { get; init; }
[JsonPropertyName("critical")]
public double? Critical { get; init; }
}
/// <summary>
/// Effective scope for profile application.
/// </summary>
public sealed record NotificationEffectiveScope
{
[JsonPropertyName("tenants")]
public IReadOnlyList<string>? Tenants { get; init; }
[JsonPropertyName("projects")]
public IReadOnlyList<string>? Projects { get; init; }
[JsonPropertyName("purl_patterns")]
public IReadOnlyList<string>? PurlPatterns { get; init; }
[JsonPropertyName("cpe_patterns")]
public IReadOnlyList<string>? CpePatterns { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Hash information for profile content.
/// </summary>
public sealed record NotificationHash
{
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
[JsonPropertyName("value")]
public required string Value { get; init; }
}
/// <summary>
/// Related URLs for the notification.
/// </summary>
public sealed record NotificationLinks
{
[JsonPropertyName("profile_url")]
public string? ProfileUrl { get; init; }
[JsonPropertyName("diff_url")]
public string? DiffUrl { get; init; }
[JsonPropertyName("simulation_url")]
public string? SimulationUrl { get; init; }
}
/// <summary>
/// Trace context for distributed tracing.
/// </summary>
public sealed record NotificationTraceContext
{
[JsonPropertyName("trace_id")]
public string? TraceId { get; init; }
[JsonPropertyName("span_id")]
public string? SpanId { get; init; }
}
/// <summary>
/// Override details for override_added/removed events.
/// </summary>
public sealed record NotificationOverrideDetails
{
[JsonPropertyName("override_id")]
public string? OverrideId { get; init; }
[JsonPropertyName("override_type")]
public string? OverrideType { get; init; }
[JsonPropertyName("target")]
public string? Target { get; init; }
[JsonPropertyName("action")]
public string? Action { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
}
/// <summary>
/// Simulation details for simulation_ready events.
/// </summary>
public sealed record NotificationSimulationDetails
{
[JsonPropertyName("simulation_id")]
public string? SimulationId { get; init; }
[JsonPropertyName("findings_count")]
public int? FindingsCount { get; init; }
[JsonPropertyName("high_impact_count")]
public int? HighImpactCount { get; init; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Request to publish a notification via webhook.
/// </summary>
public sealed record WebhookDeliveryRequest
{
/// <summary>
/// Target webhook URL.
/// </summary>
public required string Url { get; init; }
/// <summary>
/// The notification event to deliver.
/// </summary>
public required PolicyProfileNotificationEvent Event { get; init; }
/// <summary>
/// Shared secret for HMAC signature (X-Stella-Signature header).
/// </summary>
public string? SharedSecret { get; init; }
}
/// <summary>
/// Configuration options for policy profile notifications.
/// </summary>
public sealed class PolicyProfileNotificationOptions
{
/// <summary>
/// Topic name for notifications service delivery.
/// Default: notifications.policy.profiles
/// </summary>
public string TopicName { get; set; } = "notifications.policy.profiles";
/// <summary>
/// Base URL for generating profile links.
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// Whether to include trace context in notifications.
/// </summary>
public bool IncludeTraceContext { get; set; } = true;
/// <summary>
/// Whether notifications are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,396 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Interface for publishing policy profile notification events.
/// </summary>
public interface IPolicyProfileNotificationPublisher
{
/// <summary>
/// Publishes a notification event to the configured transport.
/// </summary>
Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default);
/// <summary>
/// Delivers a notification via webhook with HMAC signature.
/// </summary>
Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Logging-based notification publisher for policy profile events.
/// Logs notifications as structured events for downstream consumption.
/// </summary>
internal sealed class LoggingPolicyProfileNotificationPublisher : IPolicyProfileNotificationPublisher
{
private readonly ILogger<LoggingPolicyProfileNotificationPublisher> _logger;
private readonly PolicyProfileNotificationOptions _options;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public LoggingPolicyProfileNotificationPublisher(
ILogger<LoggingPolicyProfileNotificationPublisher> logger,
IOptions<PolicyProfileNotificationOptions> options,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (!_options.Enabled)
{
_logger.LogDebug(
"Policy profile notifications disabled; skipping event {EventId} type {EventType}",
notification.EventId,
notification.EventType);
return Task.CompletedTask;
}
var payload = JsonSerializer.Serialize(notification, JsonOptions);
_logger.LogInformation(
"PolicyProfileNotification topic={Topic} event_id={EventId} event_type={EventType} tenant={TenantId} profile={ProfileId}@{ProfileVersion} payload={Payload}",
_options.TopicName,
notification.EventId,
notification.EventType,
notification.TenantId,
notification.ProfileId,
notification.ProfileVersion,
payload);
return Task.CompletedTask;
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var payload = JsonSerializer.Serialize(request.Event, JsonOptions);
var signature = ComputeHmacSignature(payload, request.SharedSecret);
_logger.LogInformation(
"PolicyProfileWebhook url={Url} event_id={EventId} event_type={EventType} signature={Signature}",
request.Url,
request.Event.EventId,
request.Event.EventType,
signature ?? "(no secret)");
return Task.FromResult(true);
}
private static string? ComputeHmacSignature(string payload, string? sharedSecret)
{
if (string.IsNullOrEmpty(sharedSecret))
{
return null;
}
var keyBytes = Encoding.UTF8.GetBytes(sharedSecret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(payloadBytes);
return Convert.ToHexStringLower(hashBytes);
}
}
/// <summary>
/// Factory for creating policy profile notification events.
/// </summary>
public sealed class PolicyProfileNotificationFactory
{
private readonly TimeProvider _timeProvider;
private readonly PolicyProfileNotificationOptions _options;
public PolicyProfileNotificationFactory(
TimeProvider? timeProvider = null,
PolicyProfileNotificationOptions? options = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_options = options ?? new PolicyProfileNotificationOptions();
}
/// <summary>
/// Creates a profile created notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileCreatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileCreated,
tenantId,
profileId,
profileVersion,
"New profile draft created",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile activated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileActivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileActivated,
tenantId,
profileId,
profileVersion,
"Profile version activated",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile deactivated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileDeactivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileDeactivated,
tenantId,
profileId,
profileVersion,
reason ?? "Profile version deactivated",
actorId,
hash);
}
/// <summary>
/// Creates a threshold changed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateThresholdChangedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
NotificationThresholds thresholds,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ThresholdChanged,
tenantId,
profileId,
profileVersion,
reason ?? "Risk thresholds updated",
actorId,
hash,
thresholds: thresholds,
scope: scope);
}
/// <summary>
/// Creates an override added notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideAddedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideAdded,
tenantId,
profileId,
profileVersion,
$"Override added: {overrideDetails.OverrideType}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates an override removed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideRemovedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideRemoved,
tenantId,
profileId,
profileVersion,
$"Override removed: {overrideDetails.OverrideId}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates a simulation ready notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateSimulationReadyEvent(
string tenantId,
string profileId,
string profileVersion,
NotificationSimulationDetails simulationDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.SimulationReady,
tenantId,
profileId,
profileVersion,
"Simulation results available",
actorId: null,
hash,
simulationDetails: simulationDetails);
}
private PolicyProfileNotificationEvent CreateEvent(
string eventType,
string tenantId,
string profileId,
string profileVersion,
string changeReason,
string? actorId,
string? hash,
NotificationThresholds? thresholds = null,
NotificationEffectiveScope? scope = null,
NotificationOverrideDetails? overrideDetails = null,
NotificationSimulationDetails? simulationDetails = null)
{
var eventId = GenerateUuidV7();
var now = _timeProvider.GetUtcNow();
NotificationActor? actor = null;
if (!string.IsNullOrWhiteSpace(actorId))
{
actor = new NotificationActor
{
Type = actorId.Contains('@') ? "user" : "system",
Id = actorId
};
}
NotificationHash? hashInfo = null;
if (!string.IsNullOrWhiteSpace(hash))
{
hashInfo = new NotificationHash
{
Algorithm = "sha256",
Value = hash
};
}
NotificationLinks? links = null;
if (!string.IsNullOrWhiteSpace(_options.BaseUrl))
{
links = new NotificationLinks
{
ProfileUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}",
DiffUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}/diff",
SimulationUrl = simulationDetails?.SimulationId is not null
? $"{_options.BaseUrl}/api/risk/simulations/results/{simulationDetails.SimulationId}"
: null
};
}
NotificationTraceContext? trace = null;
if (_options.IncludeTraceContext)
{
var activity = Activity.Current;
if (activity is not null)
{
trace = new NotificationTraceContext
{
TraceId = activity.TraceId.ToString(),
SpanId = activity.SpanId.ToString()
};
}
}
return new PolicyProfileNotificationEvent
{
EventId = eventId,
EventType = eventType,
EmittedAt = now,
TenantId = tenantId,
ProfileId = profileId,
ProfileVersion = profileVersion,
ChangeReason = changeReason,
Actor = actor,
Thresholds = thresholds,
EffectiveScope = scope,
Hash = hashInfo,
Links = links,
Trace = trace,
OverrideDetails = overrideDetails,
SimulationDetails = simulationDetails
};
}
/// <summary>
/// Generates a UUIDv7 (time-ordered UUID) for event identification.
/// </summary>
private string GenerateUuidV7()
{
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var randomBytes = new byte[10];
RandomNumberGenerator.Fill(randomBytes);
var bytes = new byte[16];
// First 6 bytes: timestamp (48 bits)
bytes[0] = (byte)((timestamp >> 40) & 0xFF);
bytes[1] = (byte)((timestamp >> 32) & 0xFF);
bytes[2] = (byte)((timestamp >> 24) & 0xFF);
bytes[3] = (byte)((timestamp >> 16) & 0xFF);
bytes[4] = (byte)((timestamp >> 8) & 0xFF);
bytes[5] = (byte)(timestamp & 0xFF);
// Version 7 (4 bits) + random (12 bits)
bytes[6] = (byte)(0x70 | (randomBytes[0] & 0x0F));
bytes[7] = randomBytes[1];
// Variant (2 bits) + random (62 bits)
bytes[8] = (byte)(0x80 | (randomBytes[2] & 0x3F));
Array.Copy(randomBytes, 3, bytes, 9, 7);
return new Guid(bytes).ToString();
}
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Service for publishing policy profile lifecycle notifications.
/// Integrates with the RiskProfileLifecycleService to emit events.
/// </summary>
public sealed class PolicyProfileNotificationService
{
private readonly IPolicyProfileNotificationPublisher _publisher;
private readonly PolicyProfileNotificationFactory _factory;
private readonly PolicyProfileNotificationOptions _options;
private readonly ILogger<PolicyProfileNotificationService> _logger;
public PolicyProfileNotificationService(
IPolicyProfileNotificationPublisher publisher,
PolicyProfileNotificationFactory factory,
IOptions<PolicyProfileNotificationOptions> options,
ILogger<PolicyProfileNotificationService> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Notifies that a new profile version was created.
/// </summary>
public async Task NotifyProfileCreatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileCreatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile created notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was activated.
/// </summary>
public async Task NotifyProfileActivatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileActivatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile activated notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was deactivated (deprecated or archived).
/// </summary>
public async Task NotifyProfileDeactivatedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var notification = _factory.CreateProfileDeactivatedEvent(
tenantId,
profileId,
profileVersion,
actorId,
reason,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile deactivated notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that risk thresholds were changed.
/// </summary>
public async Task NotifyThresholdChangedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var thresholds = ExtractThresholds(profile);
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateThresholdChangedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
reason,
thresholds,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish threshold changed notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that an override was added to a profile.
/// </summary>
public async Task NotifyOverrideAddedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string overrideType,
string? target,
string? action,
string? justification,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId,
OverrideType = overrideType,
Target = target,
Action = action,
Justification = justification
};
var notification = _factory.CreateOverrideAddedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override added notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that an override was removed from a profile.
/// </summary>
public async Task NotifyOverrideRemovedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId
};
var notification = _factory.CreateOverrideRemovedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override removed notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that simulation results are ready for consumption.
/// </summary>
public async Task NotifySimulationReadyAsync(
string tenantId,
string profileId,
string profileVersion,
string simulationId,
int findingsCount,
int highImpactCount,
DateTimeOffset completedAt,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var simulationDetails = new NotificationSimulationDetails
{
SimulationId = simulationId,
FindingsCount = findingsCount,
HighImpactCount = highImpactCount,
CompletedAt = completedAt
};
var notification = _factory.CreateSimulationReadyEvent(
tenantId,
profileId,
profileVersion,
simulationDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish simulation ready notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies based on a lifecycle event from the RiskProfileLifecycleService.
/// </summary>
public async Task NotifyFromLifecycleEventAsync(
string tenantId,
RiskProfileLifecycleEvent lifecycleEvent,
RiskProfileModel? profile,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(lifecycleEvent);
if (!_options.Enabled)
{
return;
}
switch (lifecycleEvent.EventType)
{
case RiskProfileLifecycleEventType.Created:
if (profile is not null)
{
await NotifyProfileCreatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Activated:
if (profile is not null)
{
await NotifyProfileActivatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Deprecated:
case RiskProfileLifecycleEventType.Archived:
await NotifyProfileDeactivatedAsync(
tenantId,
lifecycleEvent.ProfileId,
lifecycleEvent.Version,
lifecycleEvent.Actor,
lifecycleEvent.Reason,
hash,
cancellationToken).ConfigureAwait(false);
break;
case RiskProfileLifecycleEventType.Restored:
// Restored profiles go back to deprecated status; no dedicated notification
_logger.LogDebug("Profile {ProfileId}@{Version} restored; no notification emitted",
lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
default:
_logger.LogDebug("Unhandled lifecycle event type {EventType} for {ProfileId}@{Version}",
lifecycleEvent.EventType, lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
}
}
private static NotificationEffectiveScope? ExtractEffectiveScope(RiskProfileModel profile)
{
// Extract scope information from profile metadata if available
var metadata = profile.Metadata;
if (metadata is null || metadata.Count == 0)
{
return null;
}
var scope = new NotificationEffectiveScope();
var hasAny = false;
if (metadata.TryGetValue("tenants", out var tenantsObj) && tenantsObj is IEnumerable<object> tenants)
{
scope = scope with { Tenants = tenants.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("projects", out var projectsObj) && projectsObj is IEnumerable<object> projects)
{
scope = scope with { Projects = projects.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("purl_patterns", out var purlObj) && purlObj is IEnumerable<object> purls)
{
scope = scope with { PurlPatterns = purls.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("cpe_patterns", out var cpeObj) && cpeObj is IEnumerable<object> cpes)
{
scope = scope with { CpePatterns = cpes.Select(c => c.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("tags", out var tagsObj) && tagsObj is IEnumerable<object> tags)
{
scope = scope with { Tags = tags.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
return hasAny ? scope : null;
}
private static NotificationThresholds ExtractThresholds(RiskProfileModel profile)
{
// Extract thresholds from profile overrides
var thresholds = new NotificationThresholds();
// Map severity overrides to threshold values
foreach (var severityOverride in profile.Overrides.Severity)
{
var targetSeverity = severityOverride.Set.ToString().ToLowerInvariant();
var threshold = ExtractThresholdValue(severityOverride.When);
thresholds = targetSeverity switch
{
"info" or "informational" => thresholds with { Info = threshold },
"low" => thresholds with { Low = threshold },
"medium" => thresholds with { Medium = threshold },
"high" => thresholds with { High = threshold },
"critical" => thresholds with { Critical = threshold },
_ => thresholds
};
}
return thresholds;
}
private static double? ExtractThresholdValue(Dictionary<string, object> conditions)
{
// Try to extract a numeric threshold from conditions
if (conditions.TryGetValue("score_gte", out var scoreGte) && scoreGte is double d1)
{
return d1;
}
if (conditions.TryGetValue("score_gt", out var scoreGt) && scoreGt is double d2)
{
return d2;
}
if (conditions.TryGetValue("threshold", out var threshold) && threshold is double d3)
{
return d3;
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Extension methods for registering policy profile notification services.
/// </summary>
public static class PolicyProfileNotificationServiceCollectionExtensions
{
/// <summary>
/// Adds policy profile notification services to the service collection.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(this IServiceCollection services)
{
services.TryAddSingleton<PolicyProfileNotificationFactory>();
services.TryAddSingleton<IPolicyProfileNotificationPublisher, LoggingPolicyProfileNotificationPublisher>();
services.TryAddSingleton<PolicyProfileNotificationService>();
return services;
}
/// <summary>
/// Adds policy profile notification services with configuration.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(
this IServiceCollection services,
Action<PolicyProfileNotificationOptions> configure)
{
services.Configure(configure);
return services.AddPolicyProfileNotifications();
}
}

View File

@@ -0,0 +1,251 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Middleware that extracts tenant context from request headers and validates tenant access.
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
/// </summary>
public sealed partial class TenantContextMiddleware
{
private readonly RequestDelegate _next;
private readonly TenantContextOptions _options;
private readonly ILogger<TenantContextMiddleware> _logger;
// Valid tenant/project ID pattern: alphanumeric, dashes, underscores
[GeneratedRegex("^[a-zA-Z0-9_-]+$", RegexOptions.Compiled)]
private static partial Regex ValidIdPattern();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public TenantContextMiddleware(
RequestDelegate next,
IOptions<TenantContextOptions> options,
ILogger<TenantContextMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options?.Value ?? new TenantContextOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context, ITenantContextAccessor tenantContextAccessor)
{
// Skip tenant validation for excluded paths
if (!_options.Enabled || IsExcludedPath(context.Request.Path))
{
await _next(context);
return;
}
var validationResult = ValidateTenantContext(context);
if (!validationResult.IsValid)
{
await WriteTenantErrorResponse(context, validationResult);
return;
}
// Set tenant context for the request
tenantContextAccessor.TenantContext = validationResult.Context;
using (_logger.BeginScope(new Dictionary<string, object?>
{
["tenant_id"] = validationResult.Context?.TenantId,
["project_id"] = validationResult.Context?.ProjectId
}))
{
await _next(context);
}
}
private bool IsExcludedPath(PathString path)
{
var pathValue = path.Value ?? string.Empty;
return _options.ExcludedPaths.Any(excluded =>
pathValue.StartsWith(excluded, StringComparison.OrdinalIgnoreCase));
}
private TenantValidationResult ValidateTenantContext(HttpContext context)
{
// Extract tenant header
var tenantHeader = context.Request.Headers[TenantContextConstants.TenantHeader].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
if (_options.RequireTenantHeader)
{
_logger.LogWarning(
"Missing required {Header} header for {Path}",
TenantContextConstants.TenantHeader,
context.Request.Path);
return TenantValidationResult.Failure(
TenantContextConstants.MissingTenantHeaderErrorCode,
$"The {TenantContextConstants.TenantHeader} header is required.");
}
// Use default tenant ID when header is not required
tenantHeader = TenantContextConstants.DefaultTenantId;
}
// Validate tenant ID format
if (!IsValidTenantId(tenantHeader))
{
_logger.LogWarning(
"Invalid tenant ID format: {TenantId}",
tenantHeader);
return TenantValidationResult.Failure(
TenantContextConstants.InvalidTenantIdErrorCode,
"Invalid tenant ID format. Must be alphanumeric with dashes and underscores.");
}
// Extract project header (optional)
var projectHeader = context.Request.Headers[TenantContextConstants.ProjectHeader].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(projectHeader) && !IsValidProjectId(projectHeader))
{
_logger.LogWarning(
"Invalid project ID format: {ProjectId}",
projectHeader);
return TenantValidationResult.Failure(
TenantContextConstants.InvalidTenantIdErrorCode,
"Invalid project ID format. Must be alphanumeric with dashes and underscores.");
}
// Determine write permission from scopes/claims
var canWrite = DetermineWritePermission(context);
// Extract actor ID
var actorId = ExtractActorId(context);
var tenantContext = TenantContext.ForTenant(
tenantHeader,
string.IsNullOrWhiteSpace(projectHeader) ? null : projectHeader,
canWrite,
actorId);
_logger.LogDebug(
"Tenant context established: tenant={TenantId}, project={ProjectId}, canWrite={CanWrite}, actor={ActorId}",
tenantContext.TenantId,
tenantContext.ProjectId ?? "(none)",
tenantContext.CanWrite,
tenantContext.ActorId ?? "(anonymous)");
return TenantValidationResult.Success(tenantContext);
}
private bool IsValidTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return false;
}
if (tenantId.Length > _options.MaxTenantIdLength)
{
return false;
}
return ValidIdPattern().IsMatch(tenantId);
}
private bool IsValidProjectId(string projectId)
{
if (string.IsNullOrWhiteSpace(projectId))
{
return true; // Project ID is optional
}
if (projectId.Length > _options.MaxProjectIdLength)
{
return false;
}
return ValidIdPattern().IsMatch(projectId);
}
private static bool DetermineWritePermission(HttpContext context)
{
var user = context.User;
if (user?.Identity?.IsAuthenticated != true)
{
return false;
}
// Check for write-related scopes
var hasWriteScope = user.Claims.Any(c =>
c.Type == "scope" &&
(c.Value.Contains("policy:write", StringComparison.OrdinalIgnoreCase) ||
c.Value.Contains("policy:edit", StringComparison.OrdinalIgnoreCase) ||
c.Value.Contains("policy:activate", StringComparison.OrdinalIgnoreCase)));
if (hasWriteScope)
{
return true;
}
// Check for admin role
var hasAdminRole = user.IsInRole("admin") ||
user.IsInRole("policy-admin") ||
user.HasClaim("role", "admin") ||
user.HasClaim("role", "policy-admin");
return hasAdminRole;
}
private static string? ExtractActorId(HttpContext context)
{
var user = context.User;
// Try standard claims
var actorId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value
?? user?.FindFirst("client_id")?.Value;
if (!string.IsNullOrWhiteSpace(actorId))
{
return actorId;
}
// Fall back to header
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) &&
!string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
private static async Task WriteTenantErrorResponse(HttpContext context, TenantValidationResult result)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
var errorResponse = new TenantErrorResponse(
result.ErrorCode ?? "UNKNOWN_ERROR",
result.ErrorMessage ?? "An unknown error occurred.",
context.Request.Path.Value ?? "/");
await context.Response.WriteAsync(
JsonSerializer.Serialize(errorResponse, JsonOptions));
}
}
/// <summary>
/// Error response for tenant validation failures.
/// </summary>
internal sealed record TenantErrorResponse(
string ErrorCode,
string Message,
string Path);

View File

@@ -0,0 +1,233 @@
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Constants for tenant context headers and GUCs (PostgreSQL Grand Unified Configuration).
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
/// </summary>
public static class TenantContextConstants
{
/// <summary>
/// HTTP header for tenant ID (mandatory).
/// </summary>
public const string TenantHeader = "X-Stella-Tenant";
/// <summary>
/// HTTP header for project ID (optional).
/// </summary>
public const string ProjectHeader = "X-Stella-Project";
/// <summary>
/// PostgreSQL GUC for tenant ID.
/// </summary>
public const string TenantGuc = "app.tenant_id";
/// <summary>
/// PostgreSQL GUC for project ID.
/// </summary>
public const string ProjectGuc = "app.project_id";
/// <summary>
/// PostgreSQL GUC for write permission.
/// </summary>
public const string CanWriteGuc = "app.can_write";
/// <summary>
/// Default tenant ID for legacy data migration.
/// </summary>
public const string DefaultTenantId = "public";
/// <summary>
/// Error code for missing tenant header (deterministic).
/// </summary>
public const string MissingTenantHeaderErrorCode = "POLICY_TENANT_HEADER_REQUIRED";
/// <summary>
/// Error code for invalid tenant ID format.
/// </summary>
public const string InvalidTenantIdErrorCode = "POLICY_TENANT_ID_INVALID";
/// <summary>
/// Error code for tenant access denied (403).
/// </summary>
public const string TenantAccessDeniedErrorCode = "POLICY_TENANT_ACCESS_DENIED";
}
/// <summary>
/// Represents the current tenant and project context for a request.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// The tenant ID for the current request.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The project ID for the current request (optional; null for tenant-wide operations).
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Whether the current request has write permission.
/// </summary>
public bool CanWrite { get; init; }
/// <summary>
/// The actor ID (user or system) making the request.
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Timestamp when the context was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Creates a tenant context for a specific tenant.
/// </summary>
public static TenantContext ForTenant(string tenantId, string? projectId = null, bool canWrite = false, string? actorId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return new TenantContext
{
TenantId = tenantId,
ProjectId = projectId,
CanWrite = canWrite,
ActorId = actorId,
CreatedAt = DateTimeOffset.UtcNow
};
}
}
/// <summary>
/// Options for tenant context middleware configuration.
/// </summary>
public sealed class TenantContextOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "PolicyEngine:Tenancy";
/// <summary>
/// Whether tenant validation is enabled (default: true).
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require tenant header on all endpoints (default: true).
/// When false, missing tenant header defaults to <see cref="TenantContextConstants.DefaultTenantId"/>.
/// </summary>
public bool RequireTenantHeader { get; set; } = true;
/// <summary>
/// Paths to exclude from tenant validation (e.g., health checks).
/// </summary>
public List<string> ExcludedPaths { get; set; } = new()
{
"/healthz",
"/readyz",
"/.well-known"
};
/// <summary>
/// Maximum length for tenant ID (default: 256).
/// </summary>
public int MaxTenantIdLength { get; set; } = 256;
/// <summary>
/// Maximum length for project ID (default: 256).
/// </summary>
public int MaxProjectIdLength { get; set; } = 256;
/// <summary>
/// Whether to allow multi-tenant queries (default: false).
/// When true, users with appropriate scopes can query across tenants.
/// </summary>
public bool AllowMultiTenantQueries { get; set; } = false;
}
/// <summary>
/// Interface for accessing the current tenant context.
/// </summary>
public interface ITenantContextAccessor
{
/// <summary>
/// Gets or sets the current tenant context.
/// </summary>
TenantContext? TenantContext { get; set; }
}
/// <summary>
/// Default implementation of <see cref="ITenantContextAccessor"/> using AsyncLocal.
/// </summary>
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContextHolder> _tenantContextCurrent = new();
/// <inheritdoc />
public TenantContext? TenantContext
{
get => _tenantContextCurrent.Value?.Context;
set
{
var holder = _tenantContextCurrent.Value;
if (holder is not null)
{
// Clear current context trapped in the AsyncLocals, as its done.
holder.Context = null;
}
if (value is not null)
{
// Use an object to hold the context in the AsyncLocal,
// so it can be cleared in all ExecutionContexts when its cleared.
_tenantContextCurrent.Value = new TenantContextHolder { Context = value };
}
}
}
private sealed class TenantContextHolder
{
public TenantContext? Context;
}
}
/// <summary>
/// Result of tenant context validation.
/// </summary>
public sealed record TenantValidationResult
{
/// <summary>
/// Whether the validation succeeded.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Error code if validation failed.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// Error message if validation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// The validated tenant context if successful.
/// </summary>
public TenantContext? Context { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static TenantValidationResult Success(TenantContext context) =>
new() { IsValid = true, Context = context };
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static TenantValidationResult Failure(string errorCode, string errorMessage) =>
new() { IsValid = false, ErrorCode = errorCode, ErrorMessage = errorMessage };
}

View File

@@ -0,0 +1,109 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Extension methods for registering tenant context services.
/// </summary>
public static class TenantContextServiceCollectionExtensions
{
/// <summary>
/// Adds tenant context services to the service collection.
/// </summary>
public static IServiceCollection AddTenantContext(this IServiceCollection services)
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
return services;
}
/// <summary>
/// Adds tenant context services with configuration.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
Action<TenantContextOptions> configure)
{
services.Configure(configure);
return services.AddTenantContext();
}
/// <summary>
/// Adds tenant context services with configuration from configuration section.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = TenantContextOptions.SectionName)
{
services.Configure<TenantContextOptions>(configuration.GetSection(sectionName));
return services.AddTenantContext();
}
}
/// <summary>
/// Extension methods for configuring tenant context middleware.
/// </summary>
public static class TenantContextApplicationBuilderExtensions
{
/// <summary>
/// Adds the tenant context middleware to the application pipeline.
/// This middleware extracts tenant/project headers and validates tenant access.
/// </summary>
public static IApplicationBuilder UseTenantContext(this IApplicationBuilder app)
{
return app.UseMiddleware<TenantContextMiddleware>();
}
}
/// <summary>
/// Extension methods for endpoint routing to apply tenant requirements.
/// </summary>
public static class TenantContextEndpointExtensions
{
/// <summary>
/// Requires tenant context for the endpoint group.
/// </summary>
public static RouteGroupBuilder RequireTenantContext(this RouteGroupBuilder group)
{
group.AddEndpointFilter<TenantContextEndpointFilter>();
return group;
}
/// <summary>
/// Adds a tenant context requirement filter to a route handler.
/// </summary>
public static RouteHandlerBuilder RequireTenantContext(this RouteHandlerBuilder builder)
{
builder.AddEndpointFilter<TenantContextEndpointFilter>();
return builder;
}
}
/// <summary>
/// Endpoint filter that validates tenant context is present.
/// </summary>
internal sealed class TenantContextEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var tenantAccessor = context.HttpContext.RequestServices
.GetService<ITenantContextAccessor>();
if (tenantAccessor?.TenantContext is null)
{
return Results.Problem(
title: "Tenant context required",
detail: $"The {TenantContextConstants.TenantHeader} header is required for this endpoint.",
statusCode: StatusCodes.Status400BadRequest,
extensions: new Dictionary<string, object?>
{
["error_code"] = TenantContextConstants.MissingTenantHeaderErrorCode
});
}
return await next(context);
}
}

View File

@@ -0,0 +1,481 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Notifications;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
using Xunit;
using MsOptions = Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tests.Notifications;
public sealed class PolicyProfileNotificationServiceTests
{
private readonly FakeNotificationPublisher _publisher;
private readonly PolicyProfileNotificationFactory _factory;
private readonly PolicyProfileNotificationOptions _options;
private readonly PolicyProfileNotificationService _service;
private readonly FakeTimeProvider _timeProvider;
public PolicyProfileNotificationServiceTests()
{
_publisher = new FakeNotificationPublisher();
_timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-07T12:00:00Z"));
_options = new PolicyProfileNotificationOptions
{
Enabled = true,
TopicName = "test.policy.profiles",
BaseUrl = "https://policy.test.local"
};
_factory = new PolicyProfileNotificationFactory(_timeProvider, _options);
_service = new PolicyProfileNotificationService(
_publisher,
_factory,
MsOptions.Options.Create(_options),
NullLogger<PolicyProfileNotificationService>.Instance);
}
[Fact]
public async Task NotifyProfileCreatedAsync_PublishesEvent()
{
// Arrange
var profile = CreateTestProfile();
// Act
await _service.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileCreated, evt.EventType);
Assert.Equal("tenant-123", evt.TenantId);
Assert.Equal("test-profile", evt.ProfileId);
Assert.Equal("1.0.0", evt.ProfileVersion);
Assert.NotNull(evt.Actor);
Assert.Equal("user", evt.Actor.Type);
Assert.Equal("alice@example.com", evt.Actor.Id);
Assert.NotNull(evt.Hash);
Assert.Equal("abc123hash", evt.Hash.Value);
}
[Fact]
public async Task NotifyProfileActivatedAsync_PublishesEvent()
{
// Arrange
var profile = CreateTestProfile();
// Act
await _service.NotifyProfileActivatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileActivated, evt.EventType);
Assert.Equal("tenant-123", evt.TenantId);
Assert.Equal("test-profile", evt.ProfileId);
}
[Fact]
public async Task NotifyProfileDeactivatedAsync_PublishesEvent()
{
// Act
await _service.NotifyProfileDeactivatedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"Deprecated in favor of v2.0.0",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileDeactivated, evt.EventType);
Assert.Equal("Deprecated in favor of v2.0.0", evt.ChangeReason);
}
[Fact]
public async Task NotifyThresholdChangedAsync_PublishesEventWithThresholds()
{
// Arrange
var profile = CreateTestProfileWithThresholds();
// Act
await _service.NotifyThresholdChangedAsync(
"tenant-123",
profile,
"alice@example.com",
"Increased high/critical thresholds",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ThresholdChanged, evt.EventType);
Assert.NotNull(evt.Thresholds);
}
[Fact]
public async Task NotifyOverrideAddedAsync_PublishesEventWithDetails()
{
// Act
await _service.NotifyOverrideAddedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"override-001",
"severity",
"CVE-2024-1234",
"suppress",
"False positive confirmed by security team",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.OverrideAdded, evt.EventType);
Assert.NotNull(evt.OverrideDetails);
Assert.Equal("override-001", evt.OverrideDetails.OverrideId);
Assert.Equal("severity", evt.OverrideDetails.OverrideType);
Assert.Equal("CVE-2024-1234", evt.OverrideDetails.Target);
Assert.Equal("False positive confirmed by security team", evt.OverrideDetails.Justification);
}
[Fact]
public async Task NotifyOverrideRemovedAsync_PublishesEvent()
{
// Act
await _service.NotifyOverrideRemovedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"override-001",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.OverrideRemoved, evt.EventType);
Assert.NotNull(evt.OverrideDetails);
Assert.Equal("override-001", evt.OverrideDetails.OverrideId);
}
[Fact]
public async Task NotifySimulationReadyAsync_PublishesEventWithDetails()
{
// Act
await _service.NotifySimulationReadyAsync(
"tenant-123",
"test-profile",
"1.0.0",
"sim-001",
findingsCount: 42,
highImpactCount: 5,
completedAt: _timeProvider.GetUtcNow(),
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.SimulationReady, evt.EventType);
Assert.NotNull(evt.SimulationDetails);
Assert.Equal("sim-001", evt.SimulationDetails.SimulationId);
Assert.Equal(42, evt.SimulationDetails.FindingsCount);
Assert.Equal(5, evt.SimulationDetails.HighImpactCount);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Created_PublishesNotification()
{
// Arrange
var profile = CreateTestProfile();
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-001",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Created,
OldStatus: null,
NewStatus: RiskProfileLifecycleStatus.Draft,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: null);
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileCreated, evt.EventType);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Activated_PublishesNotification()
{
// Arrange
var profile = CreateTestProfile();
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-002",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Activated,
OldStatus: RiskProfileLifecycleStatus.Draft,
NewStatus: RiskProfileLifecycleStatus.Active,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: null);
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileActivated, evt.EventType);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Deprecated_PublishesDeactivatedNotification()
{
// Arrange
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-003",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Deprecated,
OldStatus: RiskProfileLifecycleStatus.Active,
NewStatus: RiskProfileLifecycleStatus.Deprecated,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: "Superseded by v2.0.0");
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile: null,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileDeactivated, evt.EventType);
Assert.Equal("Superseded by v2.0.0", evt.ChangeReason);
}
[Fact]
public async Task NotifyProfileCreatedAsync_WhenDisabled_DoesNotPublish()
{
// Arrange
var disabledOptions = new PolicyProfileNotificationOptions { Enabled = false };
var disabledService = new PolicyProfileNotificationService(
_publisher,
_factory,
MsOptions.Options.Create(disabledOptions),
NullLogger<PolicyProfileNotificationService>.Instance);
var profile = CreateTestProfile();
// Act
await disabledService.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Empty(_publisher.PublishedEvents);
}
[Fact]
public async Task NotifyProfileCreatedAsync_WhenPublisherThrows_LogsWarningAndContinues()
{
// Arrange
var throwingPublisher = new ThrowingNotificationPublisher();
var serviceWithThrowingPublisher = new PolicyProfileNotificationService(
throwingPublisher,
_factory,
MsOptions.Options.Create(_options),
NullLogger<PolicyProfileNotificationService>.Instance);
var profile = CreateTestProfile();
// Act (should not throw)
await serviceWithThrowingPublisher.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert - no exception thrown
Assert.True(true);
}
[Fact]
public void EventTypes_AreCorrect()
{
Assert.Equal("policy.profile.created", PolicyProfileNotificationEventTypes.ProfileCreated);
Assert.Equal("policy.profile.activated", PolicyProfileNotificationEventTypes.ProfileActivated);
Assert.Equal("policy.profile.deactivated", PolicyProfileNotificationEventTypes.ProfileDeactivated);
Assert.Equal("policy.profile.threshold_changed", PolicyProfileNotificationEventTypes.ThresholdChanged);
Assert.Equal("policy.profile.override_added", PolicyProfileNotificationEventTypes.OverrideAdded);
Assert.Equal("policy.profile.override_removed", PolicyProfileNotificationEventTypes.OverrideRemoved);
Assert.Equal("policy.profile.simulation_ready", PolicyProfileNotificationEventTypes.SimulationReady);
}
[Fact]
public void Factory_GeneratesUniqueEventIds()
{
// Arrange & Act
var event1 = _factory.CreateProfileCreatedEvent("t1", "p1", "1.0", null, null);
var event2 = _factory.CreateProfileCreatedEvent("t1", "p1", "1.0", null, null);
// Assert
Assert.NotEqual(event1.EventId, event2.EventId);
}
[Fact]
public void Factory_IncludesBaseUrlInLinks()
{
// Arrange & Act
var notification = _factory.CreateProfileActivatedEvent(
"tenant-123",
"my-profile",
"2.0.0",
"alice@example.com",
"hash123",
scope: null);
// Assert
Assert.NotNull(notification.Links);
Assert.Equal("https://policy.test.local/api/risk/profiles/my-profile", notification.Links.ProfileUrl);
}
[Fact]
public void Factory_DetectsUserActorType()
{
// Act
var userEvent = _factory.CreateProfileCreatedEvent("t", "p", "1.0", "alice@example.com", null);
var systemEvent = _factory.CreateProfileCreatedEvent("t", "p", "1.0", "policy-service", null);
// Assert
Assert.Equal("user", userEvent.Actor?.Type);
Assert.Equal("system", systemEvent.Actor?.Type);
}
private static RiskProfileModel CreateTestProfile()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile for unit tests",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "vuln", Type = RiskSignalType.Numeric, Path = "$.cvss.score" }
},
Weights = new Dictionary<string, double> { ["cvss"] = 1.0 },
Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>(),
Decisions = new List<DecisionOverride>()
}
};
}
private static RiskProfileModel CreateTestProfileWithThresholds()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile with thresholds",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "vuln", Type = RiskSignalType.Numeric, Path = "$.cvss.score" }
},
Weights = new Dictionary<string, double> { ["cvss"] = 1.0 },
Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>
{
new() { Set = RiskSeverity.Critical, When = new Dictionary<string, object> { ["score_gte"] = 0.9 } },
new() { Set = RiskSeverity.High, When = new Dictionary<string, object> { ["score_gte"] = 0.75 } },
new() { Set = RiskSeverity.Medium, When = new Dictionary<string, object> { ["score_gte"] = 0.5 } }
},
Decisions = new List<DecisionOverride>()
}
};
}
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class FakeNotificationPublisher : IPolicyProfileNotificationPublisher
{
public List<PolicyProfileNotificationEvent> PublishedEvents { get; } = new();
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
PublishedEvents.Add(notification);
return Task.CompletedTask;
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(true);
}
}
private sealed class ThrowingNotificationPublisher : IPolicyProfileNotificationPublisher
{
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("Publisher failed");
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("Publisher failed");
}
}
}

View File

@@ -0,0 +1,526 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Tenancy;
using Xunit;
using MsOptions = Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tests.Tenancy;
public sealed class TenantContextTests
{
[Fact]
public void TenantContext_ForTenant_CreatesTenantContext()
{
// Arrange & Act
var context = TenantContext.ForTenant("tenant-123", "project-456", canWrite: true, actorId: "user@example.com");
// Assert
Assert.Equal("tenant-123", context.TenantId);
Assert.Equal("project-456", context.ProjectId);
Assert.True(context.CanWrite);
Assert.Equal("user@example.com", context.ActorId);
}
[Fact]
public void TenantContext_ForTenant_WithoutOptionalFields_CreatesTenantContext()
{
// Act
var context = TenantContext.ForTenant("tenant-123");
// Assert
Assert.Equal("tenant-123", context.TenantId);
Assert.Null(context.ProjectId);
Assert.False(context.CanWrite);
Assert.Null(context.ActorId);
}
[Fact]
public void TenantContext_ForTenant_ThrowsOnNullTenantId()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => TenantContext.ForTenant(null!));
}
[Fact]
public void TenantContext_ForTenant_ThrowsOnEmptyTenantId()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => TenantContext.ForTenant(string.Empty));
}
[Fact]
public void TenantContext_ForTenant_ThrowsOnWhitespaceTenantId()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => TenantContext.ForTenant(" "));
}
}
public sealed class TenantContextAccessorTests
{
[Fact]
public void TenantContextAccessor_GetSet_WorksCorrectly()
{
// Arrange
var accessor = new TenantContextAccessor();
var context = TenantContext.ForTenant("tenant-123");
// Act
accessor.TenantContext = context;
// Assert
Assert.NotNull(accessor.TenantContext);
Assert.Equal("tenant-123", accessor.TenantContext.TenantId);
}
[Fact]
public void TenantContextAccessor_InitialValue_IsNull()
{
// Arrange & Act
var accessor = new TenantContextAccessor();
// Assert
Assert.Null(accessor.TenantContext);
}
[Fact]
public void TenantContextAccessor_SetNull_ClearsContext()
{
// Arrange
var accessor = new TenantContextAccessor();
accessor.TenantContext = TenantContext.ForTenant("tenant-123");
// Act
accessor.TenantContext = null;
// Assert
Assert.Null(accessor.TenantContext);
}
}
public sealed class TenantValidationResultTests
{
[Fact]
public void TenantValidationResult_Success_CreatesValidResult()
{
// Arrange
var context = TenantContext.ForTenant("tenant-123");
// Act
var result = TenantValidationResult.Success(context);
// Assert
Assert.True(result.IsValid);
Assert.Null(result.ErrorCode);
Assert.Null(result.ErrorMessage);
Assert.NotNull(result.Context);
Assert.Equal("tenant-123", result.Context.TenantId);
}
[Fact]
public void TenantValidationResult_Failure_CreatesInvalidResult()
{
// Act
var result = TenantValidationResult.Failure("ERR_CODE", "Error message");
// Assert
Assert.False(result.IsValid);
Assert.Equal("ERR_CODE", result.ErrorCode);
Assert.Equal("Error message", result.ErrorMessage);
Assert.Null(result.Context);
}
}
public sealed class TenantContextMiddlewareTests
{
private readonly NullLogger<TenantContextMiddleware> _logger;
private readonly TenantContextAccessor _tenantAccessor;
private readonly TenantContextOptions _options;
public TenantContextMiddlewareTests()
{
_logger = NullLogger<TenantContextMiddleware>.Instance;
_tenantAccessor = new TenantContextAccessor();
_options = new TenantContextOptions
{
Enabled = true,
RequireTenantHeader = true,
ExcludedPaths = new List<string> { "/healthz", "/readyz" }
};
}
[Fact]
public async Task Middleware_WithValidTenantHeader_SetsTenantContext()
{
// Arrange
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.True(nextCalled);
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
}
[Fact]
public async Task Middleware_WithTenantAndProjectHeaders_SetsBothInContext()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123", "project-456");
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
Assert.Equal("project-456", _tenantAccessor.TenantContext.ProjectId);
}
[Fact]
public async Task Middleware_MissingTenantHeader_Returns400WithErrorCode()
{
// Arrange
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId: null);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
Assert.Null(_tenantAccessor.TenantContext);
}
[Fact]
public async Task Middleware_MissingTenantHeaderNotRequired_UsesDefaultTenant()
{
// Arrange
var optionsNotRequired = new TenantContextOptions
{
Enabled = true,
RequireTenantHeader = false
};
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(optionsNotRequired),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId: null);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(TenantContextConstants.DefaultTenantId, _tenantAccessor.TenantContext.TenantId);
}
[Fact]
public async Task Middleware_ExcludedPath_SkipsValidation()
{
// Arrange
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/healthz", tenantId: null);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.True(nextCalled);
Assert.Null(_tenantAccessor.TenantContext); // Not set for excluded paths
}
[Fact]
public async Task Middleware_Disabled_SkipsValidation()
{
// Arrange
var disabledOptions = new TenantContextOptions { Enabled = false };
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(disabledOptions),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId: null);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.True(nextCalled);
}
[Theory]
[InlineData("tenant-123")]
[InlineData("TENANT_456")]
[InlineData("tenant_with-mixed-123")]
public async Task Middleware_ValidTenantIdFormat_Passes(string tenantId)
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(tenantId, _tenantAccessor.TenantContext.TenantId);
}
[Theory]
[InlineData("tenant 123")] // spaces
[InlineData("tenant@123")] // special char
[InlineData("tenant/123")] // slash
[InlineData("tenant.123")] // dot
public async Task Middleware_InvalidTenantIdFormat_Returns400(string tenantId)
{
// Arrange
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Fact]
public async Task Middleware_TenantIdTooLong_Returns400()
{
// Arrange
var longTenantId = new string('a', 300); // exceeds default 256 limit
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", longTenantId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Theory]
[InlineData("project-123")]
[InlineData("PROJECT_456")]
[InlineData("proj_with-mixed-123")]
public async Task Middleware_ValidProjectIdFormat_Passes(string projectId)
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123", projectId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(projectId, _tenantAccessor.TenantContext.ProjectId);
}
[Fact]
public async Task Middleware_WithWriteScope_SetsCanWriteTrue()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
var claims = new[]
{
new Claim("sub", "user@example.com"),
new Claim("scope", "policy:write")
};
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.True(_tenantAccessor.TenantContext.CanWrite);
}
[Fact]
public async Task Middleware_WithoutWriteScope_SetsCanWriteFalse()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
var claims = new[]
{
new Claim("sub", "user@example.com"),
new Claim("scope", "policy:read")
};
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.False(_tenantAccessor.TenantContext.CanWrite);
}
[Fact]
public async Task Middleware_ExtractsActorIdFromSubClaim()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
var claims = new[] { new Claim("sub", "user-id-123") };
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("user-id-123", _tenantAccessor.TenantContext.ActorId);
}
[Fact]
public async Task Middleware_ExtractsActorIdFromHeader()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
context.Request.Headers["X-StellaOps-Actor"] = "service-account-123";
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("service-account-123", _tenantAccessor.TenantContext.ActorId);
}
private static DefaultHttpContext CreateHttpContext(
string path,
string? tenantId,
string? projectId = null)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
if (!string.IsNullOrEmpty(tenantId))
{
context.Request.Headers[TenantContextConstants.TenantHeader] = tenantId;
}
if (!string.IsNullOrEmpty(projectId))
{
context.Request.Headers[TenantContextConstants.ProjectHeader] = projectId;
}
// Set up response body stream to capture output
context.Response.Body = new MemoryStream();
return context;
}
}
public sealed class TenantContextConstantsTests
{
[Fact]
public void Constants_HaveExpectedValues()
{
Assert.Equal("X-Stella-Tenant", TenantContextConstants.TenantHeader);
Assert.Equal("X-Stella-Project", TenantContextConstants.ProjectHeader);
Assert.Equal("app.tenant_id", TenantContextConstants.TenantGuc);
Assert.Equal("app.project_id", TenantContextConstants.ProjectGuc);
Assert.Equal("app.can_write", TenantContextConstants.CanWriteGuc);
Assert.Equal("public", TenantContextConstants.DefaultTenantId);
Assert.Equal("POLICY_TENANT_HEADER_REQUIRED", TenantContextConstants.MissingTenantHeaderErrorCode);
Assert.Equal("POLICY_TENANT_ID_INVALID", TenantContextConstants.InvalidTenantIdErrorCode);
Assert.Equal("POLICY_TENANT_ACCESS_DENIED", TenantContextConstants.TenantAccessDeniedErrorCode);
}
}
public sealed class TenantContextOptionsTests
{
[Fact]
public void Options_HaveCorrectDefaults()
{
// Arrange & Act
var options = new TenantContextOptions();
// Assert
Assert.True(options.Enabled);
Assert.True(options.RequireTenantHeader);
Assert.Contains("/healthz", options.ExcludedPaths);
Assert.Contains("/readyz", options.ExcludedPaths);
Assert.Contains("/.well-known", options.ExcludedPaths);
Assert.Equal(256, options.MaxTenantIdLength);
Assert.Equal(256, options.MaxProjectIdLength);
Assert.False(options.AllowMultiTenantQueries);
}
[Fact]
public void SectionName_IsCorrect()
{
Assert.Equal("PolicyEngine:Tenancy", TenantContextOptions.SectionName);
}
}

View File

@@ -9,6 +9,10 @@ internal static class OrchestratorEventKinds
{
public const string ScannerReportReady = "scanner.event.report.ready";
public const string ScannerScanCompleted = "scanner.event.scan.completed";
public const string ScannerScanStarted = "scanner.event.scan.started";
public const string ScannerScanFailed = "scanner.event.scan.failed";
public const string ScannerSbomGenerated = "scanner.event.sbom.generated";
public const string ScannerVulnerabilityDetected = "scanner.event.vulnerability.detected";
}
internal sealed record OrchestratorEvent
@@ -74,6 +78,39 @@ internal sealed record OrchestratorEvent
[JsonPropertyOrder(13)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Attributes { get; init; }
[JsonPropertyName("notifier")]
[JsonPropertyOrder(14)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public NotifierIngestionMetadata? Notifier { get; init; }
}
/// <summary>
/// Metadata for Notifier service ingestion per orchestrator-envelope.schema.json.
/// </summary>
internal sealed record NotifierIngestionMetadata
{
[JsonPropertyName("severityThresholdMet")]
[JsonPropertyOrder(0)]
public bool SeverityThresholdMet { get; init; }
[JsonPropertyName("notificationChannels")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? NotificationChannels { get; init; }
[JsonPropertyName("digestEligible")]
[JsonPropertyOrder(2)]
public bool DigestEligible { get; init; } = true;
[JsonPropertyName("immediateDispatch")]
[JsonPropertyOrder(3)]
public bool ImmediateDispatch { get; init; }
[JsonPropertyName("priority")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Priority { get; init; }
}
internal sealed record OrchestratorEventScope
@@ -226,40 +263,40 @@ internal sealed record ReportDeltaPayload
public IReadOnlyList<string>? Kev { get; init; }
}
internal sealed record ReportLinksPayload
{
[JsonPropertyName("report")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Report { get; init; }
[JsonPropertyName("policy")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Policy { get; init; }
[JsonPropertyName("attestation")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Attestation { get; init; }
}
internal sealed record LinkTarget(
[property: JsonPropertyName("ui"), JsonPropertyOrder(0), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Ui,
[property: JsonPropertyName("api"), JsonPropertyOrder(1), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Api)
{
public static LinkTarget? Create(string? ui, string? api)
{
if (string.IsNullOrWhiteSpace(ui) && string.IsNullOrWhiteSpace(api))
{
return null;
}
return new LinkTarget(
string.IsNullOrWhiteSpace(ui) ? null : ui,
string.IsNullOrWhiteSpace(api) ? null : api);
}
}
internal sealed record ReportLinksPayload
{
[JsonPropertyName("report")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Report { get; init; }
[JsonPropertyName("policy")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Policy { get; init; }
[JsonPropertyName("attestation")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkTarget? Attestation { get; init; }
}
internal sealed record LinkTarget(
[property: JsonPropertyName("ui"), JsonPropertyOrder(0), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Ui,
[property: JsonPropertyName("api"), JsonPropertyOrder(1), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Api)
{
public static LinkTarget? Create(string? ui, string? api)
{
if (string.IsNullOrWhiteSpace(ui) && string.IsNullOrWhiteSpace(api))
{
return null;
}
return new LinkTarget(
string.IsNullOrWhiteSpace(ui) ? null : ui,
string.IsNullOrWhiteSpace(api) ? null : api);
}
}
internal sealed record FindingSummaryPayload
{
@@ -287,3 +324,274 @@ internal sealed record FindingSummaryPayload
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reachability { get; init; }
}
/// <summary>
/// Payload for scanner.event.scan.started events.
/// </summary>
internal sealed record ScanStartedEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("scanId")]
[JsonPropertyOrder(0)]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("jobId")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? JobId { get; init; }
[JsonPropertyName("target")]
[JsonPropertyOrder(2)]
public ScanTargetPayload Target { get; init; } = new();
[JsonPropertyName("startedAt")]
[JsonPropertyOrder(3)]
public DateTimeOffset StartedAt { get; init; }
[JsonPropertyName("status")]
[JsonPropertyOrder(4)]
public string Status { get; init; } = "started";
}
/// <summary>
/// Payload for scanner.event.scan.failed events.
/// </summary>
internal sealed record ScanFailedEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("scanId")]
[JsonPropertyOrder(0)]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("jobId")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? JobId { get; init; }
[JsonPropertyName("target")]
[JsonPropertyOrder(2)]
public ScanTargetPayload Target { get; init; } = new();
[JsonPropertyName("startedAt")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("failedAt")]
[JsonPropertyOrder(4)]
public DateTimeOffset FailedAt { get; init; }
[JsonPropertyName("durationMs")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? DurationMs { get; init; }
[JsonPropertyName("status")]
[JsonPropertyOrder(6)]
public string Status { get; init; } = "failed";
[JsonPropertyName("error")]
[JsonPropertyOrder(7)]
public ScanErrorPayload Error { get; init; } = new();
}
/// <summary>
/// Payload for scanner.event.sbom.generated events.
/// </summary>
internal sealed record SbomGeneratedEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("scanId")]
[JsonPropertyOrder(0)]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("sbomId")]
[JsonPropertyOrder(1)]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("target")]
[JsonPropertyOrder(2)]
public ScanTargetPayload Target { get; init; } = new();
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(3)]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("format")]
[JsonPropertyOrder(4)]
public string Format { get; init; } = "cyclonedx";
[JsonPropertyName("specVersion")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SpecVersion { get; init; }
[JsonPropertyName("componentCount")]
[JsonPropertyOrder(6)]
public int ComponentCount { get; init; }
[JsonPropertyName("sbomRef")]
[JsonPropertyOrder(7)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SbomRef { get; init; }
[JsonPropertyName("digest")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Digest { get; init; }
}
/// <summary>
/// Payload for scanner.event.vulnerability.detected events.
/// </summary>
internal sealed record VulnerabilityDetectedEventPayload : OrchestratorEventPayload
{
[JsonPropertyName("scanId")]
[JsonPropertyOrder(0)]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("vulnerability")]
[JsonPropertyOrder(1)]
public VulnerabilityInfoPayload Vulnerability { get; init; } = new();
[JsonPropertyName("affectedComponent")]
[JsonPropertyOrder(2)]
public ComponentInfoPayload AffectedComponent { get; init; } = new();
[JsonPropertyName("reachability")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reachability { get; init; }
[JsonPropertyName("detectedAt")]
[JsonPropertyOrder(4)]
public DateTimeOffset DetectedAt { get; init; }
}
/// <summary>
/// Target being scanned.
/// </summary>
internal sealed record ScanTargetPayload
{
[JsonPropertyName("type")]
[JsonPropertyOrder(0)]
public string Type { get; init; } = "container_image";
[JsonPropertyName("identifier")]
[JsonPropertyOrder(1)]
public string Identifier { get; init; } = string.Empty;
[JsonPropertyName("digest")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Digest { get; init; }
[JsonPropertyName("tag")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tag { get; init; }
[JsonPropertyName("platform")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Platform { get; init; }
}
/// <summary>
/// Error information for failed scans.
/// </summary>
internal sealed record ScanErrorPayload
{
[JsonPropertyName("code")]
[JsonPropertyOrder(0)]
public string Code { get; init; } = "SCAN_FAILED";
[JsonPropertyName("message")]
[JsonPropertyOrder(1)]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("details")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary<string, string>? Details { get; init; }
[JsonPropertyName("recoverable")]
[JsonPropertyOrder(3)]
public bool Recoverable { get; init; }
}
/// <summary>
/// Vulnerability information.
/// </summary>
internal sealed record VulnerabilityInfoPayload
{
[JsonPropertyName("id")]
[JsonPropertyOrder(0)]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("severity")]
[JsonPropertyOrder(1)]
public string Severity { get; init; } = "unknown";
[JsonPropertyName("cvssScore")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? CvssScore { get; init; }
[JsonPropertyName("cvssVector")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CvssVector { get; init; }
[JsonPropertyName("title")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; init; }
[JsonPropertyName("fixAvailable")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? FixAvailable { get; init; }
[JsonPropertyName("fixedVersion")]
[JsonPropertyOrder(6)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FixedVersion { get; init; }
[JsonPropertyName("kevListed")]
[JsonPropertyOrder(7)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? KevListed { get; init; }
[JsonPropertyName("epssScore")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? EpssScore { get; init; }
}
/// <summary>
/// Component information.
/// </summary>
internal sealed record ComponentInfoPayload
{
[JsonPropertyName("purl")]
[JsonPropertyOrder(0)]
public string Purl { get; init; } = string.Empty;
[JsonPropertyName("name")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; init; }
[JsonPropertyName("version")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Version { get; init; }
[JsonPropertyName("ecosystem")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Ecosystem { get; init; }
[JsonPropertyName("location")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Location { get; init; }
}

View File

@@ -98,17 +98,17 @@ internal static class OrchestratorEventSerializer
"newHigh",
"kev"
},
[typeof(ReportLinksPayload)] = new[]
{
"report",
"policy",
"attestation"
},
[typeof(LinkTarget)] = new[]
{
"ui",
"api"
},
[typeof(ReportLinksPayload)] = new[]
{
"report",
"policy",
"attestation"
},
[typeof(LinkTarget)] = new[]
{
"ui",
"api"
},
[typeof(FindingSummaryPayload)] = new[]
{
"id",
@@ -162,12 +162,12 @@ internal static class OrchestratorEventSerializer
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options)
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetOrder(type, property.Name))
@@ -178,49 +178,53 @@ internal static class OrchestratorEventSerializer
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
ConfigurePolymorphism(info);
return info;
}
private static int GetOrder(Type type, string propertyName)
{
}
}
ConfigurePolymorphism(info);
return info;
}
private static int GetOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
{
return index;
}
if (type.BaseType is not null)
{
return GetOrder(type.BaseType, propertyName);
}
return int.MaxValue;
}
private static void ConfigurePolymorphism(JsonTypeInfo info)
{
if (info.Type != typeof(OrchestratorEventPayload))
{
return;
}
info.PolymorphismOptions ??= new JsonPolymorphismOptions();
AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload));
}
private static void AddDerivedType(JsonPolymorphismOptions options, Type derivedType)
{
if (options.DerivedTypes.Any(d => d.DerivedType == derivedType))
{
return;
}
options.DerivedTypes.Add(new JsonDerivedType(derivedType));
}
}
}
if (type.BaseType is not null)
{
return GetOrder(type.BaseType, propertyName);
}
return int.MaxValue;
}
private static void ConfigurePolymorphism(JsonTypeInfo info)
{
if (info.Type != typeof(OrchestratorEventPayload))
{
return;
}
info.PolymorphismOptions ??= new JsonPolymorphismOptions();
AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanStartedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(ScanFailedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(SbomGeneratedEventPayload));
AddDerivedType(info.PolymorphismOptions, typeof(VulnerabilityDetectedEventPayload));
}
private static void AddDerivedType(JsonPolymorphismOptions options, Type derivedType)
{
if (options.DerivedTypes.Any(d => d.DerivedType == derivedType))
{
return;
}
options.DerivedTypes.Add(new JsonDerivedType(derivedType));
}
}
}

View File

@@ -147,7 +147,7 @@ internal static class PythonContainerAdapter
foreach (var sitePackages in DiscoverLayerSitePackages(rootPath))
{
foreach (var distInfo in EnumerateDistInfoDirectories(sitePackages))
foreach (var distInfo in EnumerateMetadataDirectories(sitePackages))
{
discovered.Add(distInfo);
}
@@ -156,7 +156,7 @@ internal static class PythonContainerAdapter
// Also check root-level site-packages
foreach (var sitePackages in DiscoverSitePackagesInDirectory(rootPath))
{
foreach (var distInfo in EnumerateDistInfoDirectories(sitePackages))
foreach (var distInfo in EnumerateMetadataDirectories(sitePackages))
{
discovered.Add(distInfo);
}
@@ -167,30 +167,33 @@ internal static class PythonContainerAdapter
.ToArray();
}
private static IEnumerable<string> EnumerateDistInfoDirectories(string sitePackages)
private static IEnumerable<string> EnumerateMetadataDirectories(string sitePackages)
{
if (!Directory.Exists(sitePackages))
{
yield break;
}
IEnumerable<string>? directories = null;
try
foreach (var pattern in new[] { "*.dist-info", "*.egg-info" })
{
directories = Directory.EnumerateDirectories(sitePackages, "*.dist-info");
}
catch (IOException)
{
yield break;
}
catch (UnauthorizedAccessException)
{
yield break;
}
IEnumerable<string>? directories = null;
try
{
directories = Directory.EnumerateDirectories(sitePackages, pattern);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
foreach (var directory in directories)
{
yield return directory;
foreach (var directory in directories)
{
yield return directory;
}
}
}

View File

@@ -291,22 +291,8 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
{
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Collect from root path recursively
try
{
foreach (var dir in Directory.EnumerateDirectories(rootPath, "*.dist-info", Enumeration))
{
directories.Add(dir);
}
}
catch (IOException)
{
// Ignore enumeration errors
}
catch (UnauthorizedAccessException)
{
// Ignore access errors
}
AddMetadataDirectories(rootPath, "*.dist-info", directories);
AddMetadataDirectories(rootPath, "*.egg-info", directories);
// Also collect from OCI container layers
foreach (var dir in PythonContainerAdapter.DiscoverDistInfoDirectories(rootPath))
@@ -317,5 +303,24 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
return directories
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
static void AddMetadataDirectories(string basePath, string pattern, ISet<string> accumulator)
{
try
{
foreach (var dir in Directory.EnumerateDirectories(basePath, pattern, Enumeration))
{
accumulator.Add(dir);
}
}
catch (IOException)
{
// Ignore enumeration errors
}
catch (UnauthorizedAccessException)
{
// Ignore access errors
}
}
}
}

View File

@@ -0,0 +1,27 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.spring.boot)
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
// Individual library references
implementation(libs.kotlin.stdlib)
implementation(libs.slf4j.api)
implementation(libs.guava)
// Bundle reference (expands to multiple libraries)
implementation(libs.bundles.jackson)
// Test bundle
testImplementation(libs.bundles.testing)
// Direct declaration alongside catalog
runtimeOnly("ch.qos.logback:logback-classic:1.4.14")
}

View File

@@ -0,0 +1,135 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"name": "commons-lang3",
"version": "3.14.0",
"type": "maven",
"metadata": {
"artifactId": "commons-lang3",
"groupId": "org.apache.commons",
"declaredOnly": "true",
"versionSource": "version-catalog",
"catalogAlias": "commons-lang",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@1.9.21",
"purl": "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@1.9.21",
"name": "kotlin-stdlib",
"version": "1.9.21",
"type": "maven",
"metadata": {
"artifactId": "kotlin-stdlib",
"groupId": "org.jetbrains.kotlin",
"declaredOnly": "true",
"versionSource": "version-catalog",
"versionRef": "kotlin",
"catalogAlias": "kotlin-stdlib",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0",
"name": "jackson-core",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-core",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"versionSource": "version-catalog",
"versionRef": "jackson",
"catalogAlias": "jackson-core",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"name": "jackson-databind",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"versionSource": "version-catalog",
"versionRef": "jackson",
"catalogAlias": "jackson-databind",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"versionSource": "version-catalog",
"catalogAlias": "slf4j-api",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"declaredOnly": "true",
"versionSource": "version-catalog",
"versionRef": "guava",
"catalogAlias": "guava",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"versionSource": "version-catalog",
"versionRef": "junit",
"catalogAlias": "junit-jupiter",
"buildFile": "libs.versions.toml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.mockito/mockito-core@5.8.0",
"purl": "pkg:maven/org.mockito/mockito-core@5.8.0",
"name": "mockito-core",
"version": "5.8.0",
"type": "maven",
"metadata": {
"artifactId": "mockito-core",
"groupId": "org.mockito",
"declaredOnly": "true",
"versionSource": "version-catalog",
"catalogAlias": "mockito-core",
"buildFile": "libs.versions.toml"
}
}
]

View File

@@ -0,0 +1,35 @@
[versions]
kotlin = "1.9.21"
spring-boot = "3.2.0"
jackson = { strictly = "2.16.0" }
junit = { prefer = "5.10.1" }
guava = "32.1.3-jre"
[libraries]
# Short notation
commons-lang = "org.apache.commons:commons-lang3:3.14.0"
# Module notation with version reference
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
# Full notation with group/name
jackson-core = { group = "com.fasterxml.jackson.core", name = "jackson-core", version.ref = "jackson" }
jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" }
# Direct version in table
slf4j-api = { module = "org.slf4j:slf4j-api", version = "2.0.9" }
# Without version (managed elsewhere)
guava = { module = "com.google.guava:guava", version.ref = "guava" }
# Test libraries
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
mockito-core = { module = "org.mockito:mockito-core", version = "5.8.0" }
[bundles]
jackson = ["jackson-core", "jackson-databind"]
testing = ["junit-jupiter", "mockito-core"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }

View File

@@ -0,0 +1,44 @@
plugins {
id 'java'
id 'application'
}
group = 'com.example'
version = '1.0.0'
repositories {
mavenCentral()
}
dependencies {
// String notation - compile scope
implementation 'com.google.guava:guava:32.1.3-jre'
// String notation - with parentheses
implementation("org.apache.commons:commons-lang3:3.14.0")
// Map notation - compile scope
implementation group: 'org.slf4j', name: 'slf4j-api', version: '2.0.9'
// String notation - test scope
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
// String notation - provided scope
compileOnly 'org.projectlombok:lombok:1.18.30'
// String notation - runtime scope
runtimeOnly 'ch.qos.logback:logback-classic:1.4.14'
// Annotation processor
annotationProcessor 'org.projectlombok:lombok:1.18.30'
// Platform/BOM import
implementation platform('org.springframework.boot:spring-boot-dependencies:3.2.0')
// Classifier example
implementation 'org.lwjgl:lwjgl:3.3.3:natives-linux'
}
application {
mainClass = 'com.example.Main'
}

View File

@@ -0,0 +1,108 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"name": "commons-lang3",
"version": "3.14.0",
"type": "maven",
"metadata": {
"artifactId": "commons-lang3",
"groupId": "org.apache.commons",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.projectlombok/lombok@1.18.30",
"purl": "pkg:maven/org.projectlombok/lombok@1.18.30",
"name": "lombok",
"version": "1.18.30",
"type": "maven",
"metadata": {
"artifactId": "lombok",
"groupId": "org.projectlombok",
"declaredOnly": "true",
"declaredScope": "provided",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/ch.qos.logback/logback-classic@1.4.14",
"purl": "pkg:maven/ch.qos.logback/logback-classic@1.4.14",
"name": "logback-classic",
"version": "1.4.14",
"type": "maven",
"metadata": {
"artifactId": "logback-classic",
"groupId": "ch.qos.logback",
"declaredOnly": "true",
"declaredScope": "runtime",
"buildFile": "build.gradle"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.lwjgl/lwjgl@3.3.3",
"purl": "pkg:maven/org.lwjgl/lwjgl@3.3.3",
"name": "lwjgl",
"version": "3.3.3",
"type": "maven",
"metadata": {
"artifactId": "lwjgl",
"groupId": "org.lwjgl",
"classifier": "natives-linux",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle"
}
}
]

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2048m
org.gradle.caching=true

View File

@@ -0,0 +1,50 @@
plugins {
id("java")
id("org.springframework.boot") version "3.2.0"
kotlin("jvm") version "1.9.21"
`java-library`
}
group = "com.example"
version = "2.0.0"
repositories {
mavenCentral()
}
dependencies {
// String coordinate notation
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.21")
// Named arguments notation
implementation(group = "com.fasterxml.jackson.core", name = "jackson-databind", version = "2.16.0")
// Test dependencies
testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
testImplementation("io.mockk:mockk:1.13.8")
// Provided scope
compileOnly("jakarta.servlet:jakarta.servlet-api:6.0.0")
// Runtime scope
runtimeOnly("org.postgresql:postgresql:42.7.0")
// Platform/BOM import
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
// Enforced platform
api(enforcedPlatform("com.google.cloud:libraries-bom:26.28.0"))
// Annotation processor (kapt)
kapt("org.mapstruct:mapstruct-processor:1.5.5.Final")
// KSP processor
ksp("io.insert-koin:koin-ksp-compiler:1.3.0")
// Internal project dependency (should be skipped)
implementation(project(":core-module"))
}
kotlin {
jvmToolchain(17)
}

View File

@@ -0,0 +1,122 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@1.9.21",
"purl": "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@1.9.21",
"name": "kotlin-stdlib",
"version": "1.9.21",
"type": "maven",
"metadata": {
"artifactId": "kotlin-stdlib",
"groupId": "org.jetbrains.kotlin",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"name": "jackson-databind",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/io.mockk/mockk@1.13.8",
"purl": "pkg:maven/io.mockk/mockk@1.13.8",
"name": "mockk",
"version": "1.13.8",
"type": "maven",
"metadata": {
"artifactId": "mockk",
"groupId": "io.mockk",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/jakarta.servlet/jakarta.servlet-api@6.0.0",
"purl": "pkg:maven/jakarta.servlet/jakarta.servlet-api@6.0.0",
"name": "jakarta.servlet-api",
"version": "6.0.0",
"type": "maven",
"metadata": {
"artifactId": "jakarta.servlet-api",
"groupId": "jakarta.servlet",
"declaredOnly": "true",
"declaredScope": "provided",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.postgresql/postgresql@42.7.0",
"purl": "pkg:maven/org.postgresql/postgresql@42.7.0",
"name": "postgresql",
"version": "42.7.0",
"type": "maven",
"metadata": {
"artifactId": "postgresql",
"groupId": "org.postgresql",
"declaredOnly": "true",
"declaredScope": "runtime",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.mapstruct/mapstruct-processor@1.5.5.Final",
"purl": "pkg:maven/org.mapstruct/mapstruct-processor@1.5.5.Final",
"name": "mapstruct-processor",
"version": "1.5.5.Final",
"type": "maven",
"metadata": {
"artifactId": "mapstruct-processor",
"groupId": "org.mapstruct",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle.kts"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/io.insert-koin/koin-ksp-compiler@1.3.0",
"purl": "pkg:maven/io.insert-koin/koin-ksp-compiler@1.3.0",
"name": "koin-ksp-compiler",
"version": "1.3.0",
"type": "maven",
"metadata": {
"artifactId": "koin-ksp-compiler",
"groupId": "io.insert-koin",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "build.gradle.kts"
}
}
]

View File

@@ -0,0 +1,82 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework.boot/spring-boot-starter-web",
"purl": "pkg:maven/org.springframework.boot/spring-boot-starter-web",
"name": "spring-boot-starter-web",
"type": "maven",
"metadata": {
"artifactId": "spring-boot-starter-web",
"groupId": "org.springframework.boot",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "bom",
"bomArtifact": "org.springframework.boot:spring-boot-dependencies:3.2.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind",
"name": "jackson-databind",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "bom",
"bomArtifact": "org.springframework.boot:spring-boot-dependencies:3.2.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/software.amazon.awssdk/s3",
"purl": "pkg:maven/software.amazon.awssdk/s3",
"name": "s3",
"type": "maven",
"metadata": {
"artifactId": "s3",
"groupId": "software.amazon.awssdk",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "bom",
"bomArtifact": "software.amazon.awssdk:bom:2.21.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"name": "commons-lang3",
"version": "3.14.0",
"type": "maven",
"metadata": {
"artifactId": "commons-lang3",
"groupId": "org.apache.commons",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "dependencyManagement",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.projectlombok/lombok@1.18.30",
"purl": "pkg:maven/org.projectlombok/lombok@1.18.30",
"name": "lombok",
"version": "1.18.30",
"type": "maven",
"metadata": {
"artifactId": "lombok",
"groupId": "org.projectlombok",
"declaredOnly": "true",
"declaredScope": "provided",
"versionSource": "direct",
"buildFile": "pom.xml"
}
}
]

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bom-consumer</artifactId>
<version>1.0.0</version>
<name>BOM Consumer</name>
<description>Project that imports BOMs for version management</description>
<properties>
<spring-boot.version>3.2.0</spring-boot.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring Boot BOM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud BOM -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- AWS SDK BOM -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.21.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Local version override -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Versions managed by Spring Boot BOM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Versions managed by AWS SDK BOM -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Version managed by local dependencyManagement -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Direct version declaration -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,62 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.hibernate.orm/hibernate-core@6.4.0.Final",
"purl": "pkg:maven/org.hibernate.orm/hibernate-core@6.4.0.Final",
"name": "hibernate-core",
"version": "6.4.0.Final",
"type": "maven",
"metadata": {
"artifactId": "hibernate-core",
"groupId": "org.hibernate.orm",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
}
]

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>licensed-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Licensed Application</name>
<description>Example project with license declarations</description>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
<distribution>repo</distribution>
<comments>Dual licensed under Apache-2.0 and MIT</comments>
</license>
</licenses>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Apache-2.0 licensed dependency -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<!-- MIT licensed dependency -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<!-- EPL-2.0 licensed dependency -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<!-- LGPL-2.1 licensed dependency -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.0.Final</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,86 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-core@6.1.0",
"purl": "pkg:maven/org.springframework/spring-core@6.1.0",
"name": "spring-core",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-core",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "parent",
"parentArtifact": "com.example:parent-pom:1.0.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-context@6.1.0",
"purl": "pkg:maven/org.springframework/spring-context@6.1.0",
"name": "spring-context",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-context",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "parent",
"parentArtifact": "com.example:parent-pom:1.0.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"name": "jackson-databind",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "parent",
"parentArtifact": "com.example:parent-pom:1.0.0",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"declaredScope": "compile",
"versionSource": "direct",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"versionSource": "parent",
"parentArtifact": "com.example:parent-pom:1.0.0",
"buildFile": "pom.xml"
}
}
]

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>parent-pom</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>Parent POM</name>
<description>Parent POM for version inheritance testing</description>
<properties>
<java.version>17</java.version>
<spring.version>6.1.0</spring.version>
<jackson.version>2.16.0</jackson.version>
<junit.version>5.10.1</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>parent-pom</artifactId>
<version>1.0.0</version>
<relativePath>parent/pom.xml</relativePath>
</parent>
<artifactId>child-module</artifactId>
<version>2.0.0</version>
<name>Child Module</name>
<description>Child module that inherits from parent</description>
<dependencies>
<!-- Inherits version from parent dependencyManagement -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Direct version override -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<!-- Test scope inherited from parent -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,97 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-core@6.1.0",
"purl": "pkg:maven/org.springframework/spring-core@6.1.0",
"name": "spring-core",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-core",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"versionProperty": "spring.version",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-context@6.1.0",
"purl": "pkg:maven/org.springframework/spring-context@6.1.0",
"name": "spring-context",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-context",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"versionProperty": "spring-core.version",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"name": "jackson-databind",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"versionProperty": "jackson.version",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"versionProperty": "junit.version",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.projectlombok/lombok@1.18.30",
"purl": "pkg:maven/org.projectlombok/lombok@1.18.30",
"name": "lombok",
"version": "1.18.30",
"type": "maven",
"metadata": {
"artifactId": "lombok",
"groupId": "org.projectlombok",
"declaredOnly": "true",
"declaredScope": "provided",
"versionProperty": "lombok.version",
"buildFile": "pom.xml"
}
}
]

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>properties-demo</artifactId>
<version>1.0.0</version>
<name>Properties Demo</name>
<description>Project demonstrating property placeholder resolution</description>
<properties>
<!-- Standard version properties -->
<java.version>17</java.version>
<spring.version>6.1.0</spring.version>
<jackson.version>2.16.0</jackson.version>
<lombok.version>1.18.30</lombok.version>
<!-- Nested property reference -->
<spring-core.version>${spring.version}</spring-core.version>
<!-- Property with suffix -->
<junit.major>5</junit.major>
<junit.minor>10</junit.minor>
<junit.patch>1</junit.patch>
<junit.version>${junit.major}.${junit.minor}.${junit.patch}</junit.version>
<!-- Project properties -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Simple property reference -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- Nested property reference -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring-core.version}</version>
</dependency>
<!-- Multiple property interpolation -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Composed version property -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<!-- Direct version (no property) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<!-- Property in scope attribute -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,169 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"name": "commons-lang3",
"version": "3.14.0",
"type": "maven",
"metadata": {
"artifactId": "commons-lang3",
"groupId": "org.apache.commons",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/jakarta.servlet/jakarta.servlet-api@6.0.0",
"purl": "pkg:maven/jakarta.servlet/jakarta.servlet-api@6.0.0",
"name": "jakarta.servlet-api",
"version": "6.0.0",
"type": "maven",
"metadata": {
"artifactId": "jakarta.servlet-api",
"groupId": "jakarta.servlet",
"declaredOnly": "true",
"declaredScope": "provided",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.projectlombok/lombok@1.18.30",
"purl": "pkg:maven/org.projectlombok/lombok@1.18.30",
"name": "lombok",
"version": "1.18.30",
"type": "maven",
"metadata": {
"artifactId": "lombok",
"groupId": "org.projectlombok",
"declaredOnly": "true",
"declaredScope": "provided",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.postgresql/postgresql@42.7.0",
"purl": "pkg:maven/org.postgresql/postgresql@42.7.0",
"name": "postgresql",
"version": "42.7.0",
"type": "maven",
"metadata": {
"artifactId": "postgresql",
"groupId": "org.postgresql",
"declaredOnly": "true",
"declaredScope": "runtime",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/ch.qos.logback/logback-classic@1.4.14",
"purl": "pkg:maven/ch.qos.logback/logback-classic@1.4.14",
"name": "logback-classic",
"version": "1.4.14",
"type": "maven",
"metadata": {
"artifactId": "logback-classic",
"groupId": "ch.qos.logback",
"declaredOnly": "true",
"declaredScope": "runtime",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.mockito/mockito-core@5.8.0",
"purl": "pkg:maven/org.mockito/mockito-core@5.8.0",
"name": "mockito-core",
"version": "5.8.0",
"type": "maven",
"metadata": {
"artifactId": "mockito-core",
"groupId": "org.mockito",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.assertj/assertj-core@3.24.2",
"purl": "pkg:maven/org.assertj/assertj-core@3.24.2",
"name": "assertj-core",
"version": "3.24.2",
"type": "maven",
"metadata": {
"artifactId": "assertj-core",
"groupId": "org.assertj",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.example.legacy/legacy-lib@1.0.0",
"purl": "pkg:maven/com.example.legacy/legacy-lib@1.0.0",
"name": "legacy-lib",
"version": "1.0.0",
"type": "maven",
"metadata": {
"artifactId": "legacy-lib",
"groupId": "com.example.legacy",
"declaredOnly": "true",
"declaredScope": "system",
"systemPath": "${project.basedir}/lib/legacy-lib.jar",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-context@6.1.0",
"purl": "pkg:maven/org.springframework/spring-context@6.1.0",
"name": "spring-context",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-context",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"optional": "true",
"buildFile": "pom.xml"
}
}
]

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>scoped-deps</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Scoped Dependencies Example</name>
<description>Tests all Maven dependency scopes</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- compile scope (default) - available in all classpaths -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
<!-- scope defaults to compile -->
</dependency>
<!-- compile scope (explicit) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
<scope>compile</scope>
</dependency>
<!-- provided scope - available at compile time, not included in final package -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<!-- provided scope - annotation processor -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<!-- runtime scope - not needed for compilation, only for execution -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.0</version>
<scope>runtime</scope>
</dependency>
<!-- runtime scope - logging implementation -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.14</version>
<scope>runtime</scope>
</dependency>
<!-- test scope - only available during test compilation and execution -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<!-- test scope - mocking framework -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<!-- test scope - assertions library -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
<!-- system scope - deprecated but still used - local JAR -->
<dependency>
<groupId>com.example.legacy</groupId>
<artifactId>legacy-lib</artifactId>
<version>1.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/legacy-lib.jar</systemPath>
</dependency>
<!-- import scope - only valid in dependencyManagement for BOMs -->
<!-- Note: import scope is tested in maven-bom fixture -->
<!-- optional dependency - not transitive -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.0</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,28 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.example.osgi/service@1.0.0",
"purl": "pkg:maven/com.example.osgi/service@1.0.0",
"name": "service",
"version": "1.0.0",
"type": "maven",
"metadata": {
"jarPath": "osgi-service.jar",
"osgi.symbolicName": "com.example.osgi.service",
"osgi.version": "1.0.0.qualifier",
"osgi.bundleName": "Example OSGi Service Bundle",
"osgi.vendor": "Example Corp",
"osgi.executionEnvironment": "JavaSE-17",
"osgi.importPackage": "org.osgi.framework;version=\"[1.8,2.0)\",org.osgi.service.component;version=\"[1.4,2.0)\",org.slf4j;version=\"[2.0,3.0)\"",
"osgi.exportPackage": "com.example.osgi.service.api;version=\"1.0.0\",com.example.osgi.service.spi;version=\"1.0.0\"",
"osgi.requireBundle": "org.apache.felix.scr;bundle-version=\"[2.1,3.0)\""
},
"evidence": [
{
"kind": "file",
"source": "MANIFEST.MF",
"locator": "osgi-service.jar!META-INF/MANIFEST.MF"
}
]
}
]

View File

@@ -0,0 +1,23 @@
{
"description": "OSGi bundle fixture - tests detection of Bundle-SymbolicName and Import/Export-Package headers",
"jarName": "osgi-service.jar",
"manifest": {
"Bundle-SymbolicName": "com.example.osgi.service",
"Bundle-Version": "1.0.0.qualifier",
"Bundle-Name": "Example OSGi Service Bundle",
"Bundle-Vendor": "Example Corp",
"Bundle-RequiredExecutionEnvironment": "JavaSE-17",
"Import-Package": [
"org.osgi.framework;version=\"[1.8,2.0)\"",
"org.osgi.service.component;version=\"[1.4,2.0)\"",
"org.slf4j;version=\"[2.0,3.0)\""
],
"Export-Package": [
"com.example.osgi.service.api;version=\"1.0.0\"",
"com.example.osgi.service.spi;version=\"1.0.0\""
],
"Require-Bundle": [
"org.apache.felix.scr;bundle-version=\"[2.1,3.0)\""
]
}
}

View File

@@ -0,0 +1,70 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.example/shaded-app@1.0.0",
"purl": "pkg:maven/com.example/shaded-app@1.0.0",
"name": "shaded-app",
"version": "1.0.0",
"type": "maven",
"metadata": {
"artifactId": "shaded-app",
"groupId": "com.example",
"jarPath": "shaded-app.jar",
"shaded": "true",
"shading.confidence": "High",
"shading.embeddedCount": "3",
"shading.markers": "dependency-reduced-pom.xml,multiple-pom-properties,relocated-packages"
},
"evidence": [
{
"kind": "file",
"source": "pom.properties",
"locator": "shaded-app.jar!META-INF/maven/com.example/shaded-app/pom.properties"
}
]
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"purl": "pkg:maven/org.apache.commons/commons-lang3@3.14.0",
"name": "commons-lang3",
"version": "3.14.0",
"type": "maven",
"metadata": {
"artifactId": "commons-lang3",
"groupId": "org.apache.commons",
"jarPath": "shaded-app.jar",
"embeddedIn": "com.example:shaded-app:1.0.0",
"relocated": "shaded/org/apache/commons/"
},
"evidence": [
{
"kind": "file",
"source": "pom.properties",
"locator": "shaded-app.jar!META-INF/maven/org.apache.commons/commons-lang3/pom.properties"
}
]
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"jarPath": "shaded-app.jar",
"embeddedIn": "com.example:shaded-app:1.0.0",
"relocated": "shaded/com/google/guava/"
},
"evidence": [
{
"kind": "file",
"source": "pom.properties",
"locator": "shaded-app.jar!META-INF/maven/com.google.guava/guava/pom.properties"
}
]
}
]

View File

@@ -0,0 +1,34 @@
{
"description": "Shaded JAR fixture - tests detection of bundled dependencies in a uber/fat JAR",
"jarName": "shaded-app.jar",
"shading": {
"isShaded": true,
"confidence": "High",
"markers": [
"dependency-reduced-pom.xml",
"multiple-pom-properties",
"relocated-packages"
]
},
"embeddedArtifacts": [
{
"groupId": "com.example",
"artifactId": "shaded-app",
"version": "1.0.0"
},
{
"groupId": "org.apache.commons",
"artifactId": "commons-lang3",
"version": "3.14.0"
},
{
"groupId": "com.google.guava",
"artifactId": "guava",
"version": "32.1.3-jre"
}
],
"relocatedPrefixes": [
"shaded/org/apache/commons/",
"shaded/com/google/guava/"
]
}

View File

@@ -0,0 +1,198 @@
[
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.google.guava/guava@32.1.3-jre",
"purl": "pkg:maven/com.google.guava/guava@32.1.3-jre",
"name": "guava",
"version": "32.1.3-jre",
"type": "maven",
"metadata": {
"artifactId": "guava",
"groupId": "com.google.guava",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0",
"name": "jackson-databind",
"version": "2.16.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-databind",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "com.fasterxml.jackson.core",
"versionConflict.versions": "2.14.0,2.15.0,2.16.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.15.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.15.0",
"name": "jackson-annotations",
"version": "2.15.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-annotations",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "com.fasterxml.jackson.core",
"versionConflict.versions": "2.14.0,2.15.0,2.16.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/com.fasterxml.jackson.core/jackson-core@2.14.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.14.0",
"name": "jackson-core",
"version": "2.14.0",
"type": "maven",
"metadata": {
"artifactId": "jackson-core",
"groupId": "com.fasterxml.jackson.core",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "com.fasterxml.jackson.core",
"versionConflict.versions": "2.14.0,2.15.0,2.16.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.slf4j/slf4j-api@2.0.9",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.9",
"name": "slf4j-api",
"version": "2.0.9",
"type": "maven",
"metadata": {
"artifactId": "slf4j-api",
"groupId": "org.slf4j",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/ch.qos.logback/logback-classic@1.4.11",
"purl": "pkg:maven/ch.qos.logback/logback-classic@1.4.11",
"name": "logback-classic",
"version": "1.4.11",
"type": "maven",
"metadata": {
"artifactId": "logback-classic",
"groupId": "ch.qos.logback",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-core@6.1.0",
"purl": "pkg:maven/org.springframework/spring-core@6.1.0",
"name": "spring-core",
"version": "6.1.0",
"type": "maven",
"metadata": {
"artifactId": "spring-core",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "org.springframework",
"versionConflict.versions": "5.3.30,6.0.0,6.1.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-context@6.0.0",
"purl": "pkg:maven/org.springframework/spring-context@6.0.0",
"name": "spring-context",
"version": "6.0.0",
"type": "maven",
"metadata": {
"artifactId": "spring-context",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "org.springframework",
"versionConflict.versions": "5.3.30,6.0.0,6.1.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.springframework/spring-beans@5.3.30",
"purl": "pkg:maven/org.springframework/spring-beans@5.3.30",
"name": "spring-beans",
"version": "5.3.30",
"type": "maven",
"metadata": {
"artifactId": "spring-beans",
"groupId": "org.springframework",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml",
"versionConflict.group": "org.springframework",
"versionConflict.versions": "5.3.30,6.0.0,6.1.0"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/commons-io/commons-io@2.11.0",
"purl": "pkg:maven/commons-io/commons-io@2.11.0",
"name": "commons-io",
"version": "2.11.0",
"type": "maven",
"metadata": {
"artifactId": "commons-io",
"groupId": "commons-io",
"declaredOnly": "true",
"declaredScope": "compile",
"buildFile": "pom.xml"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.1",
"name": "junit-jupiter",
"version": "5.10.1",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml",
"versionConflict.group": "org.junit.jupiter",
"versionConflict.versions": "5.9.0,5.10.1"
}
},
{
"analyzerId": "java",
"componentKey": "purl::pkg:maven/org.junit.jupiter/junit-jupiter-api@5.9.0",
"purl": "pkg:maven/org.junit.jupiter/junit-jupiter-api@5.9.0",
"name": "junit-jupiter-api",
"version": "5.9.0",
"type": "maven",
"metadata": {
"artifactId": "junit-jupiter-api",
"groupId": "org.junit.jupiter",
"declaredOnly": "true",
"declaredScope": "test",
"buildFile": "pom.xml",
"versionConflict.group": "org.junit.jupiter",
"versionConflict.versions": "5.9.0,5.10.1"
}
}
]

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>version-conflict-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Version Conflict Example</name>
<description>Tests detection of version conflicts in dependencies</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Direct dependency on guava 32.1.3-jre -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<!-- This transitively pulls in guava 30.1-jre (older) -->
<!-- Simulating conflict via direct declaration with different version -->
<!-- Jackson databind - multiple versions in ecosystem -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.0</version>
</dependency>
<!-- Jackson annotations - version should match databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.15.0</version>
</dependency>
<!-- Jackson core - another version mismatch -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.14.0</version>
</dependency>
<!-- SLF4J API - declared multiple times with different versions -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<!-- Logback brings in slf4j-api 2.0.7 transitively -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.11</version>
</dependency>
<!-- Spring framework version misalignment -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>6.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.3.30</version>
</dependency>
<!-- Commons IO - old vulnerable version vs new -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- Test dependency with conflicting version -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,8 +1,8 @@
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
@@ -37,12 +37,12 @@ public sealed class JavaLanguageAnalyzerTests
}
[Fact]
public async Task LockfilesProduceDeclaredOnlyComponentsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
public async Task LockfilesProduceDeclaredOnlyComponentsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = CreateSampleJar(root, "com.example", "runtime-only", "1.0.0");
var lockPath = Path.Combine(root, "gradle.lockfile");
@@ -64,132 +64,330 @@ public sealed class JavaLanguageAnalyzerTests
Assert.True(ComponentHasMetadata(rootElement, "declared-only", "declaredOnly", "true"));
Assert.True(ComponentHasMetadata(rootElement, "declared-only", "lockSource", "gradle.lockfile"));
Assert.True(ComponentHasMetadata(rootElement, "runtime-only", "lockMissing", "true"));
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task CapturesFrameworkConfigurationHintsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = Path.Combine(root, "demo-framework.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
{
WritePomProperties(archive, "com.example", "demo-framework", "1.0.0");
WriteManifest(archive, "demo-framework", "1.0.0", "com.example");
CreateTextEntry(archive, "META-INF/spring.factories");
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports");
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports");
CreateTextEntry(archive, "BOOT-INF/classes/application.yml");
CreateTextEntry(archive, "WEB-INF/web.xml");
CreateTextEntry(archive, "META-INF/web-fragment.xml");
CreateTextEntry(archive, "META-INF/persistence.xml");
CreateTextEntry(archive, "META-INF/beans.xml");
CreateTextEntry(archive, "META-INF/jaxb.index");
CreateTextEntry(archive, "META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate");
CreateTextEntry(archive, "logback.xml");
CreateTextEntry(archive, "META-INF/native-image/demo/reflect-config.json");
}
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
root,
analyzers,
cancellationToken,
new LanguageUsageHints(new[] { jarPath }));
using var document = JsonDocument.Parse(json);
var component = document.RootElement
.EnumerateArray()
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-framework", StringComparison.Ordinal));
var metadata = component.GetProperty("metadata");
Assert.Equal("demo-framework.jar!META-INF/spring.factories", metadata.GetProperty("config.spring.factories").GetString());
Assert.Equal(
"demo-framework.jar!META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports,demo-framework.jar!META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
metadata.GetProperty("config.spring.imports").GetString());
Assert.Equal("demo-framework.jar!BOOT-INF/classes/application.yml", metadata.GetProperty("config.spring.properties").GetString());
Assert.Equal("demo-framework.jar!WEB-INF/web.xml", metadata.GetProperty("config.web.xml").GetString());
Assert.Equal("demo-framework.jar!META-INF/web-fragment.xml", metadata.GetProperty("config.web.fragment").GetString());
Assert.Equal("demo-framework.jar!META-INF/persistence.xml", metadata.GetProperty("config.jpa").GetString());
Assert.Equal("demo-framework.jar!META-INF/beans.xml", metadata.GetProperty("config.cdi").GetString());
Assert.Equal("demo-framework.jar!META-INF/jaxb.index", metadata.GetProperty("config.jaxb").GetString());
Assert.Equal("demo-framework.jar!META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate", metadata.GetProperty("config.jaxrs").GetString());
Assert.Equal("demo-framework.jar!logback.xml", metadata.GetProperty("config.logging").GetString());
Assert.Equal("demo-framework.jar!META-INF/native-image/demo/reflect-config.json", metadata.GetProperty("config.graal").GetString());
var evidence = component.GetProperty("evidence").EnumerateArray().ToArray();
Assert.Contains(evidence, e =>
string.Equals(e.GetProperty("source").GetString(), "framework-config", StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.GetProperty("locator").GetString(), "demo-framework.jar!META-INF/spring.factories", StringComparison.OrdinalIgnoreCase) &&
e.TryGetProperty("sha256", out var sha) &&
!string.IsNullOrWhiteSpace(sha.GetString()));
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task CapturesJniHintsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = Path.Combine(root, "demo-jni.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
{
WritePomProperties(archive, "com.example", "demo-jni", "1.0.0");
WriteManifest(archive, "demo-jni", "1.0.0", "com.example");
CreateBinaryEntry(archive, "com/example/App.class", "System.loadLibrary(\"foo\")");
CreateTextEntry(archive, "lib/native/libfoo.so");
CreateTextEntry(archive, "META-INF/native-image/demo/jni-config.json");
}
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
root,
analyzers,
cancellationToken,
new LanguageUsageHints(new[] { jarPath }));
using var document = JsonDocument.Parse(json);
var component = document.RootElement
.EnumerateArray()
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-jni", StringComparison.Ordinal));
var metadata = component.GetProperty("metadata");
Assert.Equal("libfoo.so", metadata.GetProperty("jni.nativeLibs").GetString());
Assert.Equal("demo-jni.jar!META-INF/native-image/demo/jni-config.json", metadata.GetProperty("jni.graalConfig").GetString());
Assert.Equal("demo-jni.jar!com/example/App.class", metadata.GetProperty("jni.loadCalls").GetString());
}
finally
{
TestPaths.SafeDelete(root);
}
}
private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string expected)
{
foreach (var element in root.EnumerateArray())
{
if (!element.TryGetProperty("name", out var nameElement) ||
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task CapturesFrameworkConfigurationHintsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = Path.Combine(root, "demo-framework.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
{
WritePomProperties(archive, "com.example", "demo-framework", "1.0.0");
WriteManifest(archive, "demo-framework", "1.0.0", "com.example");
CreateTextEntry(archive, "META-INF/spring.factories");
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports");
CreateTextEntry(archive, "META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports");
CreateTextEntry(archive, "BOOT-INF/classes/application.yml");
CreateTextEntry(archive, "WEB-INF/web.xml");
CreateTextEntry(archive, "META-INF/web-fragment.xml");
CreateTextEntry(archive, "META-INF/persistence.xml");
CreateTextEntry(archive, "META-INF/beans.xml");
CreateTextEntry(archive, "META-INF/jaxb.index");
CreateTextEntry(archive, "META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate");
CreateTextEntry(archive, "logback.xml");
CreateTextEntry(archive, "META-INF/native-image/demo/reflect-config.json");
}
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
root,
analyzers,
cancellationToken,
new LanguageUsageHints(new[] { jarPath }));
using var document = JsonDocument.Parse(json);
var component = document.RootElement
.EnumerateArray()
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-framework", StringComparison.Ordinal));
var metadata = component.GetProperty("metadata");
Assert.Equal("demo-framework.jar!META-INF/spring.factories", metadata.GetProperty("config.spring.factories").GetString());
Assert.Equal(
"demo-framework.jar!META-INF/spring/org.springframework.boot.actuate.autoconfigure.AutoConfiguration.imports,demo-framework.jar!META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
metadata.GetProperty("config.spring.imports").GetString());
Assert.Equal("demo-framework.jar!BOOT-INF/classes/application.yml", metadata.GetProperty("config.spring.properties").GetString());
Assert.Equal("demo-framework.jar!WEB-INF/web.xml", metadata.GetProperty("config.web.xml").GetString());
Assert.Equal("demo-framework.jar!META-INF/web-fragment.xml", metadata.GetProperty("config.web.fragment").GetString());
Assert.Equal("demo-framework.jar!META-INF/persistence.xml", metadata.GetProperty("config.jpa").GetString());
Assert.Equal("demo-framework.jar!META-INF/beans.xml", metadata.GetProperty("config.cdi").GetString());
Assert.Equal("demo-framework.jar!META-INF/jaxb.index", metadata.GetProperty("config.jaxb").GetString());
Assert.Equal("demo-framework.jar!META-INF/services/jakarta.ws.rs.ext.RuntimeDelegate", metadata.GetProperty("config.jaxrs").GetString());
Assert.Equal("demo-framework.jar!logback.xml", metadata.GetProperty("config.logging").GetString());
Assert.Equal("demo-framework.jar!META-INF/native-image/demo/reflect-config.json", metadata.GetProperty("config.graal").GetString());
var evidence = component.GetProperty("evidence").EnumerateArray().ToArray();
Assert.Contains(evidence, e =>
string.Equals(e.GetProperty("source").GetString(), "framework-config", StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.GetProperty("locator").GetString(), "demo-framework.jar!META-INF/spring.factories", StringComparison.OrdinalIgnoreCase) &&
e.TryGetProperty("sha256", out var sha) &&
!string.IsNullOrWhiteSpace(sha.GetString()));
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public async Task CapturesJniHintsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = Path.Combine(root, "demo-jni.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using (var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create))
{
WritePomProperties(archive, "com.example", "demo-jni", "1.0.0");
WriteManifest(archive, "demo-jni", "1.0.0", "com.example");
CreateBinaryEntry(archive, "com/example/App.class", "System.loadLibrary(\"foo\")");
CreateTextEntry(archive, "lib/native/libfoo.so");
CreateTextEntry(archive, "META-INF/native-image/demo/jni-config.json");
}
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
root,
analyzers,
cancellationToken,
new LanguageUsageHints(new[] { jarPath }));
using var document = JsonDocument.Parse(json);
var component = document.RootElement
.EnumerateArray()
.First(element => string.Equals(element.GetProperty("name").GetString(), "demo-jni", StringComparison.Ordinal));
var metadata = component.GetProperty("metadata");
Assert.Equal("libfoo.so", metadata.GetProperty("jni.nativeLibs").GetString());
Assert.Equal("demo-jni.jar!META-INF/native-image/demo/jni-config.json", metadata.GetProperty("jni.graalConfig").GetString());
Assert.Equal("demo-jni.jar!com/example/App.class", metadata.GetProperty("jni.loadCalls").GetString());
}
finally
{
TestPaths.SafeDelete(root);
}
}
#region Build File Fixture Integration Tests
[Fact]
public async Task ParsesGradleGroovyBuildFileAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "gradle-groovy");
var goldenPath = TestPaths.ResolveFixture("java", "gradle-groovy", "expected.json");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify key dependencies are detected
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "guava"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "commons-lang3"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "slf4j-api"));
// Verify declaredOnly flag is set for build file dependencies
var guava = components.First(c => c.GetProperty("name").GetString() == "guava");
Assert.True(guava.GetProperty("metadata").TryGetProperty("declaredOnly", out var declaredOnly));
Assert.Equal("true", declaredOnly.GetString());
}
[Fact]
public async Task ParsesGradleKotlinBuildFileAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "gradle-kotlin");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify Kotlin DSL dependencies are detected
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "kotlin-stdlib"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "jackson-databind"));
// Verify kapt/ksp dependencies are detected
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "mapstruct-processor"));
}
[Fact]
public async Task ParsesGradleVersionCatalogAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "gradle-catalog");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify version catalog dependencies are resolved
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "kotlin-stdlib"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "commons-lang3"));
// Verify version is resolved from catalog
var kotlinStdlib = components.First(c => c.GetProperty("name").GetString() == "kotlin-stdlib");
Assert.Equal("1.9.21", kotlinStdlib.GetProperty("version").GetString());
}
[Fact]
public async Task ParsesMavenParentPomAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "maven-parent");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify dependencies with inherited versions are detected
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "guava"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "slf4j-api"));
// Verify version is inherited from parent
var guava = components.First(c => c.GetProperty("name").GetString() == "guava");
Assert.Equal("32.1.3-jre", guava.GetProperty("version").GetString());
}
[Fact]
public async Task ParsesMavenBomImportsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "maven-bom");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify BOM imports are detected
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "spring-boot-dependencies"));
Assert.True(components.Any(c => c.GetProperty("name").GetString() == "jackson-bom"));
// Verify BOM metadata
var springBom = components.First(c => c.GetProperty("name").GetString() == "spring-boot-dependencies");
var metadata = springBom.GetProperty("metadata");
Assert.True(metadata.TryGetProperty("bomImport", out var bomImport));
Assert.Equal("true", bomImport.GetString());
}
[Fact]
public async Task ParsesMavenPropertyPlaceholdersAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "maven-properties");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify property placeholders are resolved
var springCore = components.FirstOrDefault(c => c.GetProperty("name").GetString() == "spring-core");
Assert.NotNull(springCore);
Assert.Equal("6.1.0", springCore.Value.GetProperty("version").GetString());
// Verify versionProperty metadata is captured
var metadata = springCore.Value.GetProperty("metadata");
Assert.True(metadata.TryGetProperty("versionProperty", out var versionProp));
Assert.Equal("spring.version", versionProp.GetString());
}
[Fact]
public async Task ParsesMavenScopesAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "maven-scopes");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify different scopes are captured
var guava = components.First(c => c.GetProperty("name").GetString() == "guava");
Assert.Equal("compile", guava.GetProperty("metadata").GetProperty("declaredScope").GetString());
var servletApi = components.First(c => c.GetProperty("name").GetString() == "jakarta.servlet-api");
Assert.Equal("provided", servletApi.GetProperty("metadata").GetProperty("declaredScope").GetString());
var postgresql = components.First(c => c.GetProperty("name").GetString() == "postgresql");
Assert.Equal("runtime", postgresql.GetProperty("metadata").GetProperty("declaredScope").GetString());
var junit = components.First(c => c.GetProperty("name").GetString() == "junit-jupiter");
Assert.Equal("test", junit.GetProperty("metadata").GetProperty("declaredScope").GetString());
// Verify optional flag
var springContext = components.First(c => c.GetProperty("name").GetString() == "spring-context");
Assert.True(springContext.GetProperty("metadata").TryGetProperty("optional", out var optional));
Assert.Equal("true", optional.GetString());
}
[Fact]
public async Task DetectsVersionConflictsAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("java", "version-conflict");
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken);
using var document = JsonDocument.Parse(json);
var components = document.RootElement.EnumerateArray().ToArray();
// Verify Jackson version conflict is detected
var jacksonDatabind = components.First(c => c.GetProperty("name").GetString() == "jackson-databind");
var metadata = jacksonDatabind.GetProperty("metadata");
if (metadata.TryGetProperty("versionConflict.group", out var conflictGroup))
{
Assert.Equal("com.fasterxml.jackson.core", conflictGroup.GetString());
}
// Verify Spring version conflict is detected
var springCore = components.First(c => c.GetProperty("name").GetString() == "spring-core");
var springMetadata = springCore.GetProperty("metadata");
if (springMetadata.TryGetProperty("versionConflict.group", out var springConflictGroup))
{
Assert.Equal("org.springframework", springConflictGroup.GetString());
}
}
#endregion
private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string expected)
{
foreach (var element in root.EnumerateArray())
{
if (!element.TryGetProperty("name", out var nameElement) ||
!string.Equals(nameElement.GetString(), componentName, StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -211,53 +409,53 @@ public sealed class JavaLanguageAnalyzerTests
}
}
return false;
}
private static void WritePomProperties(ZipArchive archive, string groupId, string artifactId, string version)
{
var pomPropertiesPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
var pomPropertiesEntry = archive.CreateEntry(pomPropertiesPath);
using var writer = new StreamWriter(pomPropertiesEntry.Open(), Encoding.UTF8);
writer.WriteLine($"groupId={groupId}");
writer.WriteLine($"artifactId={artifactId}");
writer.WriteLine($"version={version}");
writer.WriteLine("packaging=jar");
writer.WriteLine("name=Sample");
}
private static void WriteManifest(ZipArchive archive, string artifactId, string version, string groupId)
{
var manifestEntry = archive.CreateEntry("META-INF/MANIFEST.MF");
using var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8);
writer.WriteLine("Manifest-Version: 1.0");
writer.WriteLine($"Implementation-Title: {artifactId}");
writer.WriteLine($"Implementation-Version: {version}");
writer.WriteLine($"Implementation-Vendor: {groupId}");
}
private static void CreateTextEntry(ZipArchive archive, string path, string? content = null)
{
var entry = archive.CreateEntry(path);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
if (!string.IsNullOrEmpty(content))
{
writer.Write(content);
}
}
private static void CreateBinaryEntry(ZipArchive archive, string path, string content)
{
var entry = archive.CreateEntry(path);
using var stream = entry.Open();
var bytes = Encoding.UTF8.GetBytes(content);
stream.Write(bytes, 0, bytes.Length);
}
private static string CreateSampleJar(string root, string groupId, string artifactId, string version)
{
var jarPath = Path.Combine(root, $"{artifactId}-{version}.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
return false;
}
private static void WritePomProperties(ZipArchive archive, string groupId, string artifactId, string version)
{
var pomPropertiesPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";
var pomPropertiesEntry = archive.CreateEntry(pomPropertiesPath);
using var writer = new StreamWriter(pomPropertiesEntry.Open(), Encoding.UTF8);
writer.WriteLine($"groupId={groupId}");
writer.WriteLine($"artifactId={artifactId}");
writer.WriteLine($"version={version}");
writer.WriteLine("packaging=jar");
writer.WriteLine("name=Sample");
}
private static void WriteManifest(ZipArchive archive, string artifactId, string version, string groupId)
{
var manifestEntry = archive.CreateEntry("META-INF/MANIFEST.MF");
using var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8);
writer.WriteLine("Manifest-Version: 1.0");
writer.WriteLine($"Implementation-Title: {artifactId}");
writer.WriteLine($"Implementation-Version: {version}");
writer.WriteLine($"Implementation-Vendor: {groupId}");
}
private static void CreateTextEntry(ZipArchive archive, string path, string? content = null)
{
var entry = archive.CreateEntry(path);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
if (!string.IsNullOrEmpty(content))
{
writer.Write(content);
}
}
private static void CreateBinaryEntry(ZipArchive archive, string path, string content)
{
var entry = archive.CreateEntry(path);
using var stream = entry.Open();
var bytes = Encoding.UTF8.GetBytes(content);
stream.Write(bytes, 0, bytes.Length);
}
private static string CreateSampleJar(string root, string groupId, string artifactId, string version)
{
var jarPath = Path.Combine(root, $"{artifactId}-{version}.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create);
var pomPropertiesPath = $"META-INF/maven/{groupId}/{artifactId}/pom.properties";

View File

@@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Serialization;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Tests verifying Notifier service can ingest scanner events per orchestrator-envelope.schema.json.
/// </summary>
public sealed class NotifierIngestionTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
[Fact]
public void NotifierMetadata_SerializesCorrectly()
{
var metadata = new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { "email", "slack" },
DigestEligible = false,
ImmediateDispatch = true,
Priority = "critical"
};
var orchestratorEvent = CreateTestEvent(metadata);
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.NotNull(node["notifier"]);
var notifierNode = node["notifier"]!.AsObject();
Assert.True(notifierNode["severityThresholdMet"]?.GetValue<bool>());
Assert.False(notifierNode["digestEligible"]?.GetValue<bool>());
Assert.True(notifierNode["immediateDispatch"]?.GetValue<bool>());
Assert.Equal("critical", notifierNode["priority"]?.GetValue<string>());
var channels = notifierNode["notificationChannels"]?.AsArray();
Assert.NotNull(channels);
Assert.Equal(2, channels.Count);
Assert.Contains("email", channels.Select(c => c?.GetValue<string>()));
Assert.Contains("slack", channels.Select(c => c?.GetValue<string>()));
}
[Fact]
public void NotifierMetadata_OmittedWhenNull()
{
var orchestratorEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.UtcNow,
Source = "scanner.webservice",
IdempotencyKey = "test-key",
Payload = new ReportReadyEventPayload
{
ReportId = "report-123",
ImageDigest = "sha256:abc123",
GeneratedAt = DateTimeOffset.UtcNow,
Verdict = "pass",
Summary = new ReportSummaryDto(),
Policy = new ReportPolicyDto(),
Links = new ReportLinksPayload(),
Report = new ReportDocumentDto()
},
Notifier = null // Explicitly null
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Null(node["notifier"]); // Should be omitted when null
}
[Theory]
[InlineData("critical", true, true)]
[InlineData("high", true, false)]
[InlineData("medium", false, false)]
[InlineData("low", false, false)]
public void NotifierMetadata_SeverityThresholdCalculation(string severity, bool expectedThresholdMet, bool expectedImmediate)
{
var metadata = CreateNotifierMetadataForSeverity(severity);
Assert.Equal(expectedThresholdMet, metadata.SeverityThresholdMet);
Assert.Equal(expectedImmediate, metadata.ImmediateDispatch);
}
[Fact]
public void ScanStartedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanStarted,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Source = "scanner.webservice",
IdempotencyKey = "scanner.event.scan.started:test-tenant:scan-001",
Payload = new ScanStartedEventPayload
{
ScanId = "scan-001",
JobId = "job-001",
Target = new ScanTargetPayload
{
Type = "container_image",
Identifier = "registry.example/app:v1.0.0",
Digest = "sha256:abc123def456"
},
StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Status = "started"
},
Notifier = new NotifierIngestionMetadata
{
SeverityThresholdMet = false,
DigestEligible = true,
ImmediateDispatch = false
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerScanStarted, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
Assert.Equal("scan-001", payload["scanId"]?.GetValue<string>());
Assert.Equal("started", payload["status"]?.GetValue<string>());
var target = payload["target"]?.AsObject();
Assert.NotNull(target);
Assert.Equal("container_image", target["type"]?.GetValue<string>());
}
[Fact]
public void ScanFailedEvent_SerializesWithErrorDetails()
{
var orchestratorEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanFailed,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"),
Source = "scanner.webservice",
IdempotencyKey = "scanner.event.scan.failed:test-tenant:scan-002",
Payload = new ScanFailedEventPayload
{
ScanId = "scan-002",
Target = new ScanTargetPayload
{
Type = "container_image",
Identifier = "registry.example/broken:latest"
},
StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
FailedAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"),
DurationMs = 300000,
Status = "failed",
Error = new ScanErrorPayload
{
Code = "IMAGE_PULL_FAILED",
Message = "Unable to pull image: authentication required",
Details = ImmutableDictionary.CreateRange(new[]
{
KeyValuePair.Create("registry", "registry.example"),
KeyValuePair.Create("httpStatus", "401")
}),
Recoverable = true
}
},
Notifier = new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { "email", "slack", "pagerduty" },
DigestEligible = false,
ImmediateDispatch = true,
Priority = "high"
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerScanFailed, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
Assert.Equal("failed", payload["status"]?.GetValue<string>());
Assert.Equal(300000, payload["durationMs"]?.GetValue<long>());
var error = payload["error"]?.AsObject();
Assert.NotNull(error);
Assert.Equal("IMAGE_PULL_FAILED", error["code"]?.GetValue<string>());
Assert.True(error["recoverable"]?.GetValue<bool>());
var notifier = node["notifier"]?.AsObject();
Assert.NotNull(notifier);
Assert.True(notifier["immediateDispatch"]?.GetValue<bool>());
}
[Fact]
public void VulnerabilityDetectedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerVulnerabilityDetected,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Source = "scanner.webservice",
IdempotencyKey = "scanner.event.vulnerability.detected:test-tenant:CVE-2024-9999:pkg:npm/lodash@4.17.20",
Payload = new VulnerabilityDetectedEventPayload
{
ScanId = "scan-001",
Vulnerability = new VulnerabilityInfoPayload
{
Id = "CVE-2024-9999",
Severity = "critical",
CvssScore = 9.8,
CvssVector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
Title = "Remote Code Execution in lodash",
FixAvailable = true,
FixedVersion = "4.17.21",
KevListed = true,
EpssScore = 0.95
},
AffectedComponent = new ComponentInfoPayload
{
Purl = "pkg:npm/lodash@4.17.20",
Name = "lodash",
Version = "4.17.20",
Ecosystem = "npm",
Location = "/app/node_modules/lodash"
},
Reachability = "reachable",
DetectedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z")
},
Notifier = new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { "email", "slack", "pagerduty" },
DigestEligible = false,
ImmediateDispatch = true,
Priority = "critical"
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerVulnerabilityDetected, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
var vuln = payload["vulnerability"]?.AsObject();
Assert.NotNull(vuln);
Assert.Equal("CVE-2024-9999", vuln["id"]?.GetValue<string>());
Assert.Equal("critical", vuln["severity"]?.GetValue<string>());
Assert.Equal(9.8, vuln["cvssScore"]?.GetValue<double>());
Assert.True(vuln["kevListed"]?.GetValue<bool>());
var component = payload["affectedComponent"]?.AsObject();
Assert.NotNull(component);
Assert.Equal("pkg:npm/lodash@4.17.20", component["purl"]?.GetValue<string>());
Assert.Equal("reachable", payload["reachability"]?.GetValue<string>());
}
[Fact]
public void SbomGeneratedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerSbomGenerated,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Source = "scanner.webservice",
IdempotencyKey = "scanner.event.sbom.generated:test-tenant:sbom-001",
Payload = new SbomGeneratedEventPayload
{
ScanId = "scan-001",
SbomId = "sbom-001",
Target = new ScanTargetPayload
{
Type = "container_image",
Identifier = "registry.example/app:v1.0.0",
Digest = "sha256:abc123def456"
},
GeneratedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Format = "cyclonedx",
SpecVersion = "1.6",
ComponentCount = 127,
SbomRef = "s3://sboms/sbom-001.json",
Digest = "sha256:sbom-digest-789"
},
Notifier = new NotifierIngestionMetadata
{
SeverityThresholdMet = false,
DigestEligible = true,
ImmediateDispatch = false
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerSbomGenerated, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
Assert.Equal("sbom-001", payload["sbomId"]?.GetValue<string>());
Assert.Equal("cyclonedx", payload["format"]?.GetValue<string>());
Assert.Equal("1.6", payload["specVersion"]?.GetValue<string>());
Assert.Equal(127, payload["componentCount"]?.GetValue<int>());
}
[Fact]
public void AllEventKinds_HaveCorrectFormat()
{
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerReportReady);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanCompleted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanStarted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanFailed);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerSbomGenerated);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerVulnerabilityDetected);
}
[Fact]
public void NotifierChannels_SupportAllChannelTypes()
{
var validChannels = new[] { "email", "slack", "teams", "webhook", "pagerduty" };
foreach (var channel in validChannels)
{
var metadata = new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { channel },
DigestEligible = true,
ImmediateDispatch = false
};
var orchestratorEvent = CreateTestEvent(metadata);
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
var notifier = node["notifier"]?.AsObject();
Assert.NotNull(notifier);
var channels = notifier["notificationChannels"]?.AsArray();
Assert.NotNull(channels);
Assert.Contains(channel, channels.Select(c => c?.GetValue<string>()));
}
}
private static OrchestratorEvent CreateTestEvent(NotifierIngestionMetadata? notifier)
{
return new OrchestratorEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.UtcNow,
Source = "scanner.webservice",
IdempotencyKey = "test-key",
Payload = new ReportReadyEventPayload
{
ReportId = "report-123",
ImageDigest = "sha256:abc123",
GeneratedAt = DateTimeOffset.UtcNow,
Verdict = "pass",
Summary = new ReportSummaryDto(),
Policy = new ReportPolicyDto(),
Links = new ReportLinksPayload(),
Report = new ReportDocumentDto()
},
Notifier = notifier
};
}
private static NotifierIngestionMetadata CreateNotifierMetadataForSeverity(string severity)
{
return severity.ToLowerInvariant() switch
{
"critical" => new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { "email", "slack", "pagerduty" },
DigestEligible = false,
ImmediateDispatch = true,
Priority = "critical"
},
"high" => new NotifierIngestionMetadata
{
SeverityThresholdMet = true,
NotificationChannels = new[] { "email", "slack" },
DigestEligible = false,
ImmediateDispatch = false,
Priority = "high"
},
_ => new NotifierIngestionMetadata
{
SeverityThresholdMet = false,
DigestEligible = true,
ImmediateDispatch = false,
Priority = "normal"
}
};
}
}

View File

@@ -4,7 +4,8 @@
| --- | --- | --- |
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
| WEB-CONSOLE-23-003 | DOING (2025-12-07) | Exports client/store/service + models shipped; Karma specs green via Playwright Chromium headless (`CHROME_BIN=C:\Users\vlindos\AppData\Local\ms-playwright\chromium-1194\chrome-win\chrome.exe`, `NG_PERSISTENT_BUILD_CACHE=1`); backend manifest/limits awaiting Policy. |
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests cant run; waiting on stable install environment and gateway endpoints to validate. |
| WEB-EXC-25-001 | BLOCKED (2025-12-06) | Pending exception schema + policy scopes/audit rules; cannot wire CRUD until contracts land. |
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |

View File

@@ -11,6 +11,8 @@ using StellaOps.Cryptography.Plugin.CryptoPro;
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
using StellaOps.Cryptography.Plugin.OpenSslGost;
using StellaOps.Cryptography.Plugin.SmSoft;
using StellaOps.Cryptography.Plugin.PqSoft;
using StellaOps.Cryptography.Plugin.WineCsp;
namespace StellaOps.Cryptography.DependencyInjection;
@@ -68,6 +70,10 @@ public static class CryptoServiceCollectionExtensions
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.TryAddSingleton<ICryptoHmac, DefaultCryptoHmac>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SmSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, PqSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, FipsSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, EidasSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KcmvpHashOnlyProvider>());
services.TryAddSingleton<ICryptoProviderRegistry>(sp =>
{
@@ -152,10 +158,12 @@ public static class CryptoServiceCollectionExtensions
#endif
services.Configure<Pkcs11GostProviderOptions>(baseSection.GetSection("Pkcs11"));
services.Configure<OpenSslGostProviderOptions>(baseSection.GetSection("OpenSsl"));
services.Configure<WineCspProviderOptions>(baseSection.GetSection("WineCsp"));
services.AddStellaOpsCrypto(configureRegistry);
services.AddOpenSslGostProvider();
services.AddPkcs11GostProvider();
services.AddWineCspProvider();
#if STELLAOPS_CRYPTO_PRO
if (OperatingSystem.IsWindows())
{
@@ -178,6 +186,7 @@ public static class CryptoServiceCollectionExtensions
{
InsertIfMissing(providers, "ru.pkcs11");
InsertIfMissing(providers, "ru.openssl.gost");
InsertIfMissing(providers, "ru.winecsp.http");
#if STELLAOPS_CRYPTO_PRO
if (OperatingSystem.IsWindows())
{

View File

@@ -13,6 +13,8 @@
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />

View File

@@ -0,0 +1,436 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Pqc.Crypto.Crystals.Dilithium;
using Org.BouncyCastle.Pqc.Crypto.Falcon;
using Org.BouncyCastle.Crypto.Prng;
using Org.BouncyCastle.Crypto.Digests;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.PqSoft;
/// <summary>
/// Software-only post-quantum provider (Dilithium3, Falcon512) using BouncyCastle PQC primitives.
/// Guarded by the <c>PQ_SOFT_ALLOWED</c> environment variable by default.
/// </summary>
public sealed class PqSoftCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
{
private const string EnvGate = "PQ_SOFT_ALLOWED";
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Dilithium3,
SignatureAlgorithms.Falcon512
};
private readonly ConcurrentDictionary<string, PqKeyEntry> entries = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<PqSoftCryptoProvider> logger;
private readonly PqSoftProviderOptions options;
public PqSoftCryptoProvider(
IOptions<PqSoftProviderOptions>? optionsAccessor = null,
ILogger<PqSoftCryptoProvider>? logger = null)
{
options = optionsAccessor?.Value ?? new PqSoftProviderOptions();
this.logger = logger ?? NullLogger<PqSoftCryptoProvider>.Instance;
foreach (var key in options.Keys)
{
TryLoadKeyFromFile(key);
}
}
public string Name => "pq.soft";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (!GateEnabled() || string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("PQ provider does not expose password hashing.");
public ICryptoHasher GetHasher(string algorithmId)
=> throw new NotSupportedException("PQ provider does not expose hashing.");
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
EnsureAllowed();
ArgumentNullException.ThrowIfNull(keyReference);
if (!SupportedAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
if (!entries.TryGetValue(keyReference.KeyId, out var entry))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
if (!string.Equals(entry.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.AlgorithmId}', not '{algorithmId}'.");
}
return entry.CreateSigner();
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
EnsureAllowed();
ArgumentNullException.ThrowIfNull(signingKey);
var normalizedAlg = Normalize(signingKey.AlgorithmId);
if (!SupportedAlgorithms.Contains(normalizedAlg))
{
throw new InvalidOperationException($"Signing algorithm '{normalizedAlg}' is not supported by provider '{Name}'.");
}
if (signingKey.PrivateKey.IsEmpty)
{
throw new InvalidOperationException("PQ provider requires raw private key bytes.");
}
var entry = normalizedAlg switch
{
SignatureAlgorithms.Dilithium3 => CreateDilithiumEntry(signingKey),
SignatureAlgorithms.Falcon512 => CreateFalconEntry(signingKey),
_ => throw new InvalidOperationException($"Unsupported PQ algorithm '{normalizedAlg}'.")
};
entries.AddOrUpdate(signingKey.Reference.KeyId, entry, (_, _) => entry);
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return entries.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> entries.Values.Select(static e => e.Descriptor).ToArray();
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
{
foreach (var entry in entries.Values)
{
yield return new CryptoProviderKeyDescriptor(
Name,
entry.Descriptor.Reference.KeyId,
entry.AlgorithmId,
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["provider"] = Name,
["algorithm"] = entry.AlgorithmId,
["certified"] = "false",
["simulation"] = "software"
});
}
}
private bool GateEnabled()
{
if (!options.RequireEnvironmentGate)
{
return true;
}
var value = Environment.GetEnvironmentVariable(EnvGate);
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private void EnsureAllowed()
{
if (!GateEnabled())
{
throw new InvalidOperationException($"Provider '{Name}' is disabled. Set {EnvGate}=1 or disable RequireEnvironmentGate to enable.");
}
}
private void TryLoadKeyFromFile(PqSoftKeyOptions key)
{
if (string.IsNullOrWhiteSpace(key.KeyId) || string.IsNullOrWhiteSpace(key.PrivateKeyPath))
{
return;
}
try
{
var priv = File.ReadAllBytes(key.PrivateKeyPath);
var pub = string.IsNullOrWhiteSpace(key.PublicKeyPath) ? Array.Empty<byte>() : File.ReadAllBytes(key.PublicKeyPath);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference(key.KeyId, Name),
key.Algorithm,
priv,
DateTimeOffset.UtcNow,
metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["source"] = "file",
["path"] = key.PrivateKeyPath
},
publicKey: pub);
UpsertSigningKey(signingKey);
logger.LogInformation("Loaded PQ key {KeyId} for algorithm {Algorithm}", key.KeyId, key.Algorithm);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load PQ key {KeyId} from {Path}", key.KeyId, key.PrivateKeyPath);
}
}
private static string Normalize(string algorithmId) => algorithmId.ToUpperInvariant();
private static PqKeyEntry CreateDilithiumEntry(CryptoSigningKey signingKey)
{
var parameters = DilithiumParameters.Dilithium3;
if (!signingKey.PublicKey.IsEmpty)
{
var pubFromBytes = new DilithiumPublicKeyParameters(parameters, signingKey.PublicKey.ToArray());
var privFromBytes = new DilithiumPrivateKeyParameters(parameters, signingKey.PrivateKey.ToArray(), pubFromBytes);
var descriptorFromBytes = new CryptoSigningKey(
signingKey.Reference,
SignatureAlgorithms.Dilithium3,
privFromBytes.GetEncoded(),
signingKey.CreatedAt,
signingKey.ExpiresAt,
pubFromBytes.GetEncoded(),
signingKey.Metadata);
return new DilithiumKeyEntry(descriptorFromBytes, privFromBytes, pubFromBytes);
}
var random = CreateSeededRandom(signingKey.PrivateKey);
var generator = new DilithiumKeyPairGenerator();
generator.Init(new DilithiumKeyGenerationParameters(random, parameters));
var pair = generator.GenerateKeyPair();
var priv = (DilithiumPrivateKeyParameters)pair.Private;
var pub = (DilithiumPublicKeyParameters)pair.Public;
var descriptor = new CryptoSigningKey(
signingKey.Reference,
SignatureAlgorithms.Dilithium3,
priv.GetEncoded(),
signingKey.CreatedAt,
signingKey.ExpiresAt,
pub.GetEncoded(),
signingKey.Metadata);
return new DilithiumKeyEntry(descriptor, priv, pub);
}
private static PqKeyEntry CreateFalconEntry(CryptoSigningKey signingKey)
{
var parameters = FalconParameters.falcon_512;
var random = CreateSeededRandom(signingKey.PrivateKey);
var generator = new FalconKeyPairGenerator();
generator.Init(new FalconKeyGenerationParameters(random, parameters));
var pair = generator.GenerateKeyPair();
var priv = (FalconPrivateKeyParameters)pair.Private;
var pub = (FalconPublicKeyParameters)pair.Public;
var descriptor = new CryptoSigningKey(
signingKey.Reference,
SignatureAlgorithms.Falcon512,
priv.GetEncoded(),
signingKey.CreatedAt,
signingKey.ExpiresAt,
pub.GetEncoded(),
signingKey.Metadata);
return new FalconKeyEntry(descriptor, priv, pub);
}
private static SecureRandom CreateSeededRandom(ReadOnlyMemory<byte> seed)
{
var generator = new DigestRandomGenerator(new Sha512Digest());
generator.AddSeedMaterial(seed.ToArray());
return new SecureRandom(generator);
}
}
/// <summary>
/// Options for the PQ soft provider.
/// </summary>
public sealed class PqSoftProviderOptions
{
public bool RequireEnvironmentGate { get; set; } = true;
public List<PqSoftKeyOptions> Keys { get; set; } = new();
}
/// <summary>
/// Key configuration for the PQ soft provider.
/// </summary>
public sealed class PqSoftKeyOptions
{
public required string KeyId { get; set; }
= string.Empty;
public required string Algorithm { get; set; }
= SignatureAlgorithms.Dilithium3;
public string? PrivateKeyPath { get; set; }
= string.Empty;
public string? PublicKeyPath { get; set; }
= string.Empty;
}
internal abstract record PqKeyEntry(CryptoSigningKey Descriptor, string AlgorithmId)
{
public abstract ICryptoSigner CreateSigner();
}
internal sealed record DilithiumKeyEntry(
CryptoSigningKey Descriptor,
DilithiumPrivateKeyParameters PrivateKey,
DilithiumPublicKeyParameters PublicKey)
: PqKeyEntry(Descriptor, SignatureAlgorithms.Dilithium3)
{
public override ICryptoSigner CreateSigner() => new DilithiumSignerWrapper(Descriptor.Reference.KeyId, PrivateKey, PublicKey);
}
internal sealed record FalconKeyEntry(
CryptoSigningKey Descriptor,
FalconPrivateKeyParameters PrivateKey,
FalconPublicKeyParameters PublicKey)
: PqKeyEntry(Descriptor, SignatureAlgorithms.Falcon512)
{
public override ICryptoSigner CreateSigner() => new FalconSignerWrapper(Descriptor.Reference.KeyId, PrivateKey, PublicKey);
}
internal sealed class DilithiumSignerWrapper : ICryptoSigner
{
private readonly string keyId;
private readonly DilithiumPrivateKeyParameters privateKey;
private readonly DilithiumPublicKeyParameters publicKey;
public DilithiumSignerWrapper(string keyId, DilithiumPrivateKeyParameters privateKey, DilithiumPublicKeyParameters publicKey)
{
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKey = publicKey;
}
public string KeyId => keyId;
public string AlgorithmId => SignatureAlgorithms.Dilithium3;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var signer = new DilithiumSigner();
signer.Init(true, privateKey);
return ValueTask.FromResult(signer.GenerateSignature(data.ToArray()));
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var verifier = new DilithiumSigner();
verifier.Init(false, publicKey);
var ok = verifier.VerifySignature(data.ToArray(), signature.ToArray());
return ValueTask.FromResult(ok);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = keyId,
Alg = AlgorithmId,
Kty = JsonWebAlgorithmsKeyTypes.Octet, // PQ JWK mapping not standard; encode as opaque octet key
Use = JsonWebKeyUseNames.Sig,
Crv = "Dilithium3"
};
jwk.KeyOps.Add("sign");
jwk.KeyOps.Add("verify");
jwk.X = Base64UrlEncoder.Encode(publicKey.GetEncoded());
return jwk;
}
}
internal sealed class FalconSignerWrapper : ICryptoSigner
{
private readonly string keyId;
private readonly FalconPrivateKeyParameters privateKey;
private readonly FalconPublicKeyParameters publicKey;
public FalconSignerWrapper(string keyId, FalconPrivateKeyParameters privateKey, FalconPublicKeyParameters publicKey)
{
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKey = publicKey;
}
public string KeyId => keyId;
public string AlgorithmId => SignatureAlgorithms.Falcon512;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var signer = new FalconSigner();
signer.Init(true, privateKey);
return ValueTask.FromResult(signer.GenerateSignature(data.ToArray()));
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var verifier = new FalconSigner();
verifier.Init(false, publicKey);
var ok = verifier.VerifySignature(data.ToArray(), signature.ToArray());
return ValueTask.FromResult(ok);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = keyId,
Alg = AlgorithmId,
Kty = JsonWebAlgorithmsKeyTypes.Octet,
Use = JsonWebKeyUseNames.Sig,
Crv = "Falcon512"
};
jwk.KeyOps.Add("sign");
jwk.KeyOps.Add("verify");
jwk.X = Base64UrlEncoder.Encode(publicKey.GetEncoded());
return jwk;
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -15,12 +15,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
</ItemGroup>
</Project>

View File

@@ -124,13 +124,13 @@ public sealed class WineCspHttpProvider : ICryptoProvider, ICryptoProviderDiagno
ArgumentNullException.ThrowIfNull(signingKey);
var entry = new WineCspKeyEntry(
signingKey.KeyId,
signingKey.Algorithm,
signingKey.KeyId,
signingKey.Reference.KeyId,
signingKey.AlgorithmId,
signingKey.Reference.KeyId,
null);
entries[signingKey.KeyId] = entry;
logger?.LogDebug("Registered Wine CSP key reference: {KeyId}", signingKey.KeyId);
entries[signingKey.Reference.KeyId] = entry;
logger?.LogDebug("Registered Wine CSP key reference: {KeyId}", signingKey.Reference.KeyId);
}
public bool RemoveSigningKey(string keyId)

View File

@@ -0,0 +1,73 @@
using System;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class PolicyProvidersTests
{
[Fact]
public async Task FipsSoft_Signs_And_Verifies_Es256()
{
Environment.SetEnvironmentVariable("FIPS_SOFT_ALLOWED", "1");
var provider = new FipsSoftCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var key = new CryptoSigningKey(
new CryptoKeyReference("fips-es256"),
SignatureAlgorithms.Es256,
ecdsa.ExportParameters(true),
DateTimeOffset.UtcNow);
provider.UpsertSigningKey(key);
var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference("fips-es256"));
var data = Encoding.UTF8.GetBytes("fips-soft-provider");
var signature = await signer.SignAsync(data);
(await signer.VerifyAsync(data, signature)).Should().BeTrue();
provider.GetHasher(HashAlgorithms.Sha256).ComputeHash(data).Length.Should().Be(32);
}
[Fact]
public async Task EidasSoft_Signs_And_Verifies_Es384()
{
Environment.SetEnvironmentVariable("EIDAS_SOFT_ALLOWED", "1");
var provider = new EidasSoftCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP384);
var key = new CryptoSigningKey(
new CryptoKeyReference("eidas-es384"),
SignatureAlgorithms.Es384,
ecdsa.ExportParameters(true),
DateTimeOffset.UtcNow);
provider.UpsertSigningKey(key);
var signer = provider.GetSigner(SignatureAlgorithms.Es384, new CryptoKeyReference("eidas-es384"));
var data = Encoding.UTF8.GetBytes("eidas-soft-provider");
var signature = await signer.SignAsync(data);
(await signer.VerifyAsync(data, signature)).Should().BeTrue();
provider.GetHasher(HashAlgorithms.Sha384).ComputeHash(data).Length.Should().Be(48);
}
[Fact]
public void KcmvpHashOnly_Computes_Hash()
{
Environment.SetEnvironmentVariable("KCMVP_HASH_ALLOWED", "1");
var provider = new KcmvpHashOnlyProvider();
var data = Encoding.UTF8.GetBytes("kcmvp-hash-only");
provider.Supports(CryptoCapability.ContentHashing, HashAlgorithms.Sha256).Should().BeTrue();
var digest = provider.GetHasher(HashAlgorithms.Sha256).ComputeHash(data);
digest.Length.Should().Be(32);
provider.Invoking(p => p.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference("none")))
.Should().Throw<NotSupportedException>();
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Pqc.Crypto.Crystals.Dilithium;
using Org.BouncyCastle.Pqc.Crypto.Falcon;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.PqSoft;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class PqSoftCryptoProviderTests
{
[Fact]
public async Task Dilithium3_Signs_And_Verifies()
{
var provider = CreateProvider();
var generator = new DilithiumKeyPairGenerator();
generator.Init(new DilithiumKeyGenerationParameters(new SecureRandom(), DilithiumParameters.Dilithium3));
var keyPair = generator.GenerateKeyPair();
var priv = ((DilithiumPrivateKeyParameters)keyPair.Private).GetEncoded();
var pub = ((DilithiumPublicKeyParameters)keyPair.Public).GetEncoded();
provider.UpsertSigningKey(new CryptoSigningKey(
new CryptoKeyReference("pq-dil3"),
SignatureAlgorithms.Dilithium3,
priv,
DateTimeOffset.UtcNow,
publicKey: pub));
var signer = provider.GetSigner(SignatureAlgorithms.Dilithium3, new CryptoKeyReference("pq-dil3"));
var data = Encoding.UTF8.GetBytes("dilithium-soft");
var signature = await signer.SignAsync(data);
(await signer.VerifyAsync(data, signature)).Should().BeTrue();
}
[Fact]
public async Task Falcon512_Signs_And_Verifies()
{
var provider = CreateProvider();
var generator = new FalconKeyPairGenerator();
generator.Init(new FalconKeyGenerationParameters(new SecureRandom(), FalconParameters.falcon_512));
var keyPair = generator.GenerateKeyPair();
var priv = ((FalconPrivateKeyParameters)keyPair.Private).GetEncoded();
var pub = ((FalconPublicKeyParameters)keyPair.Public).GetEncoded();
provider.UpsertSigningKey(new CryptoSigningKey(
new CryptoKeyReference("pq-falcon"),
SignatureAlgorithms.Falcon512,
priv,
DateTimeOffset.UtcNow,
publicKey: pub));
var signer = provider.GetSigner(SignatureAlgorithms.Falcon512, new CryptoKeyReference("pq-falcon"));
var data = Encoding.UTF8.GetBytes("falcon-soft");
var signature = await signer.SignAsync(data);
(await signer.VerifyAsync(data, signature)).Should().BeTrue();
}
private static PqSoftCryptoProvider CreateProvider()
{
var options = Options.Create(new PqSoftProviderOptions
{
RequireEnvironmentGate = false
});
return new PqSoftCryptoProvider(options);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Cryptography;
/// <summary>
/// EC signing provider with an explicit allow-list for compliance profiles (FIPS/eIDAS).
/// </summary>
public class EcdsaPolicyCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
{
private readonly string name;
private readonly HashSet<string> signingAlgorithms;
private readonly HashSet<string> hashAlgorithms;
private readonly string? gateEnv;
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys = new(StringComparer.OrdinalIgnoreCase);
public EcdsaPolicyCryptoProvider(
string name,
IEnumerable<string> signingAlgorithms,
IEnumerable<string> hashAlgorithms,
string? gateEnv = null)
{
this.name = name ?? throw new ArgumentNullException(nameof(name));
this.signingAlgorithms = new HashSet<string>(signingAlgorithms ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
this.hashAlgorithms = new HashSet<string>(hashAlgorithms ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
this.gateEnv = string.IsNullOrWhiteSpace(gateEnv) ? null : gateEnv;
if (this.signingAlgorithms.Count == 0)
{
throw new ArgumentException("At least one signing algorithm must be supplied.", nameof(signingAlgorithms));
}
if (this.hashAlgorithms.Count == 0)
{
throw new ArgumentException("At least one hash algorithm must be supplied.", nameof(hashAlgorithms));
}
}
public string Name => name;
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId) || !GateEnabled())
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => signingAlgorithms.Contains(algorithmId),
CryptoCapability.ContentHashing => hashAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException($"Provider '{Name}' does not expose password hashing.");
public ICryptoHasher GetHasher(string algorithmId)
{
EnsureHashSupported(algorithmId);
return new DefaultCryptoHasher(NormalizeHash(algorithmId));
}
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
EnsureSigningSupported(algorithmId);
ArgumentNullException.ThrowIfNull(keyReference);
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
if (!string.Equals(signingKey.AlgorithmId, NormalizeAlg(algorithmId), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
}
return EcdsaSigner.Create(signingKey);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
EnsureSigningSupported(signingKey?.AlgorithmId ?? string.Empty);
ArgumentNullException.ThrowIfNull(signingKey);
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
{
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
}
ValidateCurve(signingKey.AlgorithmId, signingKey.PrivateParameters);
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.ToArray();
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
{
foreach (var key in signingKeys.Values)
{
yield return new CryptoProviderKeyDescriptor(
Name,
key.Reference.KeyId,
key.AlgorithmId,
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["curve"] = ResolveCurve(key.AlgorithmId),
["profile"] = Name,
["certified"] = "false"
});
}
}
private bool GateEnabled()
{
if (gateEnv is null)
{
return true;
}
var value = Environment.GetEnvironmentVariable(gateEnv);
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private void EnsureSigningSupported(string algorithmId)
{
if (!Supports(CryptoCapability.Signing, algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
}
private void EnsureHashSupported(string algorithmId)
{
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
{
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
}
private static string NormalizeAlg(string algorithmId) => algorithmId.ToUpperInvariant();
private static string NormalizeHash(string algorithmId) => algorithmId.ToUpperInvariant();
private static void ValidateCurve(string algorithmId, ECParameters parameters)
{
var expectedCurve = ResolveCurve(algorithmId);
var oid = parameters.Curve.Oid?.Value ?? string.Empty;
var matches = expectedCurve switch
{
JsonWebKeyECTypes.P256 => string.Equals(oid, ECCurve.NamedCurves.nistP256.Oid.Value, StringComparison.Ordinal),
JsonWebKeyECTypes.P384 => string.Equals(oid, ECCurve.NamedCurves.nistP384.Oid.Value, StringComparison.Ordinal),
JsonWebKeyECTypes.P521 => string.Equals(oid, ECCurve.NamedCurves.nistP521.Oid.Value, StringComparison.Ordinal),
_ => false
};
if (!matches)
{
throw new InvalidOperationException($"Signing key curve mismatch. Expected curve '{expectedCurve}' for algorithm '{algorithmId}'.");
}
}
private static string ResolveCurve(string algorithmId)
=> algorithmId.ToUpperInvariant() switch
{
SignatureAlgorithms.Es256 => JsonWebKeyECTypes.P256,
SignatureAlgorithms.Es384 => JsonWebKeyECTypes.P384,
SignatureAlgorithms.Es512 => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported ECDSA curve mapping for algorithm '{algorithmId}'.")
};
}
/// <summary>
/// FIPS-compatible ECDSA provider (software-only, non-certified).
/// </summary>
public sealed class FipsSoftCryptoProvider : EcdsaPolicyCryptoProvider
{
public FipsSoftCryptoProvider()
: base(
name: "fips.ecdsa.soft",
signingAlgorithms: new[] { SignatureAlgorithms.Es256, SignatureAlgorithms.Es384, SignatureAlgorithms.Es512 },
hashAlgorithms: new[] { HashAlgorithms.Sha256, HashAlgorithms.Sha384, HashAlgorithms.Sha512 },
gateEnv: "FIPS_SOFT_ALLOWED")
{
}
}
/// <summary>
/// eIDAS-compatible ECDSA provider (software-only, non-certified, QSCD not enforced).
/// </summary>
public sealed class EidasSoftCryptoProvider : EcdsaPolicyCryptoProvider
{
public EidasSoftCryptoProvider()
: base(
name: "eu.eidas.soft",
signingAlgorithms: new[] { SignatureAlgorithms.Es256, SignatureAlgorithms.Es384 },
hashAlgorithms: new[] { HashAlgorithms.Sha256, HashAlgorithms.Sha384 },
gateEnv: "EIDAS_SOFT_ALLOWED")
{
}
}
/// <summary>
/// Hash-only provider for KCMVP baseline (software-only, non-certified).
/// </summary>
public sealed class KcmvpHashOnlyProvider : ICryptoProvider
{
private const string GateEnv = "KCMVP_HASH_ALLOWED";
public string Name => "kr.kcmvp.hash";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (!GateEnabled())
{
return false;
}
return capability == CryptoCapability.ContentHashing &&
string.Equals(algorithmId, HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase);
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("KCMVP hash provider does not expose password hashing.");
public ICryptoHasher GetHasher(string algorithmId)
{
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
{
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return new DefaultCryptoHasher(HashAlgorithms.Sha256);
}
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
=> throw new NotSupportedException("KCMVP hash-only provider does not expose signing.");
public void UpsertSigningKey(CryptoSigningKey signingKey)
=> throw new NotSupportedException("KCMVP hash-only provider does not manage signing keys.");
public bool RemoveSigningKey(string keyId) => false;
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>();
private static bool GateEnabled()
{
var value = Environment.GetEnvironmentVariable(GateEnv);
return string.IsNullOrEmpty(value) ||
string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -13,4 +13,6 @@ public static class SignatureAlgorithms
public const string GostR3410_2012_256 = "GOST12-256";
public const string GostR3410_2012_512 = "GOST12-512";
public const string Sm2 = "SM2";
public const string Dilithium3 = "DILITHIUM3";
public const string Falcon512 = "FALCON512";
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -18,8 +18,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-preview.7.24407.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
<!-- Reference the GostCryptography fork for CSP access -->