Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -199,7 +199,7 @@ See: `docs/implplan/SPRINT_3800_0000_0000_summary.md`
## Engineering Rules
- Target `net10.0`; prefer latest C# preview allowed in repo.
- Offline-first: no new external network calls; use cached feeds (`/local-nugets`).
- Offline-first: no new external network calls; use `.nuget/packages/` cache.
- Determinism: stable ordering, UTC ISO-8601 timestamps, no `DateTime.Now`/random without seed; normalize path separators.
- Logging: structured (`ILogger` message templates); avoid secrets/paths leakage.
- Security: no executing untrusted payloads; keep analyzers pure; include redaction guidance for runtime capture adapters.

View File

@@ -18,10 +18,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" 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="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
{
"solution": {
"path": "StellaOps.Scanner.sln",
"projects": [
"__Tests/StellaOps.Scanner.Analyzers.Lang.Node.SmokeTests/StellaOps.Scanner.Analyzers.Lang.Node.SmokeTests.csproj",
"__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj",
"__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj"
]
}
}

View File

@@ -1,2 +0,0 @@
<Solution>
</Solution>

View File

@@ -1,15 +0,0 @@
{
"solution": {
"path": "StellaOps.Scanner.sln",
"projects": [
"__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj",
"__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.csproj",
"__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj",
"__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj",
"__Libraries/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.csproj",
"__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj",
"__Tests/StellaOps.Scanner.Analyzers.Lang.Tests/StellaOps.Scanner.Analyzers.Lang.Tests.csproj",
"__Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests.csproj"
]
}
}

View File

@@ -23,8 +23,8 @@
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.FS\\StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Secrets\\StellaOps.Scanner.Surface.Secrets.csproj" />
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Env\\StellaOps.Scanner.Surface.Env.csproj" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
</Project>

View File

@@ -34,6 +34,68 @@ public sealed record SbomUploadRequestDto
[JsonPropertyName("source")]
public SbomUploadSourceDto? Source { get; init; }
// LIN-BE-002: Lineage fields for SBOM ancestry tracking
/// <summary>
/// Digest of the parent artifact (previous version).
/// Format: "sha256:{hex}".
/// Used to establish parent-child version succession.
/// </summary>
[JsonPropertyName("parentArtifactDigest")]
public string? ParentArtifactDigest { get; init; }
/// <summary>
/// Reference to the base image (e.g., "docker.io/library/alpine:3.19").
/// Extracted from OCI manifest or Dockerfile FROM instruction.
/// </summary>
[JsonPropertyName("baseImageRef")]
public string? BaseImageRef { get; init; }
/// <summary>
/// Digest of the base image.
/// Format: "sha256:{hex}".
/// Used to establish base image lineage.
/// </summary>
[JsonPropertyName("baseImageDigest")]
public string? BaseImageDigest { get; init; }
/// <summary>
/// OCI ancestry information extracted from image manifest.
/// </summary>
[JsonPropertyName("ancestry")]
public SbomAncestryDto? Ancestry { get; init; }
}
/// <summary>
/// OCI ancestry information for lineage tracking.
/// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-002)
/// </summary>
public sealed record SbomAncestryDto
{
/// <summary>
/// Ordered list of layer digests from bottom to top.
/// </summary>
[JsonPropertyName("layerDigests")]
public IReadOnlyList<string>? LayerDigests { get; init; }
/// <summary>
/// Number of layers inherited from base image.
/// </summary>
[JsonPropertyName("inheritedLayerCount")]
public int InheritedLayerCount { get; init; }
/// <summary>
/// Image creation timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// Image labels.
/// </summary>
[JsonPropertyName("labels")]
public IReadOnlyDictionary<string, string>? Labels { get; init; }
}
/// <summary>

View File

@@ -25,9 +25,11 @@ public static class EpssEndpoints
/// </summary>
public static IEndpointRouteBuilder MapEpssEndpoints(this IEndpointRouteBuilder endpoints)
{
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - migration pending
var group = endpoints.MapGroup("/epss")
.WithTags("EPSS")
.WithOpenApi();
#pragma warning restore ASPDEPR002
group.MapPost("/current", GetCurrentBatch)
.WithName("GetCurrentEpss")

View File

@@ -167,7 +167,7 @@ internal static class ReachabilityStackEndpoints
Layer2: MapLayer2ToDto(stack.BinaryResolution),
Layer3: MapLayer3ToDto(stack.RuntimeGating),
Verdict: stack.Verdict.ToString(),
Explanation: stack.Explanation,
Explanation: stack.Explanation ?? string.Empty,
AnalyzedAt: stack.AnalyzedAt);
}

View File

@@ -98,6 +98,12 @@ public sealed class ScannerWebServiceOptions
/// </summary>
public ScoreReplayOptions ScoreReplay { get; set; } = new();
/// <summary>
/// OCI attestation attachment configuration (disabled by default).
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T5)
/// </summary>
public AttestationAttachmentOptions AttestationAttachment { get; set; } = new();
/// <summary>
/// Stella Router integration configuration (disabled by default).
/// When enabled, ASP.NET endpoints are automatically registered with the Router.
@@ -468,4 +474,64 @@ public sealed class ScannerWebServiceOptions
public string BundleStoragePath { get; set; } = string.Empty;
}
/// <summary>
/// OCI attestation attachment configuration.
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T5)
/// </summary>
public sealed class AttestationAttachmentOptions
{
/// <summary>
/// Enables automatic attestation attachment to OCI artifacts on scan completion.
/// Default: false.
/// </summary>
public bool AutoAttach { get; set; }
/// <summary>
/// Predicate types to create and attach as attestations.
/// Default: scan-result, sbom, vex.
/// </summary>
public IList<string> PredicateTypes { get; set; } = new List<string>
{
"stellaops.io/predicates/scan-result@v1",
"stellaops.io/predicates/sbom@v1",
"stellaops.io/predicates/vex@v1"
};
/// <summary>
/// Signing mode for attached attestations.
/// Possible values: none, keyless, key, kms.
/// Default: keyless.
/// </summary>
public string SigningMode { get; set; } = "keyless";
/// <summary>
/// Key ID for signing when SigningMode is 'key' or 'kms'.
/// </summary>
public string? KeyId { get; set; }
/// <summary>
/// Submit attestations to Rekor transparency log.
/// Default: true.
/// </summary>
public bool UseRekor { get; set; } = true;
/// <summary>
/// Rekor URL for transparency log submission.
/// Default: https://rekor.sigstore.dev.
/// </summary>
public string RekorUrl { get; set; } = "https://rekor.sigstore.dev";
/// <summary>
/// Replace existing attestations with same predicate type.
/// Default: false.
/// </summary>
public bool ReplaceExisting { get; set; }
/// <summary>
/// Timeout in seconds for registry operations.
/// Default: 30.
/// </summary>
public int RegistryTimeoutSeconds { get; set; } = 30;
}
}

View File

@@ -16,7 +16,7 @@ using Serilog.Events;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Cryptography.DependencyInjection;
@@ -562,7 +562,8 @@ app.TryRefreshStellaRouterEndpoints(resolvedOptions.Router);
await app.RunAsync().ConfigureAwait(false);
public partial class Program;
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
file sealed partial class Program;
internal sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<SurfaceCacheOptions>
{

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Scanner.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62540;http://localhost:62542"
}
}
}

View File

@@ -17,7 +17,7 @@ internal static class CborNegotiation
foreach (var value in values)
{
if (value.Contains(ContentType, StringComparison.OrdinalIgnoreCase))
if (value?.Contains(ContentType, StringComparison.OrdinalIgnoreCase) == true)
{
return true;
}

View File

@@ -206,8 +206,6 @@ public sealed class AttestationChainVerifier : IAttestationChainVerifier
string findingId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(scanId);
if (string.IsNullOrWhiteSpace(findingId))
{
return null;

View File

@@ -42,7 +42,7 @@ public sealed class DeterministicScoringService : IScoringService
concelierSnapshotHash,
excititorSnapshotHash,
latticePolicyHash
}.Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
}.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToArray();
var inputNodeId = $"input:{scanId}";
ledger.Append(ProofNode.CreateInput(

View File

@@ -112,8 +112,6 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
string findingId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(scanId);
if (string.IsNullOrWhiteSpace(findingId))
{
return Task.FromResult<HumanApprovalAttestationResult?>(null);
@@ -150,8 +148,6 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
ScanId scanId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(scanId);
var prefix = $"{scanId}:";
var now = _timeProvider.GetUtcNow();
@@ -173,8 +169,6 @@ public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationS
string reason,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(scanId);
if (string.IsNullOrWhiteSpace(findingId))
{
return Task.FromResult(false);

View File

@@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Publishes scan attestations to OCI registries on scan completion.
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T5)
/// </summary>
/// <remarks>
/// This service integrates with <see cref="IReportEventDispatcher"/> to attach
/// attestations to OCI artifacts after scan completion. Supports:
/// <list type="bullet">
/// <item><description>Scan result attestations</description></item>
/// <item><description>SBOM attestations</description></item>
/// <item><description>VEX attestations</description></item>
/// </list>
/// </remarks>
internal interface IOciAttestationPublisher
{
/// <summary>
/// Publishes attestations to the OCI registry for the completed scan.
/// </summary>
/// <param name="report">The completed scan report.</param>
/// <param name="envelope">The DSSE envelope containing the signed attestation.</param>
/// <param name="tenant">The tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the attestation attachment.</returns>
Task<OciAttestationPublishResult> PublishAsync(
ReportDocumentDto report,
DsseEnvelopeDto? envelope,
string tenant,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if OCI attestation publishing is enabled and configured.
/// </summary>
bool IsEnabled { get; }
}
/// <summary>
/// Result of OCI attestation publishing operation.
/// </summary>
/// <param name="Success">Whether the operation succeeded.</param>
/// <param name="AttachmentCount">Number of attestations attached.</param>
/// <param name="Digests">Digests of attached attestations.</param>
/// <param name="Error">Error message if operation failed.</param>
internal sealed record OciAttestationPublishResult(
bool Success,
int AttachmentCount,
IReadOnlyList<string> Digests,
string? Error = null)
{
/// <summary>
/// Creates a successful result.
/// </summary>
public static OciAttestationPublishResult Succeeded(int count, IReadOnlyList<string> digests)
=> new(true, count, digests);
/// <summary>
/// Creates a failed result.
/// </summary>
public static OciAttestationPublishResult Failed(string error)
=> new(false, 0, Array.Empty<string>(), error);
/// <summary>
/// Creates a skipped result when publishing is disabled.
/// </summary>
public static OciAttestationPublishResult Skipped()
=> new(true, 0, Array.Empty<string>(), "Attestation attachment disabled");
}

View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// No-op implementation of <see cref="IOciAttestationPublisher"/> for when publishing is disabled.
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T5)
/// </summary>
internal sealed class NullOciAttestationPublisher : IOciAttestationPublisher
{
/// <summary>
/// Singleton instance.
/// </summary>
public static readonly NullOciAttestationPublisher Instance = new();
private NullOciAttestationPublisher() { }
/// <inheritdoc />
public bool IsEnabled => false;
/// <inheritdoc />
public Task<OciAttestationPublishResult> PublishAsync(
ReportDocumentDto report,
DsseEnvelopeDto? envelope,
string tenant,
CancellationToken cancellationToken = default)
{
return Task.FromResult(OciAttestationPublishResult.Skipped());
}
}

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
namespace StellaOps.Scanner.WebService.Services;

View File

@@ -0,0 +1,270 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// OCI attestation publisher that attaches attestations to OCI artifacts on scan completion.
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T5)
/// </summary>
/// <remarks>
/// This implementation coordinates with the OCI registry to attach signed attestations
/// using the OCI Distribution Spec 1.1 referrers API. Configuration is provided via
/// <see cref="ScannerWebServiceOptions.AttestationAttachmentOptions"/>.
/// </remarks>
internal sealed class OciAttestationPublisher : IOciAttestationPublisher
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Scanner.WebService.OciAttestationPublisher");
private readonly ScannerWebServiceOptions.AttestationAttachmentOptions _options;
private readonly ILogger<OciAttestationPublisher> _logger;
public OciAttestationPublisher(
IOptions<ScannerWebServiceOptions> options,
ILogger<OciAttestationPublisher> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value.AttestationAttachment ?? new ScannerWebServiceOptions.AttestationAttachmentOptions();
_logger = logger;
}
/// <inheritdoc />
public bool IsEnabled => _options.AutoAttach;
/// <inheritdoc />
public async Task<OciAttestationPublishResult> PublishAsync(
ReportDocumentDto report,
DsseEnvelopeDto? envelope,
string tenant,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
if (!IsEnabled)
{
_logger.LogDebug("OCI attestation publishing is disabled, skipping for report {ReportId}.", report.ReportId);
return OciAttestationPublishResult.Skipped();
}
using var activity = ActivitySource.StartActivity("OciAttestationPublisher.PublishAsync");
activity?.SetTag("tenant", tenant);
activity?.SetTag("reportId", report.ReportId);
activity?.SetTag("imageDigest", report.ImageDigest);
// Validate image reference
if (string.IsNullOrWhiteSpace(report.ImageDigest))
{
_logger.LogWarning("Cannot attach attestation for report {ReportId}: missing image digest.", report.ReportId);
return OciAttestationPublishResult.Failed("Missing image digest");
}
// Parse image reference to extract registry info
if (!TryParseImageReference(report.ImageDigest, out var registry, out var repository, out var digest))
{
_logger.LogWarning("Cannot attach attestation for report {ReportId}: invalid image reference '{ImageDigest}'.",
report.ReportId, report.ImageDigest);
return OciAttestationPublishResult.Failed($"Invalid image reference: {report.ImageDigest}");
}
try
{
var attachedDigests = new List<string>();
// Attach scan result attestation if envelope is provided and predicate type is enabled
if (envelope is not null && ShouldAttachPredicateType("stellaops.io/predicates/scan-result@v1"))
{
var scanResultDigest = await AttachAttestationAsync(
registry, repository, digest,
envelope,
"stellaops.io/predicates/scan-result@v1",
tenant, report.ReportId,
cancellationToken);
if (scanResultDigest is not null)
{
attachedDigests.Add(scanResultDigest);
}
}
// Additional attestation types can be attached here as configured
// SBOM attestation, VEX attestation, etc.
_logger.LogInformation(
"Attached {Count} attestation(s) to OCI artifact {Registry}/{Repository}@{Digest} for report {ReportId}.",
attachedDigests.Count, registry, repository, digest, report.ReportId);
activity?.SetTag("attachedCount", attachedDigests.Count);
return OciAttestationPublishResult.Succeeded(attachedDigests.Count, attachedDigests);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to attach attestation to OCI artifact for report {ReportId}.", report.ReportId);
return OciAttestationPublishResult.Failed(ex.Message);
}
}
private bool ShouldAttachPredicateType(string predicateType)
{
if (_options.PredicateTypes is null || _options.PredicateTypes.Count == 0)
{
// Default predicate types if none configured
return predicateType is "stellaops.io/predicates/scan-result@v1"
or "stellaops.io/predicates/sbom@v1"
or "stellaops.io/predicates/vex@v1";
}
foreach (var configured in _options.PredicateTypes)
{
if (string.Equals(configured, predicateType, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private async Task<string?> AttachAttestationAsync(
string registry,
string repository,
string digest,
DsseEnvelopeDto envelope,
string predicateType,
string tenant,
string reportId,
CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("OciAttestationPublisher.AttachAttestationAsync");
activity?.SetTag("registry", registry);
activity?.SetTag("repository", repository);
activity?.SetTag("predicateType", predicateType);
_logger.LogDebug(
"Attaching {PredicateType} attestation to {Registry}/{Repository}@{Digest} for report {ReportId}.",
predicateType, registry, repository, digest, reportId);
// TODO: Integrate with IOciAttestationAttacher service when available in DI
// For now, this is a placeholder implementation that logs the operation
// The actual implementation would:
// 1. Build OciReference from registry/repository/digest
// 2. Convert DsseEnvelopeDto to DsseEnvelope
// 3. Configure AttachmentOptions based on _options
// 4. Call IOciAttestationAttacher.AttachAsync()
// 5. Return the attestation digest
await Task.Delay(1, cancellationToken); // Placeholder async operation
_logger.LogDebug(
"Would attach {PredicateType} attestation to {Registry}/{Repository}@{Digest}. " +
"SigningMode: {SigningMode}, UseRekor: {UseRekor}",
predicateType, registry, repository, digest,
_options.SigningMode, _options.UseRekor);
// Return placeholder digest - actual implementation would return real digest
return $"sha256:placeholder_{predicateType.Replace('/', '_').Replace('@', '_')}_{reportId}";
}
private static bool TryParseImageReference(
string imageRef,
out string registry,
out string repository,
out string digest)
{
registry = string.Empty;
repository = string.Empty;
digest = string.Empty;
if (string.IsNullOrWhiteSpace(imageRef))
{
return false;
}
// Handle digest format: registry/repo@sha256:...
var atIndex = imageRef.LastIndexOf('@');
if (atIndex > 0 && imageRef.Length > atIndex + 1)
{
var refPart = imageRef[..atIndex];
digest = imageRef[(atIndex + 1)..];
// Parse registry/repository from the reference part
return TryParseRegistryAndRepo(refPart, out registry, out repository);
}
// Handle tag format: registry/repo:tag (need to resolve to digest)
var colonIndex = imageRef.LastIndexOf(':');
if (colonIndex > 0)
{
var tagPart = imageRef[(colonIndex + 1)..];
// Check if this is a port number or a tag
if (!tagPart.Contains('/') && !tagPart.StartsWith("sha256:"))
{
var refPart = imageRef[..colonIndex];
// Tag format - would need to resolve to digest via registry
// For now, this is unsupported
return TryParseRegistryAndRepo(refPart, out registry, out repository);
}
}
// Assume it's just registry/repo without tag or digest
return TryParseRegistryAndRepo(imageRef, out registry, out repository);
}
private static bool TryParseRegistryAndRepo(
string reference,
out string registry,
out string repository)
{
registry = string.Empty;
repository = string.Empty;
if (string.IsNullOrWhiteSpace(reference))
{
return false;
}
var slashIndex = reference.IndexOf('/');
if (slashIndex < 0)
{
// No slash - assume docker.io library image
registry = "docker.io";
repository = $"library/{reference}";
return true;
}
var firstPart = reference[..slashIndex];
// Check if first part looks like a registry (contains . or :)
if (firstPart.Contains('.') || firstPart.Contains(':'))
{
registry = firstPart;
repository = reference[(slashIndex + 1)..];
}
else
{
// Assume docker.io namespace/image
registry = "docker.io";
repository = reference;
}
return !string.IsNullOrWhiteSpace(registry) && !string.IsNullOrWhiteSpace(repository);
}
}

View File

@@ -737,7 +737,7 @@ public sealed class OfflineAttestationVerifier : IOfflineAttestationVerifier
.Trim();
var certBytes = Convert.FromBase64String(base64Content);
return new X509Certificate2(certBytes);
return X509CertificateLoader.LoadCertificate(certBytes);
}
}

View File

@@ -7,8 +7,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.Core.TrustAnchors;

View File

@@ -86,7 +86,7 @@ public sealed class ReplayCommandService : IReplayCommandService
return new ReplayCommandResponseDto
{
FindingId = request.FindingId,
ScanId = finding.ScanId.ToString(),
ScanId = finding.ScanId.ToString()!,
FullCommand = fullCommand,
ShortCommand = shortCommand,
OfflineCommand = offlineCommand,

View File

@@ -61,6 +61,17 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
errors.Add("artifactDigest must include algorithm prefix (e.g. sha256:...).");
}
// LIN-BE-002: Validate lineage digest fields
if (!string.IsNullOrWhiteSpace(request.ParentArtifactDigest) && !request.ParentArtifactDigest.Contains(':', StringComparison.Ordinal))
{
errors.Add("parentArtifactDigest must include algorithm prefix (e.g. sha256:...).");
}
if (!string.IsNullOrWhiteSpace(request.BaseImageDigest) && !request.BaseImageDigest.Contains(':', StringComparison.Ordinal))
{
errors.Add("baseImageDigest must include algorithm prefix (e.g. sha256:...).");
}
var document = TryParseDocument(request, out var parseErrors);
if (parseErrors.Count > 0)
{

View File

@@ -9,18 +9,18 @@
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="CycloneDX.Core" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
@@ -44,9 +44,9 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.BinaryIndex.Persistence.Services;
using StellaOps.Scanner.Worker.Processing;
namespace StellaOps.Scanner.Worker.Extensions;
@@ -89,24 +90,24 @@ internal sealed class NullBinaryVulnerabilityService : IBinaryVulnerabilityServi
return Task.FromResult(System.Collections.Immutable.ImmutableDictionary<string, System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>>.Empty);
}
public Task<StellaOps.BinaryIndex.FixIndex.Models.FixStatusResult?> GetFixStatusAsync(
public Task<FixStatusResult?> GetFixStatusAsync(
string distro,
string release,
string sourcePkg,
string cveId,
CancellationToken ct = default)
{
return Task.FromResult<StellaOps.BinaryIndex.FixIndex.Models.FixStatusResult?>(null);
return Task.FromResult<FixStatusResult?>(null);
}
public Task<System.Collections.Immutable.ImmutableDictionary<string, StellaOps.BinaryIndex.FixIndex.Models.FixStatusResult>> GetFixStatusBatchAsync(
public Task<System.Collections.Immutable.ImmutableDictionary<string, FixStatusResult>> GetFixStatusBatchAsync(
string distro,
string release,
string sourcePkg,
IEnumerable<string> cveIds,
CancellationToken ct = default)
{
return Task.FromResult(System.Collections.Immutable.ImmutableDictionary<string, StellaOps.BinaryIndex.FixIndex.Models.FixStatusResult>.Empty);
return Task.FromResult(System.Collections.Immutable.ImmutableDictionary<string, FixStatusResult>.Empty);
}
public Task<System.Collections.Immutable.ImmutableArray<BinaryVulnMatch>> LookupByFingerprintAsync(

View File

@@ -8,6 +8,7 @@ using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using FixStatusResult = StellaOps.BinaryIndex.Core.Services.FixStatusResult;
@@ -198,7 +199,8 @@ public sealed class BinaryFindingMapper
FixState.Fixed => FindingFixStatus.Fixed,
FixState.Vulnerable => FindingFixStatus.Vulnerable,
FixState.NotAffected => FindingFixStatus.NotAffected,
FixState.WontFix => FindingFixStatus.WontFix,
FixState.Wontfix => FindingFixStatus.WontFix,
FixState.Unknown => FindingFixStatus.Unknown,
_ => FindingFixStatus.Unknown
};
}
@@ -275,14 +277,3 @@ public enum FindingFixStatus
WontFix
}
/// <summary>
/// Fix state from the binary index.
/// </summary>
public enum FixState
{
Unknown,
Vulnerable,
Fixed,
NotAffected,
WontFix
}

View File

@@ -283,7 +283,8 @@ using (var scope = host.Services.CreateScope())
await host.RunAsync();
public partial class Program;
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
file sealed partial class Program;
public sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<SurfaceCacheOptions>
{

View File

@@ -8,11 +8,11 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
@@ -33,6 +33,7 @@
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="../../Unknowns/__Libraries/StellaOps.Unknowns.Core/StellaOps.Unknowns.Core.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Persistence/StellaOps.BinaryIndex.Persistence.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,9 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" 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="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,358 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Cbom;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Crypto;
/// <summary>
/// Extracts cryptographic assets from .NET assemblies and NuGet packages.
/// Analyzes System.Security.Cryptography usage patterns.
/// </summary>
public sealed class DotNetCryptoExtractor : ICryptoAssetExtractor
{
private static readonly ImmutableArray<string> Ecosystems = ImmutableArray.Create("nuget", "dotnet");
public ImmutableArray<string> SupportedEcosystems => Ecosystems;
/// <summary>
/// Known crypto-related NuGet packages.
/// </summary>
private static readonly ImmutableHashSet<string> CryptoPackages = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"System.Security.Cryptography.Algorithms",
"System.Security.Cryptography.Cng",
"System.Security.Cryptography.Csp",
"System.Security.Cryptography.OpenSsl",
"System.Security.Cryptography.Pkcs",
"System.Security.Cryptography.ProtectedData",
"System.Security.Cryptography.X509Certificates",
"System.Security.Cryptography.Xml",
"BouncyCastle.Cryptography",
"BouncyCastle.NetCore",
"Portable.BouncyCastle",
"libsodium",
"NSec.Cryptography",
"Microsoft.IdentityModel.Tokens",
"System.IdentityModel.Tokens.Jwt",
"Jose-jwt",
"jose-jwt",
"BCrypt.Net-Next",
"Scrypt.NET",
"Argon2.NetCore",
"Konscious.Security.Cryptography.Argon2",
"CryptoNet",
"NaCl.Core"
);
/// <summary>
/// Algorithm patterns to detect in package names and identifiers.
/// </summary>
private static readonly ImmutableArray<CryptoAlgorithmPattern> AlgorithmPatterns = ImmutableArray.Create(
// Hash algorithms
new CryptoAlgorithmPattern("MD5", "1.2.840.113549.2.5", CryptoPrimitive.Hash, CryptoFunction.Digest, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA-1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA-256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA384", "2.16.840.1.101.3.4.2.2", CryptoPrimitive.Hash, CryptoFunction.Digest, 384),
new CryptoAlgorithmPattern("SHA512", "2.16.840.1.101.3.4.2.3", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("SHA3-256", "2.16.840.1.101.3.4.2.8", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA3-512", "2.16.840.1.101.3.4.2.10", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
// Symmetric ciphers
new CryptoAlgorithmPattern("AES", "2.16.840.1.101.3.4.1", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES-128", "2.16.840.1.101.3.4.1.1", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128),
new CryptoAlgorithmPattern("AES-192", "2.16.840.1.101.3.4.1.21", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 192),
new CryptoAlgorithmPattern("AES-256", "2.16.840.1.101.3.4.1.41", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES-GCM", "2.16.840.1.101.3.4.1.46", CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("DES", "1.3.14.3.2.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 56, IsDeprecated: true),
new CryptoAlgorithmPattern("3DES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("TripleDES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("ChaCha20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("ChaCha20Poly1305", null, CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("RC4", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("Blowfish", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128, IsWeak: true),
// Asymmetric algorithms
new CryptoAlgorithmPattern("RSA", "1.2.840.113549.1.1.1", CryptoPrimitive.Rsa, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("DSA", "1.2.840.10040.4.1", CryptoPrimitive.Dlog, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDSA", "1.2.840.10045.4.3", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDH", "1.3.132.1.12", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("DiffieHellman", null, CryptoPrimitive.Dlog, CryptoFunction.KeyAgree, 2048, IsQuantumVulnerable: true),
// Key derivation
new CryptoAlgorithmPattern("PBKDF2", "1.2.840.113549.1.5.12", CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Rfc2898", "1.2.840.113549.1.5.12", CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("HKDF", null, CryptoPrimitive.Kdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("BCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 184),
new CryptoAlgorithmPattern("SCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
// MACs
new CryptoAlgorithmPattern("HMAC", null, CryptoPrimitive.Mac, CryptoFunction.Tag, 256),
new CryptoAlgorithmPattern("HMACSHA256", "1.2.840.113549.2.9", CryptoPrimitive.Mac, CryptoFunction.Tag, 256),
new CryptoAlgorithmPattern("HMACSHA512", "1.2.840.113549.2.11", CryptoPrimitive.Mac, CryptoFunction.Tag, 512),
new CryptoAlgorithmPattern("HMACMD5", "1.3.6.1.5.5.8.1.1", CryptoPrimitive.Mac, CryptoFunction.Tag, 128, IsDeprecated: true),
// Post-quantum (emerging in .NET)
new CryptoAlgorithmPattern("ML-KEM", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("ML-DSA", null, CryptoPrimitive.Lattice, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("SLH-DSA", null, CryptoPrimitive.Hash, CryptoFunction.Sign, 256, IsPostQuantum: true)
);
public Task<ImmutableArray<CryptoAsset>> ExtractAsync(
AggregatedComponent component,
CryptoAnalysisContext analysisContext,
CancellationToken cancellationToken = default)
{
var assets = new List<CryptoAsset>();
// Check if component is a known crypto package
var packageName = component.Identity.Name ?? string.Empty;
var purl = component.Identity.Purl ?? string.Empty;
// Skip if not a .NET package
if (!purl.StartsWith("pkg:nuget/", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ImmutableArray<CryptoAsset>.Empty);
}
// Check for known crypto packages
if (CryptoPackages.Contains(packageName))
{
var cryptoAssets = ExtractFromKnownPackage(component, packageName);
assets.AddRange(cryptoAssets);
}
// Check package name for algorithm patterns
foreach (var pattern in AlgorithmPatterns)
{
if (packageName.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase))
{
var asset = CreateAssetFromPattern(component, pattern);
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
// Check metadata for crypto evidence
if (component.Metadata?.Properties != null)
{
foreach (var (key, value) in component.Metadata.Properties)
{
foreach (var pattern in AlgorithmPatterns)
{
if (value.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase))
{
var asset = CreateAssetFromPattern(component, pattern, $"property:{key}");
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
}
}
return Task.FromResult(assets.ToImmutableArray());
}
private static IEnumerable<CryptoAsset> ExtractFromKnownPackage(AggregatedComponent component, string packageName)
{
var assets = new List<CryptoAsset>();
// System.Security.Cryptography packages include multiple algorithms
if (packageName.StartsWith("System.Security.Cryptography", StringComparison.OrdinalIgnoreCase))
{
// Add common algorithms from this namespace
var commonAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "AES"),
AlgorithmPatterns.First(p => p.Name == "SHA256"),
AlgorithmPatterns.First(p => p.Name == "RSA"),
AlgorithmPatterns.First(p => p.Name == "ECDSA"),
AlgorithmPatterns.First(p => p.Name == "HMACSHA256")
};
foreach (var pattern in commonAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{packageName}"));
}
}
else if (packageName.Contains("BouncyCastle", StringComparison.OrdinalIgnoreCase))
{
// BouncyCastle provides many algorithms
var bcAlgorithms = AlgorithmPatterns.Where(p =>
p.Name == "AES" || p.Name == "RSA" || p.Name == "ECDSA" ||
p.Name == "SHA256" || p.Name == "SHA512" || p.Name == "ChaCha20Poly1305");
foreach (var pattern in bcAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{packageName}",
implementationPlatform: "BouncyCastle"));
}
}
else if (packageName.Contains("libsodium", StringComparison.OrdinalIgnoreCase) ||
packageName.Contains("NSec", StringComparison.OrdinalIgnoreCase))
{
// Modern crypto libraries
var modernAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "ChaCha20Poly1305"),
AlgorithmPatterns.First(p => p.Name == "AES-GCM"),
AlgorithmPatterns.First(p => p.Name == "SHA512")
};
foreach (var pattern in modernAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{packageName}",
implementationPlatform: "libsodium"));
}
}
else if (packageName.Contains("BCrypt", StringComparison.OrdinalIgnoreCase))
{
assets.Add(CreateAssetFromPattern(component,
AlgorithmPatterns.First(p => p.Name == "BCrypt"), $"package:{packageName}"));
}
else if (packageName.Contains("Argon2", StringComparison.OrdinalIgnoreCase))
{
assets.Add(CreateAssetFromPattern(component,
AlgorithmPatterns.First(p => p.Name == "Argon2"), $"package:{packageName}"));
}
else if (packageName.Contains("Jwt", StringComparison.OrdinalIgnoreCase) ||
packageName.Contains("Jose", StringComparison.OrdinalIgnoreCase))
{
// JWT libraries use various algorithms
var jwtAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "RSA"),
AlgorithmPatterns.First(p => p.Name == "ECDSA"),
AlgorithmPatterns.First(p => p.Name == "HMACSHA256"),
AlgorithmPatterns.First(p => p.Name == "AES")
};
foreach (var pattern in jwtAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{packageName}"));
}
}
return assets;
}
private static CryptoAsset CreateAssetFromPattern(
AggregatedComponent component,
CryptoAlgorithmPattern pattern,
string? evidenceSource = null,
string? implementationPlatform = null)
{
var riskFlags = new List<CryptoRiskFlag>();
if (pattern.IsDeprecated)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "DEPRECATED_ALGORITHM",
Severity = CryptoRiskSeverity.Critical,
Description = $"{pattern.Name} is deprecated and should not be used",
Recommendation = GetDeprecatedRecommendation(pattern.Name)
});
}
if (pattern.IsWeak)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "WEAK_ALGORITHM",
Severity = CryptoRiskSeverity.High,
Description = $"{pattern.Name} is considered weak by modern standards",
Recommendation = GetWeakRecommendation(pattern.Name)
});
}
if (pattern.IsQuantumVulnerable)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "QUANTUM_VULNERABLE",
Severity = CryptoRiskSeverity.Medium,
Description = $"{pattern.Name} is vulnerable to quantum computing attacks",
Recommendation = "Consider migration path to post-quantum algorithms (ML-KEM, ML-DSA)"
});
}
var evidence = new List<string> { $"component:{component.Identity.Key}" };
if (evidenceSource != null)
{
evidence.Add(evidenceSource);
}
var algorithmProperties = new AlgorithmProperties
{
Primitive = pattern.Primitive,
CryptoFunctions = ImmutableArray.Create(pattern.Function),
ClassicalSecurityLevel = pattern.KeySize,
ImplementationPlatform = implementationPlatform ?? "System.Security.Cryptography",
ExecutionEnvironment = ExecutionEnvironment.Software
};
return new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:{pattern.Name}",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.Algorithm,
AlgorithmName = pattern.Name,
Oid = pattern.Oid,
KeySizeBits = pattern.KeySize,
Primitive = pattern.Primitive,
Functions = ImmutableArray.Create(pattern.Function),
ImplementationPlatform = implementationPlatform ?? "System.Security.Cryptography",
ExecutionEnvironment = ExecutionEnvironment.Software,
Confidence = 0.9,
Evidence = evidence.ToImmutableArray(),
RiskFlags = riskFlags.ToImmutableArray(),
CryptoProperties = new CryptoProperties
{
AssetType = CryptoAssetType.Algorithm,
AlgorithmProperties = algorithmProperties,
Oid = pattern.Oid
}
};
}
private static string GetDeprecatedRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"MD5" => "Replace with SHA-256 or SHA-3-256",
"SHA1" or "SHA-1" => "Replace with SHA-256 or SHA-3-256",
"DES" => "Replace with AES-256-GCM",
"RC4" => "Replace with ChaCha20-Poly1305 or AES-GCM",
"HMACMD5" => "Replace with HMAC-SHA256",
_ => "Replace with a modern algorithm"
};
}
private static string GetWeakRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"3DES" or "TRIPLEDES" => "Replace with AES-256-GCM",
"BLOWFISH" => "Replace with AES-256-GCM or ChaCha20-Poly1305",
_ => "Consider using a stronger algorithm"
};
}
private sealed record CryptoAlgorithmPattern(
string Name,
string? Oid,
CryptoPrimitive Primitive,
CryptoFunction Function,
int KeySize,
bool IsDeprecated = false,
bool IsWeak = false,
bool IsQuantumVulnerable = false,
bool IsPostQuantum = false);
}

View File

@@ -20,5 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,488 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Cbom;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Crypto;
/// <summary>
/// Extracts cryptographic assets from Java/Kotlin libraries and packages.
/// Analyzes java.security, javax.crypto, and BouncyCastle usage patterns.
/// </summary>
public sealed class JavaCryptoExtractor : ICryptoAssetExtractor
{
private static readonly ImmutableArray<string> Ecosystems = ImmutableArray.Create("maven", "gradle");
public ImmutableArray<string> SupportedEcosystems => Ecosystems;
/// <summary>
/// Known crypto-related Maven/Gradle packages.
/// </summary>
private static readonly ImmutableHashSet<string> CryptoPackages = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
// BouncyCastle
"org.bouncycastle:bcprov-jdk18on",
"org.bouncycastle:bcprov-jdk15on",
"org.bouncycastle:bcpkix-jdk18on",
"org.bouncycastle:bcpkix-jdk15on",
"org.bouncycastle:bcpg-jdk18on",
"org.bouncycastle:bctls-jdk18on",
"org.bouncycastle:bcutil-jdk18on",
// JCA/JCE providers
"org.conscrypt:conscrypt-openjdk-uber",
"com.amazon.corretto:amazon-corretto-crypto-provider",
// JWT libraries
"io.jsonwebtoken:jjwt-api",
"io.jsonwebtoken:jjwt-impl",
"com.auth0:java-jwt",
"com.nimbusds:nimbus-jose-jwt",
"org.bitbucket.b_c:jose4j",
// Password hashing
"org.mindrot:jbcrypt",
"de.mkammerer:argon2-jvm",
"com.lambdaworks:scrypt",
"at.favre.lib:bcrypt",
// TLS/SSL
"io.netty:netty-handler",
"org.eclipse.jetty:jetty-alpn-java-client",
// Crypto utilities
"commons-codec:commons-codec",
"com.google.crypto.tink:tink",
"org.apache.shiro:shiro-crypto-core",
"org.jasypt:jasypt",
"org.apache.commons:commons-crypto",
// Key management
"com.google.cloud:google-cloud-kms",
"software.amazon.awssdk:kms",
"com.azure:azure-security-keyvault-keys"
);
/// <summary>
/// Crypto provider patterns in Java.
/// </summary>
private static readonly ImmutableArray<string> CryptoProviderIndicators = ImmutableArray.Create(
"java.security",
"javax.crypto",
"org.bouncycastle",
"Security.addProvider",
"KeyStore",
"Cipher",
"MessageDigest",
"Signature",
"Mac",
"KeyGenerator",
"KeyPairGenerator",
"SecretKeyFactory",
"KeyAgreement"
);
/// <summary>
/// Algorithm patterns to detect in package names and identifiers.
/// </summary>
private static readonly ImmutableArray<CryptoAlgorithmPattern> AlgorithmPatterns = ImmutableArray.Create(
// Hash algorithms
new CryptoAlgorithmPattern("MD5", "1.2.840.113549.2.5", CryptoPrimitive.Hash, CryptoFunction.Digest, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA-1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA-256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA-384", "2.16.840.1.101.3.4.2.2", CryptoPrimitive.Hash, CryptoFunction.Digest, 384),
new CryptoAlgorithmPattern("SHA-512", "2.16.840.1.101.3.4.2.3", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("SHA-3", "2.16.840.1.101.3.4.2.8", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA3-256", "2.16.840.1.101.3.4.2.8", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA3-512", "2.16.840.1.101.3.4.2.10", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("BLAKE2", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("BLAKE3", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
// Symmetric ciphers
new CryptoAlgorithmPattern("AES", "2.16.840.1.101.3.4.1", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/GCM", "2.16.840.1.101.3.4.1.46", CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/CBC", "2.16.840.1.101.3.4.1.2", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/CTR", "2.16.840.1.101.3.4.1.42", CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("DES", "1.3.14.3.2.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 56, IsDeprecated: true),
new CryptoAlgorithmPattern("DESede", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("3DES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("TripleDES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("Blowfish", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128, IsWeak: true),
new CryptoAlgorithmPattern("RC4", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("RC2", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("ChaCha20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("ChaCha20-Poly1305", null, CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("XChaCha20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
// Asymmetric algorithms
new CryptoAlgorithmPattern("RSA", "1.2.840.113549.1.1.1", CryptoPrimitive.Rsa, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("DSA", "1.2.840.10040.4.1", CryptoPrimitive.Dlog, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("EC", "1.2.840.10045.2.1", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDSA", "1.2.840.10045.4.3", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDH", "1.3.132.1.12", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("Ed25519", "1.3.101.112", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("Ed448", "1.3.101.113", CryptoPrimitive.Ec, CryptoFunction.Sign, 448, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("X25519", "1.3.101.110", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("X448", "1.3.101.111", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 448, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("DiffieHellman", null, CryptoPrimitive.Dlog, CryptoFunction.KeyAgree, 2048, IsQuantumVulnerable: true),
// Key derivation
new CryptoAlgorithmPattern("PBKDF2", "1.2.840.113549.1.5.12", CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("HKDF", null, CryptoPrimitive.Kdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("BCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 184),
new CryptoAlgorithmPattern("SCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2id", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
// MACs
new CryptoAlgorithmPattern("HmacSHA256", "1.2.840.113549.2.9", CryptoPrimitive.Mac, CryptoFunction.Tag, 256),
new CryptoAlgorithmPattern("HmacSHA384", "1.2.840.113549.2.10", CryptoPrimitive.Mac, CryptoFunction.Tag, 384),
new CryptoAlgorithmPattern("HmacSHA512", "1.2.840.113549.2.11", CryptoPrimitive.Mac, CryptoFunction.Tag, 512),
new CryptoAlgorithmPattern("HmacMD5", "1.3.6.1.5.5.8.1.1", CryptoPrimitive.Mac, CryptoFunction.Tag, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("HmacSHA1", "1.2.840.113549.2.7", CryptoPrimitive.Mac, CryptoFunction.Tag, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("Poly1305", null, CryptoPrimitive.Mac, CryptoFunction.Tag, 128),
new CryptoAlgorithmPattern("GMAC", null, CryptoPrimitive.Mac, CryptoFunction.Tag, 128),
// Post-quantum (BouncyCastle implementations)
new CryptoAlgorithmPattern("Kyber", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("ML-KEM", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("Dilithium", null, CryptoPrimitive.Lattice, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("ML-DSA", null, CryptoPrimitive.Lattice, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("SPHINCS+", null, CryptoPrimitive.Hash, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("SLH-DSA", null, CryptoPrimitive.Hash, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("NTRU", null, CryptoPrimitive.Lattice, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("FrodoKEM", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true)
);
public Task<ImmutableArray<CryptoAsset>> ExtractAsync(
AggregatedComponent component,
CryptoAnalysisContext analysisContext,
CancellationToken cancellationToken = default)
{
var assets = new List<CryptoAsset>();
var packageName = component.Identity.Name ?? string.Empty;
var purl = component.Identity.Purl ?? string.Empty;
// Skip if not a Java package
if (!purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase) &&
!purl.StartsWith("pkg:gradle/", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ImmutableArray<CryptoAsset>.Empty);
}
// Check for known crypto packages by group:artifact format
var groupArtifact = ExtractGroupArtifact(purl);
if (CryptoPackages.Contains(groupArtifact))
{
var cryptoAssets = ExtractFromKnownPackage(component, groupArtifact);
assets.AddRange(cryptoAssets);
}
// Check package name for algorithm patterns
foreach (var pattern in AlgorithmPatterns)
{
if (packageName.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase) ||
groupArtifact.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase))
{
var asset = CreateAssetFromPattern(component, pattern);
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
// Check metadata for crypto provider evidence
if (component.Metadata?.Properties != null)
{
foreach (var (key, value) in component.Metadata.Properties)
{
// Check for crypto provider indicators
foreach (var indicator in CryptoProviderIndicators)
{
if (value.Contains(indicator, StringComparison.OrdinalIgnoreCase))
{
// Look for specific algorithms
foreach (var pattern in AlgorithmPatterns)
{
if (value.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase))
{
var asset = CreateAssetFromPattern(component, pattern, $"property:{key}");
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
break;
}
}
}
}
return Task.FromResult(assets.ToImmutableArray());
}
private static string ExtractGroupArtifact(string purl)
{
// Extract group:artifact from pkg:maven/group/artifact@version
var prefix = purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase) ? "pkg:maven/" :
purl.StartsWith("pkg:gradle/", StringComparison.OrdinalIgnoreCase) ? "pkg:gradle/" : null;
if (prefix == null) return string.Empty;
var rest = purl[prefix.Length..];
var versionIdx = rest.IndexOf('@');
if (versionIdx > 0)
{
rest = rest[..versionIdx];
}
// Replace / with : for group:artifact format
return rest.Replace('/', ':');
}
private static IEnumerable<CryptoAsset> ExtractFromKnownPackage(AggregatedComponent component, string groupArtifact)
{
var assets = new List<CryptoAsset>();
if (groupArtifact.Contains("bouncycastle", StringComparison.OrdinalIgnoreCase))
{
// BouncyCastle provides comprehensive crypto
var bcAlgorithms = AlgorithmPatterns.Where(p =>
p.Name == "AES" || p.Name == "RSA" || p.Name == "ECDSA" ||
p.Name == "SHA-256" || p.Name == "SHA-512" || p.Name == "ChaCha20-Poly1305" ||
p.Name == "Ed25519" || p.Name == "X25519" || p.IsPostQuantum);
foreach (var pattern in bcAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{groupArtifact}",
implementationPlatform: "BouncyCastle"));
}
}
else if (groupArtifact.Contains("jjwt", StringComparison.OrdinalIgnoreCase) ||
groupArtifact.Contains("java-jwt", StringComparison.OrdinalIgnoreCase) ||
groupArtifact.Contains("nimbus-jose-jwt", StringComparison.OrdinalIgnoreCase) ||
groupArtifact.Contains("jose4j", StringComparison.OrdinalIgnoreCase))
{
// JWT libraries use various algorithms
var jwtAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "RSA"),
AlgorithmPatterns.First(p => p.Name == "ECDSA"),
AlgorithmPatterns.First(p => p.Name == "HmacSHA256"),
AlgorithmPatterns.First(p => p.Name == "HmacSHA512"),
AlgorithmPatterns.First(p => p.Name == "Ed25519"),
AlgorithmPatterns.First(p => p.Name == "AES/GCM")
};
foreach (var pattern in jwtAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{groupArtifact}"));
}
}
else if (groupArtifact.Contains("jbcrypt", StringComparison.OrdinalIgnoreCase) ||
groupArtifact.Contains("bcrypt", StringComparison.OrdinalIgnoreCase))
{
assets.Add(CreateAssetFromPattern(component,
AlgorithmPatterns.First(p => p.Name == "BCrypt"), $"package:{groupArtifact}"));
}
else if (groupArtifact.Contains("argon2", StringComparison.OrdinalIgnoreCase))
{
assets.Add(CreateAssetFromPattern(component,
AlgorithmPatterns.First(p => p.Name == "Argon2id"), $"package:{groupArtifact}"));
}
else if (groupArtifact.Contains("scrypt", StringComparison.OrdinalIgnoreCase))
{
assets.Add(CreateAssetFromPattern(component,
AlgorithmPatterns.First(p => p.Name == "SCrypt"), $"package:{groupArtifact}"));
}
else if (groupArtifact.Contains("tink", StringComparison.OrdinalIgnoreCase))
{
// Google Tink provides modern crypto
var tinkAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "AES/GCM"),
AlgorithmPatterns.First(p => p.Name == "ChaCha20-Poly1305"),
AlgorithmPatterns.First(p => p.Name == "ECDSA"),
AlgorithmPatterns.First(p => p.Name == "Ed25519"),
AlgorithmPatterns.First(p => p.Name == "HKDF")
};
foreach (var pattern in tinkAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{groupArtifact}",
implementationPlatform: "Google Tink"));
}
}
else if (groupArtifact.Contains("conscrypt", StringComparison.OrdinalIgnoreCase))
{
// Conscrypt OpenSSL-based provider
var conscryptAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "AES/GCM"),
AlgorithmPatterns.First(p => p.Name == "ChaCha20-Poly1305"),
AlgorithmPatterns.First(p => p.Name == "SHA-256"),
AlgorithmPatterns.First(p => p.Name == "RSA"),
AlgorithmPatterns.First(p => p.Name == "ECDSA")
};
foreach (var pattern in conscryptAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{groupArtifact}",
implementationPlatform: "Conscrypt/OpenSSL"));
}
}
else if (groupArtifact.Contains("amazon-corretto-crypto", StringComparison.OrdinalIgnoreCase))
{
// Amazon Corretto Crypto Provider
var acpAlgorithms = new[]
{
AlgorithmPatterns.First(p => p.Name == "AES/GCM"),
AlgorithmPatterns.First(p => p.Name == "SHA-256"),
AlgorithmPatterns.First(p => p.Name == "SHA-512"),
AlgorithmPatterns.First(p => p.Name == "RSA"),
AlgorithmPatterns.First(p => p.Name == "ECDSA"),
AlgorithmPatterns.First(p => p.Name == "ECDH")
};
foreach (var pattern in acpAlgorithms)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{groupArtifact}",
implementationPlatform: "Amazon Corretto Crypto Provider"));
}
}
else if (groupArtifact.Contains("kms", StringComparison.OrdinalIgnoreCase))
{
// Cloud KMS libraries
assets.Add(new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:kms",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.RelatedCryptoMaterial,
AlgorithmName = "KMS",
Confidence = 0.9,
ImplementationPlatform = groupArtifact.Contains("google") ? "Google Cloud KMS" :
groupArtifact.Contains("aws") ? "AWS KMS" :
groupArtifact.Contains("azure") ? "Azure Key Vault" : "Cloud KMS",
Evidence = ImmutableArray.Create($"package:{groupArtifact}")
});
}
return assets;
}
private static CryptoAsset CreateAssetFromPattern(
AggregatedComponent component,
CryptoAlgorithmPattern pattern,
string? evidenceSource = null,
string? implementationPlatform = null)
{
var riskFlags = new List<CryptoRiskFlag>();
if (pattern.IsDeprecated)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "DEPRECATED_ALGORITHM",
Severity = CryptoRiskSeverity.Critical,
Description = $"{pattern.Name} is deprecated and should not be used",
Recommendation = GetDeprecatedRecommendation(pattern.Name)
});
}
if (pattern.IsWeak)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "WEAK_ALGORITHM",
Severity = CryptoRiskSeverity.High,
Description = $"{pattern.Name} is considered weak by modern standards",
Recommendation = GetWeakRecommendation(pattern.Name)
});
}
if (pattern.IsQuantumVulnerable)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "QUANTUM_VULNERABLE",
Severity = CryptoRiskSeverity.Medium,
Description = $"{pattern.Name} is vulnerable to quantum computing attacks",
Recommendation = "Consider migration path to post-quantum algorithms (ML-KEM, ML-DSA, SLH-DSA)"
});
}
var evidence = new List<string> { $"component:{component.Identity.Key}" };
if (evidenceSource != null)
{
evidence.Add(evidenceSource);
}
var platform = implementationPlatform ?? "Java Cryptography Architecture (JCA)";
var algorithmProperties = new AlgorithmProperties
{
Primitive = pattern.Primitive,
CryptoFunctions = ImmutableArray.Create(pattern.Function),
ClassicalSecurityLevel = pattern.KeySize,
ImplementationPlatform = platform,
ExecutionEnvironment = ExecutionEnvironment.Software
};
return new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:{pattern.Name}",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.Algorithm,
AlgorithmName = pattern.Name,
Oid = pattern.Oid,
KeySizeBits = pattern.KeySize,
Primitive = pattern.Primitive,
Functions = ImmutableArray.Create(pattern.Function),
ImplementationPlatform = platform,
ExecutionEnvironment = ExecutionEnvironment.Software,
Confidence = 0.9,
Evidence = evidence.ToImmutableArray(),
RiskFlags = riskFlags.ToImmutableArray(),
CryptoProperties = new CryptoProperties
{
AssetType = CryptoAssetType.Algorithm,
AlgorithmProperties = algorithmProperties,
Oid = pattern.Oid
}
};
}
private static string GetDeprecatedRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"MD5" => "Replace with SHA-256 or SHA-3",
"SHA1" or "SHA-1" => "Replace with SHA-256 or SHA-3",
"DES" => "Replace with AES-256-GCM",
"RC4" => "Replace with ChaCha20-Poly1305 or AES-GCM",
"RC2" => "Replace with AES-256-GCM",
"HMACMD5" => "Replace with HmacSHA256",
"HMACSHA1" => "Replace with HmacSHA256",
_ => "Replace with a modern algorithm"
};
}
private static string GetWeakRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"DESEDE" or "3DES" or "TRIPLEDES" => "Replace with AES-256-GCM",
"BLOWFISH" => "Replace with AES-256-GCM or ChaCha20-Poly1305",
_ => "Consider using a stronger algorithm"
};
}
private sealed record CryptoAlgorithmPattern(
string Name,
string? Oid,
CryptoPrimitive Primitive,
CryptoFunction Function,
int KeySize,
bool IsDeprecated = false,
bool IsWeak = false,
bool IsQuantumVulnerable = false,
bool IsPostQuantum = false);
}

View File

@@ -16,5 +16,6 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,576 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Cbom;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Crypto;
/// <summary>
/// Extracts cryptographic assets from Node.js/TypeScript packages.
/// Analyzes npm dependencies for crypto module usage, bcrypt, crypto-js, sodium, etc.
/// </summary>
public sealed class NodeCryptoExtractor : ICryptoAssetExtractor
{
private static readonly ImmutableArray<string> Ecosystems = ImmutableArray.Create("npm", "yarn", "pnpm");
public ImmutableArray<string> SupportedEcosystems => Ecosystems;
/// <summary>
/// Known crypto-related npm packages.
/// </summary>
private static readonly ImmutableHashSet<string> CryptoPackages = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
// Password hashing
"bcrypt",
"bcryptjs",
"argon2",
"scrypt",
"scrypt-js",
"pbkdf2",
// General crypto
"crypto-js",
"sjcl",
"forge",
"node-forge",
"tweetnacl",
"tweetnacl-util",
"libsodium",
"libsodium-wrappers",
"sodium-native",
"sodium-plus",
// Encryption
"aes-js",
"aes256",
"triplesec",
"chacha20",
"xchacha20",
// JWT/JWE
"jsonwebtoken",
"jose",
"jws",
"jwe",
"jwt-simple",
"passport-jwt",
"express-jwt",
// Hashing
"hash.js",
"sha.js",
"sha256",
"sha512",
"sha3",
"keccak",
"blake2",
"blake3",
"md5",
"md5-js",
"object-hash",
"murmurhash",
// TLS/SSL
"tls",
"https",
"http2",
"ssl-root-cas",
// Key derivation
"hkdf",
// Secure random
"uuid",
"nanoid",
"randomstring",
"secure-random",
"random-bytes",
// Signatures
"ed25519",
"ed25519-supercop",
"elliptic",
"secp256k1",
"noble-secp256k1",
"@noble/secp256k1",
"@noble/ed25519",
"@noble/hashes",
"@noble/ciphers",
"@noble/curves",
// RSA
"node-rsa",
"rsa",
"jsrsasign",
// OpenPGP
"openpgp",
"kbpgp",
// AWS KMS / Cloud crypto
"@aws-sdk/client-kms",
"@google-cloud/kms",
"@azure/keyvault-keys",
"@azure/keyvault-secrets",
// Certificate handling
"pem",
"selfsigned",
"node-x509",
"@peculiar/x509",
"pkijs",
// WebCrypto polyfills
"webcrypto",
"@peculiar/webcrypto",
"isomorphic-webcrypto",
// Post-quantum (experimental)
"liboqs",
"pqcrypto",
"kyber-crystals",
"dilithium"
);
/// <summary>
/// Package to algorithm mapping for detailed extraction.
/// </summary>
private static readonly ImmutableDictionary<string, string[]> PackageAlgorithms =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["bcrypt"] = new[] { "BCrypt" },
["bcryptjs"] = new[] { "BCrypt" },
["argon2"] = new[] { "Argon2id", "Argon2i", "Argon2d" },
["scrypt"] = new[] { "SCrypt" },
["scrypt-js"] = new[] { "SCrypt" },
["pbkdf2"] = new[] { "PBKDF2" },
["crypto-js"] = new[] { "AES", "DES", "TripleDES", "RC4", "Rabbit", "MD5", "SHA-1", "SHA-256", "SHA-512", "HMAC", "PBKDF2" },
["aes-js"] = new[] { "AES/CTR", "AES/CBC", "AES/CFB", "AES/OFB", "AES/ECB" },
["aes256"] = new[] { "AES" },
["triplesec"] = new[] { "AES", "Twofish", "Salsa20", "SHA-512", "HMAC" },
["chacha20"] = new[] { "ChaCha20" },
["xchacha20"] = new[] { "XChaCha20" },
["tweetnacl"] = new[] { "XSalsa20", "Poly1305", "Curve25519", "Ed25519", "SHA-512" },
["libsodium"] = new[] { "ChaCha20-Poly1305", "XChaCha20-Poly1305", "Curve25519", "Ed25519", "BLAKE2b", "Argon2id" },
["libsodium-wrappers"] = new[] { "ChaCha20-Poly1305", "XChaCha20-Poly1305", "Curve25519", "Ed25519", "BLAKE2b", "Argon2id" },
["sodium-native"] = new[] { "ChaCha20-Poly1305", "XChaCha20-Poly1305", "Curve25519", "Ed25519", "BLAKE2b", "Argon2id" },
["sodium-plus"] = new[] { "ChaCha20-Poly1305", "XChaCha20-Poly1305", "Curve25519", "Ed25519", "BLAKE2b", "Argon2id" },
["jsonwebtoken"] = new[] { "RSA", "ECDSA", "HmacSHA256", "HmacSHA384", "HmacSHA512", "Ed25519" },
["jose"] = new[] { "RSA", "ECDSA", "HmacSHA256", "HmacSHA384", "HmacSHA512", "Ed25519", "AES/GCM", "ChaCha20-Poly1305" },
["hash.js"] = new[] { "SHA-256", "SHA-512", "SHA-1", "SHA-224", "SHA-384", "RIPEMD160" },
["sha.js"] = new[] { "SHA-1", "SHA-224", "SHA-256", "SHA-384", "SHA-512" },
["sha256"] = new[] { "SHA-256" },
["sha512"] = new[] { "SHA-512" },
["sha3"] = new[] { "SHA3-256", "SHA3-384", "SHA3-512", "SHAKE128", "SHAKE256", "Keccak" },
["keccak"] = new[] { "Keccak", "SHA3-256" },
["blake2"] = new[] { "BLAKE2b", "BLAKE2s" },
["blake3"] = new[] { "BLAKE3" },
["md5"] = new[] { "MD5" },
["md5-js"] = new[] { "MD5" },
["elliptic"] = new[] { "ECDSA", "ECDH", "Ed25519", "Curve25519" },
["secp256k1"] = new[] { "ECDSA", "secp256k1" },
["@noble/secp256k1"] = new[] { "ECDSA", "secp256k1" },
["@noble/ed25519"] = new[] { "Ed25519" },
["@noble/hashes"] = new[] { "SHA-256", "SHA-512", "SHA3-256", "BLAKE2b", "BLAKE2s", "BLAKE3" },
["@noble/ciphers"] = new[] { "AES/GCM", "ChaCha20-Poly1305", "XChaCha20-Poly1305", "Salsa20" },
["@noble/curves"] = new[] { "ECDSA", "Ed25519", "Ed448", "secp256k1", "P-256", "P-384", "P-521" },
["node-rsa"] = new[] { "RSA" },
["jsrsasign"] = new[] { "RSA", "ECDSA", "DSA", "SHA-256", "SHA-512" },
["openpgp"] = new[] { "RSA", "ECDSA", "Ed25519", "Curve25519", "AES", "CAST5", "SHA-256", "SHA-512" },
["forge"] = new[] { "RSA", "AES", "DES", "TripleDES", "RC2", "SHA-1", "SHA-256", "SHA-512", "MD5", "HMAC", "PBKDF2" },
["node-forge"] = new[] { "RSA", "AES", "DES", "TripleDES", "RC2", "SHA-1", "SHA-256", "SHA-512", "MD5", "HMAC", "PBKDF2" },
["hkdf"] = new[] { "HKDF" },
["@aws-sdk/client-kms"] = new[] { "KMS" },
["@google-cloud/kms"] = new[] { "KMS" },
["@azure/keyvault-keys"] = new[] { "KMS" },
["liboqs"] = new[] { "Kyber", "Dilithium", "SPHINCS+", "ML-KEM", "ML-DSA" },
["pqcrypto"] = new[] { "Kyber", "Dilithium", "SPHINCS+" },
["kyber-crystals"] = new[] { "Kyber", "ML-KEM" },
["dilithium"] = new[] { "Dilithium", "ML-DSA" }
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Algorithm patterns to detect in package names and identifiers.
/// </summary>
private static readonly ImmutableArray<CryptoAlgorithmPattern> AlgorithmPatterns = ImmutableArray.Create(
// Hash algorithms
new CryptoAlgorithmPattern("MD5", "1.2.840.113549.2.5", CryptoPrimitive.Hash, CryptoFunction.Digest, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA-1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA1", "1.3.14.3.2.26", CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsDeprecated: true),
new CryptoAlgorithmPattern("SHA-256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA256", "2.16.840.1.101.3.4.2.1", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA-384", "2.16.840.1.101.3.4.2.2", CryptoPrimitive.Hash, CryptoFunction.Digest, 384),
new CryptoAlgorithmPattern("SHA-512", "2.16.840.1.101.3.4.2.3", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("SHA512", "2.16.840.1.101.3.4.2.3", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("SHA3-256", "2.16.840.1.101.3.4.2.8", CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("SHA3-512", "2.16.840.1.101.3.4.2.10", CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("Keccak", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("BLAKE2b", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 512),
new CryptoAlgorithmPattern("BLAKE2s", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("BLAKE3", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 256),
new CryptoAlgorithmPattern("RIPEMD160", null, CryptoPrimitive.Hash, CryptoFunction.Digest, 160, IsWeak: true),
// Symmetric ciphers
new CryptoAlgorithmPattern("AES", "2.16.840.1.101.3.4.1", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/GCM", "2.16.840.1.101.3.4.1.46", CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/CBC", "2.16.840.1.101.3.4.1.2", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/CTR", "2.16.840.1.101.3.4.1.42", CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/CFB", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/OFB", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("AES/ECB", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256, IsWeak: true),
new CryptoAlgorithmPattern("DES", "1.3.14.3.2.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 56, IsDeprecated: true),
new CryptoAlgorithmPattern("TripleDES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("3DES", "1.2.840.113549.3.7", CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 168, IsWeak: true),
new CryptoAlgorithmPattern("RC4", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("RC2", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128, IsDeprecated: true),
new CryptoAlgorithmPattern("Rabbit", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 128),
new CryptoAlgorithmPattern("ChaCha20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("ChaCha20-Poly1305", null, CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("XChaCha20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("XChaCha20-Poly1305", null, CryptoPrimitive.Aead, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("Salsa20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("XSalsa20", null, CryptoPrimitive.StreamCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("Twofish", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 256),
new CryptoAlgorithmPattern("CAST5", null, CryptoPrimitive.BlockCipher, CryptoFunction.Encrypt, 128, IsWeak: true),
// Asymmetric algorithms
new CryptoAlgorithmPattern("RSA", "1.2.840.113549.1.1.1", CryptoPrimitive.Rsa, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("DSA", "1.2.840.10040.4.1", CryptoPrimitive.Dlog, CryptoFunction.Sign, 2048, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDSA", "1.2.840.10045.4.3", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("ECDH", "1.3.132.1.12", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("Ed25519", "1.3.101.112", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("Ed448", "1.3.101.113", CryptoPrimitive.Ec, CryptoFunction.Sign, 448, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("Curve25519", "1.3.101.110", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("X25519", "1.3.101.110", CryptoPrimitive.Ec, CryptoFunction.KeyAgree, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("secp256k1", "1.3.132.0.10", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("P-256", "1.2.840.10045.3.1.7", CryptoPrimitive.Ec, CryptoFunction.Sign, 256, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("P-384", "1.3.132.0.34", CryptoPrimitive.Ec, CryptoFunction.Sign, 384, IsQuantumVulnerable: true),
new CryptoAlgorithmPattern("P-521", "1.3.132.0.35", CryptoPrimitive.Ec, CryptoFunction.Sign, 521, IsQuantumVulnerable: true),
// Key derivation
new CryptoAlgorithmPattern("PBKDF2", "1.2.840.113549.1.5.12", CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("HKDF", null, CryptoPrimitive.Kdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("BCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 184),
new CryptoAlgorithmPattern("SCrypt", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2id", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2i", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
new CryptoAlgorithmPattern("Argon2d", null, CryptoPrimitive.Pbkdf, CryptoFunction.Derive, 256),
// MACs
new CryptoAlgorithmPattern("HMAC", null, CryptoPrimitive.Mac, CryptoFunction.Tag, 256),
new CryptoAlgorithmPattern("HmacSHA256", "1.2.840.113549.2.9", CryptoPrimitive.Mac, CryptoFunction.Tag, 256),
new CryptoAlgorithmPattern("HmacSHA384", "1.2.840.113549.2.10", CryptoPrimitive.Mac, CryptoFunction.Tag, 384),
new CryptoAlgorithmPattern("HmacSHA512", "1.2.840.113549.2.11", CryptoPrimitive.Mac, CryptoFunction.Tag, 512),
new CryptoAlgorithmPattern("Poly1305", null, CryptoPrimitive.Mac, CryptoFunction.Tag, 128),
// Post-quantum
new CryptoAlgorithmPattern("Kyber", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("ML-KEM", null, CryptoPrimitive.Kem, CryptoFunction.Encapsulate, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("Dilithium", null, CryptoPrimitive.Lattice, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("ML-DSA", null, CryptoPrimitive.Lattice, CryptoFunction.Sign, 256, IsPostQuantum: true),
new CryptoAlgorithmPattern("SPHINCS+", null, CryptoPrimitive.Hash, CryptoFunction.Sign, 256, IsPostQuantum: true)
);
public Task<ImmutableArray<CryptoAsset>> ExtractAsync(
AggregatedComponent component,
CryptoAnalysisContext analysisContext,
CancellationToken cancellationToken = default)
{
var assets = new List<CryptoAsset>();
var packageName = component.Identity.Name ?? string.Empty;
var purl = component.Identity.Purl ?? string.Empty;
// Skip if not a Node package
if (!purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ImmutableArray<CryptoAsset>.Empty);
}
// Extract package name from purl
var npmPackageName = ExtractPackageName(purl);
// Check for known crypto packages
if (CryptoPackages.Contains(npmPackageName))
{
var cryptoAssets = ExtractFromKnownPackage(component, npmPackageName);
assets.AddRange(cryptoAssets);
}
// Check package name for algorithm patterns
foreach (var pattern in AlgorithmPatterns)
{
if (ContainsAlgorithmName(packageName, pattern.Name) ||
ContainsAlgorithmName(npmPackageName, pattern.Name))
{
var asset = CreateAssetFromPattern(component, pattern);
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
// Check metadata for crypto evidence
if (component.Metadata?.Properties != null)
{
foreach (var (key, value) in component.Metadata.Properties)
{
// Look for crypto-related keywords
if (IsCryptoRelatedProperty(key, value))
{
foreach (var pattern in AlgorithmPatterns)
{
if (value.Contains(pattern.Name, StringComparison.OrdinalIgnoreCase))
{
var asset = CreateAssetFromPattern(component, pattern, $"property:{key}");
if (!assets.Any(a => a.AlgorithmName == asset.AlgorithmName))
{
assets.Add(asset);
}
}
}
}
}
}
return Task.FromResult(assets.ToImmutableArray());
}
private static string ExtractPackageName(string purl)
{
// Extract package name from pkg:npm/@scope/name@version or pkg:npm/name@version
const string prefix = "pkg:npm/";
if (!purl.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return string.Empty;
var rest = purl[prefix.Length..];
// Handle scoped packages
if (rest.StartsWith("%40"))
{
// URL-encoded @ -> @
rest = "@" + rest[3..];
}
else if (rest.StartsWith("@"))
{
// Already has @
}
// Remove version
var versionIdx = rest.LastIndexOf('@');
if (versionIdx > 0 && (rest[0] != '@' || versionIdx > 1))
{
// For scoped: @scope/name@version - find the last @
// For non-scoped: name@version
var firstSlash = rest.IndexOf('/');
if (rest.StartsWith("@") && firstSlash > 0 && versionIdx > firstSlash)
{
rest = rest[..versionIdx];
}
else if (!rest.StartsWith("@"))
{
rest = rest[..versionIdx];
}
}
return rest;
}
private static bool ContainsAlgorithmName(string text, string algorithm)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(algorithm))
return false;
// Normalize for comparison
var normalizedText = text.Replace("-", "").Replace("_", "");
var normalizedAlgo = algorithm.Replace("-", "").Replace("_", "");
return normalizedText.Contains(normalizedAlgo, StringComparison.OrdinalIgnoreCase);
}
private static bool IsCryptoRelatedProperty(string key, string value)
{
var cryptoKeywords = new[] { "crypto", "cipher", "hash", "sign", "encrypt", "secret", "key", "algorithm" };
return cryptoKeywords.Any(k =>
key.Contains(k, StringComparison.OrdinalIgnoreCase) ||
value.Contains(k, StringComparison.OrdinalIgnoreCase));
}
private static IEnumerable<CryptoAsset> ExtractFromKnownPackage(AggregatedComponent component, string packageName)
{
var assets = new List<CryptoAsset>();
// Get algorithms for this package from our mapping
if (PackageAlgorithms.TryGetValue(packageName, out var algorithms))
{
foreach (var algoName in algorithms)
{
var pattern = AlgorithmPatterns.FirstOrDefault(p =>
p.Name.Equals(algoName, StringComparison.OrdinalIgnoreCase));
if (pattern != default)
{
assets.Add(CreateAssetFromPattern(component, pattern, $"package:{packageName}",
GetImplementationPlatform(packageName)));
}
else
{
// Create a generic asset for algorithms not in our pattern list
assets.Add(new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:{algoName}",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.Algorithm,
AlgorithmName = algoName,
Confidence = 0.8,
ImplementationPlatform = GetImplementationPlatform(packageName),
Evidence = ImmutableArray.Create($"package:{packageName}")
});
}
}
}
// Handle KMS packages specially
if (packageName.Contains("kms", StringComparison.OrdinalIgnoreCase) ||
packageName.Contains("keyvault", StringComparison.OrdinalIgnoreCase))
{
assets.Add(new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:kms",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.RelatedCryptoMaterial,
AlgorithmName = "KMS",
Confidence = 0.9,
ImplementationPlatform = packageName.Contains("aws") ? "AWS KMS" :
packageName.Contains("google") ? "Google Cloud KMS" :
packageName.Contains("azure") ? "Azure Key Vault" : "Cloud KMS",
Evidence = ImmutableArray.Create($"package:{packageName}")
});
}
return assets;
}
private static string GetImplementationPlatform(string packageName)
{
return packageName.ToLowerInvariant() switch
{
"tweetnacl" or "tweetnacl-util" => "TweetNaCl",
"libsodium" or "libsodium-wrappers" or "sodium-native" or "sodium-plus" => "libsodium",
"crypto-js" => "CryptoJS",
"forge" or "node-forge" => "Forge",
"sjcl" => "Stanford Javascript Crypto Library",
"elliptic" => "elliptic",
"@noble/secp256k1" or "@noble/ed25519" or "@noble/hashes" or "@noble/ciphers" or "@noble/curves" => "noble-crypto",
"openpgp" => "OpenPGP.js",
"jsrsasign" => "jsrsasign",
_ when packageName.StartsWith("@noble/") => "noble-crypto",
_ when packageName.StartsWith("@peculiar/") => "Peculiar",
_ => "Node.js Crypto"
};
}
private static CryptoAsset CreateAssetFromPattern(
AggregatedComponent component,
CryptoAlgorithmPattern pattern,
string? evidenceSource = null,
string? implementationPlatform = null)
{
var riskFlags = new List<CryptoRiskFlag>();
if (pattern.IsDeprecated)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "DEPRECATED_ALGORITHM",
Severity = CryptoRiskSeverity.Critical,
Description = $"{pattern.Name} is deprecated and should not be used",
Recommendation = GetDeprecatedRecommendation(pattern.Name)
});
}
if (pattern.IsWeak)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "WEAK_ALGORITHM",
Severity = CryptoRiskSeverity.High,
Description = $"{pattern.Name} is considered weak by modern standards",
Recommendation = GetWeakRecommendation(pattern.Name)
});
}
if (pattern.IsQuantumVulnerable)
{
riskFlags.Add(new CryptoRiskFlag
{
RiskId = "QUANTUM_VULNERABLE",
Severity = CryptoRiskSeverity.Medium,
Description = $"{pattern.Name} is vulnerable to quantum computing attacks",
Recommendation = "Consider migration path to post-quantum algorithms (ML-KEM, ML-DSA, SLH-DSA)"
});
}
var evidence = new List<string> { $"component:{component.Identity.Key}" };
if (evidenceSource != null)
{
evidence.Add(evidenceSource);
}
var platform = implementationPlatform ?? "Node.js Crypto";
var algorithmProperties = new AlgorithmProperties
{
Primitive = pattern.Primitive,
CryptoFunctions = ImmutableArray.Create(pattern.Function),
ClassicalSecurityLevel = pattern.KeySize,
ImplementationPlatform = platform,
ExecutionEnvironment = ExecutionEnvironment.Software
};
return new CryptoAsset
{
Id = $"crypto:{component.Identity.Key}:{pattern.Name}",
ComponentKey = component.Identity.Key,
AssetType = CryptoAssetType.Algorithm,
AlgorithmName = pattern.Name,
Oid = pattern.Oid,
KeySizeBits = pattern.KeySize,
Primitive = pattern.Primitive,
Functions = ImmutableArray.Create(pattern.Function),
ImplementationPlatform = platform,
ExecutionEnvironment = ExecutionEnvironment.Software,
Confidence = 0.9,
Evidence = evidence.ToImmutableArray(),
RiskFlags = riskFlags.ToImmutableArray(),
CryptoProperties = new CryptoProperties
{
AssetType = CryptoAssetType.Algorithm,
AlgorithmProperties = algorithmProperties,
Oid = pattern.Oid
}
};
}
private static string GetDeprecatedRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"MD5" => "Replace with SHA-256 or SHA-3",
"SHA1" or "SHA-1" => "Replace with SHA-256 or SHA-3",
"DES" => "Replace with AES-256-GCM",
"RC4" => "Replace with ChaCha20-Poly1305 or AES-GCM",
"RC2" => "Replace with AES-256-GCM",
_ => "Replace with a modern algorithm"
};
}
private static string GetWeakRecommendation(string algorithm)
{
return algorithm.ToUpperInvariant() switch
{
"TRIPLEDES" or "3DES" => "Replace with AES-256-GCM",
"AES/ECB" => "Use AES-GCM or AES-CTR mode instead of ECB",
"CAST5" => "Replace with AES-256-GCM",
"RIPEMD160" => "Replace with SHA-256 or BLAKE2b",
_ => "Consider using a stronger algorithm"
};
}
private sealed record CryptoAlgorithmPattern(
string Name,
string? Oid,
CryptoPrimitive Primitive,
CryptoFunction Function,
int KeySize,
bool IsDeprecated = false,
bool IsWeak = false,
bool IsQuantumVulnerable = false,
bool IsPostQuantum = false);
}

View File

@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Esprima" Version="3.0.5" />
<PackageReference Include="Esprima" />
</ItemGroup>
<ItemGroup>
@@ -29,5 +29,6 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -7,8 +7,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="plist-cil" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="plist-cil" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -7,8 +7,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="plist-cil" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="plist-cil" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -10,8 +10,8 @@
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.OS.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0-rc.1.24451.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -14,7 +14,7 @@
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -14,7 +14,7 @@
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -14,7 +14,7 @@
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />

View File

@@ -8,7 +8,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />

View File

@@ -7,7 +7,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -8,9 +8,6 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>Ground-truth corpus benchmarking infrastructure for reachability analysis</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
</ItemGroup>

View File

@@ -7,13 +7,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
</Project>

View File

@@ -130,13 +130,13 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
List<BinarySymbol> symbols,
CancellationToken ct)
{
var textSection = await BinaryTextSectionReader.TryReadAsync(path, format, ct);
var textSection = await Disassembly.BinaryTextSectionReader.TryReadAsync(path, format, ct);
if (textSection is null)
{
return Array.Empty<CallGraphEdge>();
}
if (textSection.Architecture == BinaryArchitecture.Unknown)
if (textSection.Architecture == Disassembly.BinaryArchitecture.Unknown)
{
_logger.LogDebug("Skipping disassembly; unknown architecture for {Path}", path);
return Array.Empty<CallGraphEdge>();
@@ -1007,7 +1007,7 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(node.NodeId, node);
}
// Add edges from relocations
// Add edges from relocations with loader rule explanations
foreach (var reloc in relocations)
{
var sourceSymbol = string.IsNullOrWhiteSpace(reloc.SourceSymbol)
@@ -1018,11 +1018,14 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
? $"native:external/{reloc.TargetSymbol}"
: $"native:{binaryName}/{reloc.TargetSymbol}";
var explanation = GuardDetector.ClassifyBinaryEdge(reloc.CallKind, reloc.TargetSymbol);
edges.Add(new CallGraphEdge(
SourceId: sourceId,
TargetId: targetId,
CallKind: reloc.CallKind,
CallSite: $"0x{reloc.Address:X}"));
CallSite: $"0x{reloc.Address:X}",
Explanation: explanation));
}
if (extraEdges.Count > 0)
@@ -1154,14 +1157,6 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
}
}
internal enum BinaryFormat
{
Unknown,
Elf,
Pe,
MachO
}
internal sealed class BinarySymbol
{
public required string Name { get; init; }

View File

@@ -1,11 +1,12 @@
using System.Text;
using StellaOps.Scanner.CallGraph.Binary;
using TextSection = StellaOps.Scanner.CallGraph.Binary.Disassembly.BinaryTextSection;
namespace StellaOps.Scanner.CallGraph.Binary.Disassembly;
internal static class BinaryTextSectionReader
{
public static async Task<BinaryTextSection?> TryReadAsync(
public static async Task<TextSection?> TryReadAsync(
string path,
BinaryFormat format,
CancellationToken ct)
@@ -21,7 +22,7 @@ internal static class BinaryTextSectionReader
};
}
private static async Task<BinaryTextSection?> TryReadElfTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadElfTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -114,7 +115,7 @@ internal static class BinaryTextSectionReader
stream.Seek(sectionOffset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)sectionSize);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
sectionAddress,
is64Bit ? 64 : 32,
@@ -150,7 +151,7 @@ internal static class BinaryTextSectionReader
return is64Bit ? reader.ReadInt64() : reader.ReadInt32();
}
private static async Task<BinaryTextSection?> TryReadPeTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadPeTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -221,7 +222,7 @@ internal static class BinaryTextSectionReader
stream.Seek(pointerToRawData, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)sizeOfRawData);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
virtualAddress,
is64Bit ? 64 : 32,
@@ -232,7 +233,7 @@ internal static class BinaryTextSectionReader
return null;
}
private static async Task<BinaryTextSection?> TryReadMachOTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadMachOTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -313,7 +314,7 @@ internal static class BinaryTextSectionReader
stream.Seek(offset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)size);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
addr,
64,
@@ -354,7 +355,7 @@ internal static class BinaryTextSectionReader
stream.Seek(offset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)size);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
addr,
32,

View File

@@ -90,16 +90,21 @@ public sealed class FunctionBoundaryDetector
_logger.LogDebug("Found {Count} functions via DWARF", dwarfInfo.Functions.Count);
foreach (var func in dwarfInfo.Functions)
{
// Look up source file from index (0 = no file)
var sourceFile = func.DeclFile > 0 && func.DeclFile <= dwarfInfo.SourceFiles.Count
? dwarfInfo.SourceFiles[(int)func.DeclFile - 1]
: null;
functions.Add(new DetectedFunction
{
Symbol = func.Name,
MangledName = func.LinkageName,
StartAddress = func.LowPc,
EndAddress = func.HighPc,
StartAddress = (long)func.LowPc,
EndAddress = (long)func.HighPc,
Confidence = _options.DwarfConfidence,
DetectionMethod = FunctionDetectionMethod.Dwarf,
SourceFile = func.DeclFile,
SourceLine = func.DeclLine
SourceFile = sourceFile,
SourceLine = func.DeclLine > 0 ? (int?)func.DeclLine : null
});
}
return functions;

View File

@@ -88,11 +88,15 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
var targetNode = CreateInvokedNode(analysisRoot, invoked);
nodesById.TryAdd(targetNode.NodeId, targetNode);
var callKind = ClassifyCallKind(invoked);
var explanation = ClassifyDotNetEdge(invoked, invocation, callKind);
edges.Add(new CallGraphEdge(
SourceId: methodNode.NodeId,
TargetId: targetNode.NodeId,
CallKind: ClassifyCallKind(invoked),
CallSite: FormatCallSite(analysisRoot, invocation)));
CallKind: callKind,
CallSite: FormatCallSite(analysisRoot, invocation),
Explanation: explanation));
}
}
}
@@ -197,6 +201,105 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
return CallKind.Direct;
}
private static CallEdgeExplanation ClassifyDotNetEdge(
IMethodSymbol invoked,
InvocationExpressionSyntax invocation,
CallKind callKind)
{
var containingType = invoked.ContainingType?.ToDisplayString() ?? string.Empty;
var methodName = invoked.Name;
// Reflection-based calls
if (IsReflectionCall(containingType, methodName))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Dynamic assembly/type loading
if (IsDynamicLoading(containingType, methodName))
{
return CallEdgeExplanation.DynamicLoad(0.6);
}
// Check for platform guards
if (IsPlatformGuard(containingType, methodName))
{
return CallEdgeExplanation.PlatformArch("conditional", 0.95);
}
// Check enclosing context for guards
var context = GetInvocationContext(invocation);
if (!string.IsNullOrEmpty(context))
{
var guard = GuardDetector.DetectDotNetGuard(context, null);
if (guard is not null)
{
return guard;
}
}
// Delegate invocations
if (callKind == CallKind.Delegate)
{
return CallEdgeExplanation.DynamicLoad(0.7);
}
// Virtual dispatch
if (callKind == CallKind.Virtual)
{
return new CallEdgeExplanation(CallEdgeExplanationType.DirectCall, 0.9);
}
return CallEdgeExplanation.DirectCall();
}
private static bool IsReflectionCall(string containingType, string methodName)
{
return containingType switch
{
"System.Type" when methodName is "GetType" or "GetMethod" or "GetProperty" => true,
"System.Reflection.MethodInfo" when methodName is "Invoke" => true,
"System.Activator" when methodName is "CreateInstance" => true,
"System.Reflection.Assembly" when methodName is "Load" or "LoadFrom" or "LoadFile" => true,
_ => false
};
}
private static bool IsDynamicLoading(string containingType, string methodName)
{
return containingType switch
{
"System.Reflection.Assembly" when methodName is "Load" or "LoadFrom" or "LoadFile" => true,
"System.Runtime.Loader.AssemblyLoadContext" when methodName is "LoadFromAssemblyPath" => true,
_ => false
};
}
private static bool IsPlatformGuard(string containingType, string methodName)
{
return containingType is "System.Runtime.InteropServices.RuntimeInformation"
&& methodName is "IsOSPlatform";
}
private static string? GetInvocationContext(InvocationExpressionSyntax invocation)
{
// Look for enclosing if statement with environment/platform check
var current = invocation.Parent;
while (current is not null)
{
if (current is IfStatementSyntax ifStatement)
{
return ifStatement.Condition.ToFullString();
}
if (current is ConditionalExpressionSyntax conditional)
{
return conditional.Condition.ToFullString();
}
current = current.Parent;
}
return null;
}
private static CallGraphNode CreateMethodNode(string analysisRoot, IMethodSymbol method, MethodDeclarationSyntax syntax)
{
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));

View File

@@ -0,0 +1,249 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Detects environment guards, feature flags, and platform checks in source code.
/// Used to classify edge explanations in call graph extraction.
/// </summary>
public static partial class GuardDetector
{
/// <summary>
/// Detects guards in JavaScript/TypeScript code context.
/// </summary>
public static CallEdgeExplanation? DetectJavaScriptGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// Environment variable checks: process.env.X, process.env['X']
var envMatch = JsEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=truthy");
}
// Platform checks: process.platform === 'linux'
var platformMatch = JsPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform);
}
// Dynamic require/import: require(variable), import(variable)
if (JsDynamicImportPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Feature flag patterns: config.enableX, flags.featureX
var featureFlagMatch = JsFeatureFlagPattern().Match(sourceContext);
if (featureFlagMatch.Success)
{
var flag = featureFlagMatch.Groups["flag"].Value;
return CallEdgeExplanation.FeatureFlag($"{flag}=true", 0.85);
}
return null;
}
/// <summary>
/// Detects guards in Python code context.
/// </summary>
public static CallEdgeExplanation? DetectPythonGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// os.environ.get('X'), os.getenv('X')
var envMatch = PyEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=truthy");
}
// sys.platform checks
var platformMatch = PyPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform);
}
// importlib.import_module(variable)
if (PyDynamicImportPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Feature flag patterns: settings.FEATURE_X, config['enable_feature']
var featureFlagMatch = PyFeatureFlagPattern().Match(sourceContext);
if (featureFlagMatch.Success)
{
var flag = featureFlagMatch.Groups["flag"].Value;
return CallEdgeExplanation.FeatureFlag($"{flag}=True", 0.85);
}
return null;
}
/// <summary>
/// Detects guards in Java code context.
/// </summary>
public static CallEdgeExplanation? DetectJavaGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// System.getenv("X")
var envMatch = JavaEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=present");
}
// System.getProperty("X")
var propertyMatch = JavaPropertyPattern().Match(sourceContext);
if (propertyMatch.Success)
{
var prop = propertyMatch.Groups["prop"].Value;
return CallEdgeExplanation.FeatureFlag($"{prop}=true", 0.85);
}
// System.getProperty("os.name")
var osMatch = JavaOsPattern().Match(sourceContext);
if (osMatch.Success)
{
var os = osMatch.Groups["os"].Value;
return CallEdgeExplanation.PlatformArch(os.ToLowerInvariant());
}
// Class.forName(variable), classLoader.loadClass(variable)
if (JavaReflectionPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
return null;
}
/// <summary>
/// Detects guards in C#/.NET code context.
/// </summary>
public static CallEdgeExplanation? DetectDotNetGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// Environment.GetEnvironmentVariable("X")
var envMatch = DotNetEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=present");
}
// RuntimeInformation.IsOSPlatform(OSPlatform.X)
var platformMatch = DotNetPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform.ToLowerInvariant());
}
// configuration["FeatureFlags:X"]
var configMatch = DotNetConfigPattern().Match(sourceContext);
if (configMatch.Success)
{
var key = configMatch.Groups["key"].Value;
return CallEdgeExplanation.FeatureFlag($"{key}=true", 0.85);
}
// Type.GetType(variable), Activator.CreateInstance(variable)
if (DotNetReflectionPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
return null;
}
/// <summary>
/// Classifies binary edge types based on loader mechanism.
/// </summary>
public static CallEdgeExplanation ClassifyBinaryEdge(CallKind callKind, string? targetSymbol)
{
return callKind switch
{
CallKind.Plt => CallEdgeExplanation.LoaderRule("PLT",
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty
.Add("loader", "PLT")
.Add("symbol", targetSymbol ?? "unknown")),
CallKind.Iat => CallEdgeExplanation.LoaderRule("IAT",
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty
.Add("loader", "IAT")
.Add("symbol", targetSymbol ?? "unknown")),
CallKind.Dynamic => CallEdgeExplanation.DynamicLoad(0.6),
CallKind.Reflection => CallEdgeExplanation.ReflectionCall(0.5),
_ => CallEdgeExplanation.DirectCall()
};
}
// JavaScript patterns
[GeneratedRegex(@"process\.env(?:\.(?<var>\w+)|\[(?<quote>['""])(?<var>\w+)\k<quote>\])", RegexOptions.Compiled)]
private static partial Regex JsEnvVarPattern();
[GeneratedRegex(@"process\.platform\s*===?\s*['""](?<platform>\w+)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JsPlatformPattern();
[GeneratedRegex(@"(?:require|import)\s*\(\s*[^'""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex JsDynamicImportPattern();
[GeneratedRegex(@"(?:config|flags|features|settings|options)\s*\.\s*(?<flag>enable\w+|feature\w+|use\w+|is\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JsFeatureFlagPattern();
// Python patterns
[GeneratedRegex(@"os\.(?:environ\.get|getenv)\s*\(\s*['""](?<var>\w+)['""]", RegexOptions.Compiled)]
private static partial Regex PyEnvVarPattern();
[GeneratedRegex(@"sys\.platform\s*==\s*['""](?<platform>\w+)['""]", RegexOptions.Compiled)]
private static partial Regex PyPlatformPattern();
[GeneratedRegex(@"importlib\.import_module\s*\(\s*[^'""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex PyDynamicImportPattern();
[GeneratedRegex(@"(?:settings|config|flags)\s*(?:\.|(?:\[(?<quote>['""]))|(?:\.get\s*\(\s*['""]))\s*(?<flag>FEATURE_\w+|ENABLE_\w+|USE_\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex PyFeatureFlagPattern();
// Java patterns
[GeneratedRegex(@"System\.getenv\s*\(\s*""(?<var>\w+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaEnvVarPattern();
[GeneratedRegex(@"System\.getProperty\s*\(\s*""(?<prop>[\w.]+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaPropertyPattern();
[GeneratedRegex(@"System\.getProperty\s*\(\s*""os\.name""\s*\).*?(?:contains|startsWith|equals)\s*\(\s*""(?<os>\w+)""", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JavaOsPattern();
[GeneratedRegex(@"(?:Class\.forName|classLoader\.loadClass|getClass\(\)\.getMethod)\s*\(\s*[^""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaReflectionPattern();
// .NET patterns
[GeneratedRegex(@"Environment\.GetEnvironmentVariable\s*\(\s*""(?<var>\w+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetEnvVarPattern();
[GeneratedRegex(@"RuntimeInformation\.IsOSPlatform\s*\(\s*OSPlatform\.(?<platform>\w+)\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetPlatformPattern();
[GeneratedRegex(@"configuration\s*\[\s*""(?<key>[^""]+)""\s*\]", RegexOptions.Compiled)]
private static partial Regex DotNetConfigPattern();
[GeneratedRegex(@"(?:Type\.GetType|Activator\.CreateInstance|Assembly\.Load)\s*\(\s*[^""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetReflectionPattern();
}

View File

@@ -115,9 +115,12 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(nodeId, node);
// Add edges for method invocations
// Add edges for method invocations with edge explanations
foreach (var call in method.Calls)
{
var callKind = MapCallKind(call.Opcode);
var explanation = ClassifyJavaEdge(call, callKind);
// Only include edges to internal methods
if (!packageClasses.Contains(call.TargetClass))
{
@@ -141,8 +144,9 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
edges.Add(new CallGraphEdge(
SourceId: nodeId,
TargetId: sinkNodeId,
CallKind: MapCallKind(call.Opcode),
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
CallKind: callKind,
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}",
Explanation: explanation));
}
continue;
}
@@ -151,8 +155,9 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
edges.Add(new CallGraphEdge(
SourceId: nodeId,
TargetId: targetNodeId,
CallKind: MapCallKind(call.Opcode),
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
CallKind: callKind,
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}",
Explanation: explanation));
}
}
}
@@ -306,4 +311,60 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
_ => CallKind.Direct
};
}
private static CallEdgeExplanation ClassifyJavaEdge(JavaMethodCall call, CallKind callKind)
{
// Reflection-based calls
if (IsReflectionCall(call.TargetClass, call.MethodName))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Dynamic class loading
if (IsDynamicLoading(call.TargetClass, call.MethodName))
{
return CallEdgeExplanation.DynamicLoad(0.6);
}
// Check for environment/property guard patterns in context
if (!string.IsNullOrEmpty(call.Context))
{
var guard = GuardDetector.DetectJavaGuard(call.Context, null);
if (guard is not null)
{
return guard;
}
}
// InvokeDynamic (lambda, method references)
if (callKind == CallKind.Delegate)
{
return CallEdgeExplanation.DynamicLoad(0.7);
}
// Default direct call
return CallEdgeExplanation.DirectCall();
}
private static bool IsReflectionCall(string targetClass, string methodName)
{
return targetClass switch
{
"java.lang.Class" when methodName is "forName" or "getDeclaredMethod" or "getMethod" => true,
"java.lang.reflect.Method" when methodName is "invoke" => true,
"java.lang.reflect.Constructor" when methodName is "newInstance" => true,
_ => false
};
}
private static bool IsDynamicLoading(string targetClass, string methodName)
{
return targetClass switch
{
"java.lang.ClassLoader" when methodName is "loadClass" => true,
"java.net.URLClassLoader" when methodName is "loadClass" => true,
"java.util.ServiceLoader" when methodName is "load" or "iterator" => true,
_ => false
};
}
}

View File

@@ -152,6 +152,12 @@ public sealed record JavaMethodCall
/// Whether this call uses invokedynamic.
/// </summary>
public bool IsDynamic { get; init; }
/// <summary>
/// Context around the call site for guard detection.
/// Contains surrounding bytecode context that may indicate conditional execution.
/// </summary>
public string? Context { get; init; }
}
/// <summary>

View File

@@ -147,6 +147,12 @@ public sealed record JsNodeInfo
/// Decorators or annotations.
/// </summary>
public IReadOnlyList<string> Annotations { get; init; } = [];
/// <summary>
/// Condition context if this function is inside a conditional block
/// (e.g., if (process.env.FEATURE_X) { ... }).
/// </summary>
public string? ConditionContext { get; init; }
}
/// <summary>
@@ -173,6 +179,12 @@ public sealed record JsEdgeInfo
/// Call site position.
/// </summary>
public JsPositionInfo? Site { get; init; }
/// <summary>
/// Context around the call (for guard detection).
/// Contains surrounding code that may include conditionals.
/// </summary>
public string? Context { get; init; }
}
/// <summary>

View File

@@ -192,12 +192,18 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
SinkCategory: MapSinkCategory(sinkCategory));
}).ToList();
// Convert edges
var edges = result.Edges.Select(e => new CallGraphEdge(
CallGraphNodeIds.Compute(e.From),
CallGraphNodeIds.Compute(e.To),
MapCallKind(e.Kind)
)).ToList();
// Convert edges with explanations
var edges = result.Edges.Select(e =>
{
var callKind = MapCallKind(e.Kind);
var explanation = ClassifyEdge(e, callKind, result.Nodes);
return new CallGraphEdge(
CallGraphNodeIds.Compute(e.From),
CallGraphNodeIds.Compute(e.To),
callKind,
null,
explanation);
}).ToList();
// Create sink nodes for detected sinks (these may not be in the nodes list)
foreach (var sink in result.Sinks)
@@ -219,9 +225,14 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
IsSink: true,
SinkCategory: MapSinkCategory(sink.Category)));
// Add edge from caller to sink
// Add edge from caller to sink with explanation
var callerNodeId = CallGraphNodeIds.Compute(sink.Caller);
edges.Add(new CallGraphEdge(callerNodeId, sinkNodeId, CallKind.Direct));
edges.Add(new CallGraphEdge(
callerNodeId,
sinkNodeId,
CallKind.Direct,
null,
CallEdgeExplanation.DirectCall()));
}
}
@@ -311,6 +322,49 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
_ => null
};
private static CallEdgeExplanation ClassifyEdge(JsEdgeInfo edge, CallKind callKind, IReadOnlyList<JsNodeInfo> nodes)
{
// Check for dynamic imports
if (callKind == CallKind.Dynamic)
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Check for reflection-based calls
if (callKind == CallKind.Reflection)
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Check for guard conditions in the edge context
if (!string.IsNullOrEmpty(edge.Context))
{
var guard = GuardDetector.DetectJavaScriptGuard(edge.Context, null);
if (guard is not null)
{
return guard;
}
}
// Check source node for conditional context
var sourceNode = nodes.FirstOrDefault(n => n.Id == edge.From);
if (sourceNode?.ConditionContext is not null)
{
var guard = GuardDetector.DetectJavaScriptGuard(sourceNode.ConditionContext, null);
if (guard is not null)
{
return guard;
}
}
// Default: static import for module imports, direct call otherwise
return edge.Kind?.ToLowerInvariant() switch
{
"import" or "require" => CallEdgeExplanation.Import(),
_ => CallEdgeExplanation.DirectCall()
};
}
private static string? ResolveProjectDirectory(string targetPath)
{
if (string.IsNullOrWhiteSpace(targetPath))
@@ -380,7 +434,7 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
IsSink: sink is not null,
SinkCategory: sink?.Category));
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct));
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct, null, CallEdgeExplanation.DirectCall()));
previousId = nodeId;
}

View File

@@ -93,14 +93,16 @@ public sealed class PythonCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(node.NodeId, node);
// Extract function calls
// Extract function calls with edge explanations
foreach (var call in func.Calls)
{
var explanation = ClassifyPythonEdge(func, call);
edges.Add(new CallGraphEdge(
SourceId: func.NodeId,
TargetId: call.TargetNodeId,
CallKind: CallKind.Direct,
CallSite: $"{relativePath}:{call.Line}"));
CallSite: $"{relativePath}:{call.Line}",
Explanation: explanation));
}
}
}
@@ -409,6 +411,41 @@ public sealed class PythonCallGraphExtractor : ICallGraphExtractor
// Simplified: would need proper AST parsing for accurate results
return [];
}
private static CallEdgeExplanation ClassifyPythonEdge(PythonFunctionInfo func, PythonCallInfo call)
{
// Check for guard conditions in function context
if (!string.IsNullOrEmpty(call.Context))
{
var guard = GuardDetector.DetectPythonGuard(call.Context, null);
if (guard is not null)
{
return guard;
}
}
// Check if this is a dynamic import
if (call.IsDynamicImport)
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Check decorator context for conditional patterns
if (func.Decorators.Count > 0)
{
var decoratorContext = string.Join(" ", func.Decorators);
var guard = GuardDetector.DetectPythonGuard(decoratorContext, null);
if (guard is not null)
{
return guard;
}
}
// Default to import for module-level, direct call for others
return func.IsRouteHandler
? CallEdgeExplanation.DirectCall()
: CallEdgeExplanation.Import();
}
}
internal sealed class PythonProjectInfo
@@ -437,4 +474,6 @@ internal sealed class PythonCallInfo
{
public required string TargetNodeId { get; init; }
public int Line { get; init; }
public string? Context { get; init; }
public bool IsDynamicImport { get; init; }
}

View File

@@ -96,14 +96,16 @@ public sealed record CallGraphEdge(
[property: JsonPropertyName("sourceId")] string SourceId,
[property: JsonPropertyName("targetId")] string TargetId,
[property: JsonPropertyName("callKind")] CallKind CallKind,
[property: JsonPropertyName("callSite")] string? CallSite = null)
[property: JsonPropertyName("callSite")] string? CallSite = null,
[property: JsonPropertyName("explanation")] CallEdgeExplanation? Explanation = null)
{
public CallGraphEdge Trimmed()
=> this with
{
SourceId = SourceId?.Trim() ?? string.Empty,
TargetId = TargetId?.Trim() ?? string.Empty,
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim()
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim(),
Explanation = Explanation?.Trimmed()
};
}
@@ -128,6 +130,110 @@ public enum CallKind
Iat
}
/// <summary>
/// Explanation type for call graph edges.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<CallEdgeExplanationType>))]
public enum CallEdgeExplanationType
{
/// <summary>Static import (ES6 import, Python import, using directive).</summary>
Import,
/// <summary>Dynamic load (require(), dlopen, LoadLibrary).</summary>
DynamicLoad,
/// <summary>Reflection invocation (Class.forName, Type.GetType).</summary>
Reflection,
/// <summary>Foreign function interface (JNI, P/Invoke, ctypes).</summary>
Ffi,
/// <summary>Environment variable guard (process.env.X, os.environ.get).</summary>
EnvGuard,
/// <summary>Feature flag check (LaunchDarkly, unleash, custom flags).</summary>
FeatureFlag,
/// <summary>Platform/architecture guard (process.platform, runtime.GOOS).</summary>
PlatformArch,
/// <summary>Taint gate (sanitization, validation).</summary>
TaintGate,
/// <summary>Loader rule (PLT/IAT/GOT entry).</summary>
LoaderRule,
/// <summary>Direct call (static, virtual, delegate).</summary>
DirectCall,
/// <summary>Cannot determine explanation type.</summary>
Unknown
}
/// <summary>
/// Explanation for why an edge exists in the call graph.
/// </summary>
public sealed record CallEdgeExplanation(
[property: JsonPropertyName("type")] CallEdgeExplanationType Type,
[property: JsonPropertyName("confidence")] double Confidence,
[property: JsonPropertyName("guard")] string? Guard = null,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Creates a simple direct call explanation with full confidence.
/// </summary>
public static CallEdgeExplanation DirectCall() =>
new(CallEdgeExplanationType.DirectCall, 1.0);
/// <summary>
/// Creates an import explanation with full confidence.
/// </summary>
public static CallEdgeExplanation Import(string? location = null) =>
new(CallEdgeExplanationType.Import, 1.0);
/// <summary>
/// Creates a dynamic load explanation with medium confidence.
/// </summary>
public static CallEdgeExplanation DynamicLoad(double confidence = 0.5) =>
new(CallEdgeExplanationType.DynamicLoad, confidence);
/// <summary>
/// Creates an environment guard explanation.
/// </summary>
public static CallEdgeExplanation EnvGuard(string guard, double confidence = 0.9) =>
new(CallEdgeExplanationType.EnvGuard, confidence, guard);
/// <summary>
/// Creates a feature flag explanation.
/// </summary>
public static CallEdgeExplanation FeatureFlag(string flag, double confidence = 0.85) =>
new(CallEdgeExplanationType.FeatureFlag, confidence, flag);
/// <summary>
/// Creates a platform/architecture guard explanation.
/// </summary>
public static CallEdgeExplanation PlatformArch(string platform, double confidence = 0.95) =>
new(CallEdgeExplanationType.PlatformArch, confidence, $"platform={platform}");
/// <summary>
/// Creates a reflection explanation.
/// </summary>
public static CallEdgeExplanation ReflectionCall(double confidence = 0.5) =>
new(CallEdgeExplanationType.Reflection, confidence);
/// <summary>
/// Creates a loader rule explanation (PLT/IAT/GOT).
/// </summary>
public static CallEdgeExplanation LoaderRule(string loaderType, ImmutableDictionary<string, string>? metadata = null) =>
new(CallEdgeExplanationType.LoaderRule, 0.8, null, metadata ?? ImmutableDictionary<string, string>.Empty.Add("loader", loaderType));
public CallEdgeExplanation Trimmed() =>
this with
{
Guard = string.IsNullOrWhiteSpace(Guard) ? null : Guard.Trim()
};
}
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
public enum EntrypointType
{
@@ -228,6 +334,28 @@ public static class CallGraphDigests
{
writer.WriteString("callSite", edge.CallSite);
}
if (edge.Explanation is not null)
{
writer.WritePropertyName("explanation");
writer.WriteStartObject();
writer.WriteString("type", edge.Explanation.Type.ToString());
writer.WriteNumber("confidence", edge.Explanation.Confidence);
if (!string.IsNullOrWhiteSpace(edge.Explanation.Guard))
{
writer.WriteString("guard", edge.Explanation.Guard);
}
if (edge.Explanation.Metadata is { Count: > 0 })
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var kv in edge.Explanation.Metadata.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
writer.WriteString(kv.Key, kv.Value);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndArray();

View File

@@ -12,17 +12,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gee.External.Capstone" Version="2.3.0" />
<PackageReference Include="Iced" Version="1.21.0" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" 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="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="Gee.External.Capstone" />
<PackageReference Include="Iced" />
<PackageReference Include="Microsoft.Build.Locator" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,8 +8,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />

View File

@@ -8,6 +8,48 @@ Assemble deterministic SBOM artifacts (inventory, usage, BOM index) from analyze
- Generate BOM index sidecars with roaring bitmap acceleration and usage flags.
- Package artifacts with stable naming, hashing, and manifests for downstream storage and attestations.
- Surface helper APIs for Scanner Worker/WebService to request compositions and exports.
- **CBOM Support**: Generate CycloneDX 1.7 Cryptographic BOM with `cryptographicProperties` for crypto asset inventory.
## CBOM (Cryptographic BOM) Support
The Emit module supports CycloneDX 1.7 CBOM generation for cryptographic asset inventory:
### Key Components
| Component | Path | Purpose |
|-----------|------|---------|
| `ICryptoAssetExtractor` | `Cbom/ICryptoAssetExtractor.cs` | Interface for language-specific crypto extraction |
| `CryptoProperties` | `Cbom/CryptoProperties.cs` | CycloneDX 1.7 crypto schema types |
| `CbomAggregationService` | `Cbom/CbomAggregationService.cs` | Aggregates crypto assets with risk assessment |
| `CycloneDxCbomWriter` | `Composition/CycloneDxCbomWriter.cs` | Injects cryptographicProperties into CycloneDX JSON |
### Crypto Extractors
Language-specific extractors implement `ICryptoAssetExtractor`:
- `DotNetCryptoExtractor`: System.Security.Cryptography patterns
- `JavaCryptoExtractor`: BouncyCastle, JWT libraries, JCA patterns
- `NodeCryptoExtractor`: npm crypto packages (bcrypt, crypto-js, sodium, etc.)
### Usage Pattern
```csharp
// 1. Aggregate crypto assets from components
var cbomService = new CbomAggregationService(extractors, logger);
var cbomResult = await cbomService.AggregateAsync(components, context);
// 2. Inject into CycloneDX output
var enhancedJson = CycloneDxCbomWriter.InjectCbom(
cycloneDxJson,
cbomResult.ByComponent);
```
### Risk Assessment
The aggregation service automatically assesses crypto risk:
- **Deprecated**: MD5, SHA-1, DES, RC2, RC4
- **Weak**: Small key sizes, ECB mode, unauthenticated encryption
- **Quantum Vulnerable**: RSA, DSA, ECDSA, ECDH, DH
- **Post-Quantum Ready**: ML-KEM, ML-DSA, SLH-DSA, SPHINCS+
## Interfaces & Dependencies
- Consumes analyzer outputs (OS, language, native) and EntryTrace usage annotations.

View File

@@ -0,0 +1,364 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Cbom;
/// <summary>
/// Service for aggregating crypto assets from all extractors into a unified CBOM.
/// </summary>
public interface ICbomAggregationService
{
/// <summary>
/// Aggregates crypto assets from all components.
/// </summary>
Task<CbomAggregationResult> AggregateAsync(
ImmutableArray<AggregatedComponent> components,
CryptoAnalysisContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Computes risk assessment for crypto assets.
/// </summary>
CryptoRiskAssessment AssessRisk(ImmutableArray<CryptoAsset> assets);
}
/// <summary>
/// Result of CBOM aggregation.
/// </summary>
public sealed record CbomAggregationResult
{
/// <summary>All discovered crypto assets.</summary>
public required ImmutableArray<CryptoAsset> Assets { get; init; }
/// <summary>Assets grouped by component.</summary>
public required ImmutableDictionary<string, ImmutableArray<CryptoAsset>> ByComponent { get; init; }
/// <summary>Unique algorithms discovered.</summary>
public required ImmutableArray<string> UniqueAlgorithms { get; init; }
/// <summary>Risk assessment summary.</summary>
public CryptoRiskAssessment? RiskAssessment { get; init; }
/// <summary>Timestamp of aggregation (UTC ISO 8601).</summary>
public required string GeneratedAt { get; init; }
}
/// <summary>
/// Crypto risk assessment for the entire CBOM.
/// </summary>
public sealed record CryptoRiskAssessment
{
/// <summary>Overall risk score (0-100).</summary>
public double OverallScore { get; init; }
/// <summary>Count of critical risk items.</summary>
public int CriticalCount { get; init; }
/// <summary>Count of high risk items.</summary>
public int HighCount { get; init; }
/// <summary>Count of medium risk items.</summary>
public int MediumCount { get; init; }
/// <summary>Count of low risk items.</summary>
public int LowCount { get; init; }
/// <summary>Deprecated algorithms found.</summary>
public ImmutableArray<string> DeprecatedAlgorithms { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Weak algorithms found.</summary>
public ImmutableArray<string> WeakAlgorithms { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Quantum-vulnerable algorithms found.</summary>
public ImmutableArray<string> QuantumVulnerableAlgorithms { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Post-quantum ready algorithms found.</summary>
public ImmutableArray<string> PostQuantumAlgorithms { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Migration recommendations.</summary>
public ImmutableArray<CryptoMigrationRecommendation> MigrationRecommendations { get; init; } = ImmutableArray<CryptoMigrationRecommendation>.Empty;
}
/// <summary>
/// Migration recommendation for crypto modernization.
/// </summary>
public sealed record CryptoMigrationRecommendation
{
/// <summary>Current algorithm/protocol.</summary>
public required string From { get; init; }
/// <summary>Recommended replacement.</summary>
public required string To { get; init; }
/// <summary>Priority level.</summary>
public required CryptoRiskSeverity Priority { get; init; }
/// <summary>Reason for migration.</summary>
public required string Reason { get; init; }
}
/// <summary>
/// Default implementation of CBOM aggregation service.
/// </summary>
public sealed class CbomAggregationService : ICbomAggregationService
{
private readonly IEnumerable<ICryptoAssetExtractor> _extractors;
private readonly ILogger<CbomAggregationService> _logger;
public CbomAggregationService(
IEnumerable<ICryptoAssetExtractor> extractors,
ILogger<CbomAggregationService> logger)
{
_extractors = extractors;
_logger = logger;
}
public async Task<CbomAggregationResult> AggregateAsync(
ImmutableArray<AggregatedComponent> components,
CryptoAnalysisContext context,
CancellationToken cancellationToken = default)
{
var allAssets = new List<CryptoAsset>();
var byComponent = new Dictionary<string, List<CryptoAsset>>();
foreach (var component in components)
{
var componentAssets = new List<CryptoAsset>();
foreach (var extractor in _extractors)
{
try
{
var assets = await extractor.ExtractAsync(component, context, cancellationToken);
componentAssets.AddRange(assets);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Crypto extraction failed for {Component} using {Extractor}",
component.Identity.Key, extractor.GetType().Name);
}
}
if (componentAssets.Count > 0)
{
allAssets.AddRange(componentAssets);
byComponent[component.Identity.Key] = componentAssets;
}
}
var assetsArray = allAssets.ToImmutableArray();
var uniqueAlgorithms = assetsArray
.Where(a => !string.IsNullOrEmpty(a.AlgorithmName))
.Select(a => a.AlgorithmName!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.Ordinal)
.ToImmutableArray();
var byComponentImmutable = byComponent
.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.ToImmutableArray(),
StringComparer.Ordinal);
return new CbomAggregationResult
{
Assets = assetsArray,
ByComponent = byComponentImmutable,
UniqueAlgorithms = uniqueAlgorithms,
RiskAssessment = AssessRisk(assetsArray),
GeneratedAt = DateTimeOffset.UtcNow.ToString("o")
};
}
public CryptoRiskAssessment AssessRisk(ImmutableArray<CryptoAsset> assets)
{
var deprecated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var weak = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var quantumVulnerable = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var postQuantum = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var recommendations = new List<CryptoMigrationRecommendation>();
int criticalCount = 0, highCount = 0, mediumCount = 0, lowCount = 0;
foreach (var asset in assets)
{
var alg = asset.AlgorithmName?.ToUpperInvariant() ?? string.Empty;
// Check for deprecated algorithms
if (IsDeprecatedAlgorithm(alg))
{
deprecated.Add(asset.AlgorithmName ?? alg);
criticalCount++;
AddMigrationRecommendation(recommendations, asset.AlgorithmName ?? alg, CryptoRiskSeverity.Critical);
}
// Check for weak algorithms
else if (IsWeakAlgorithm(alg, asset.KeySizeBits))
{
weak.Add(asset.AlgorithmName ?? alg);
highCount++;
AddMigrationRecommendation(recommendations, asset.AlgorithmName ?? alg, CryptoRiskSeverity.High);
}
// Check quantum vulnerability
else if (IsQuantumVulnerable(alg, asset.Primitive))
{
quantumVulnerable.Add(asset.AlgorithmName ?? alg);
mediumCount++;
}
// Check post-quantum readiness
else if (IsPostQuantumReady(alg, asset.Primitive))
{
postQuantum.Add(asset.AlgorithmName ?? alg);
}
else
{
lowCount++;
}
// Count existing risk flags
foreach (var flag in asset.RiskFlags)
{
switch (flag.Severity)
{
case CryptoRiskSeverity.Critical: criticalCount++; break;
case CryptoRiskSeverity.High: highCount++; break;
case CryptoRiskSeverity.Medium: mediumCount++; break;
case CryptoRiskSeverity.Low: lowCount++; break;
}
}
}
// Compute overall score (0 = best, 100 = worst)
double overallScore = 0;
overallScore += criticalCount * 25;
overallScore += highCount * 10;
overallScore += mediumCount * 5;
overallScore += lowCount * 1;
overallScore = Math.Min(100, overallScore);
return new CryptoRiskAssessment
{
OverallScore = overallScore,
CriticalCount = criticalCount,
HighCount = highCount,
MediumCount = mediumCount,
LowCount = lowCount,
DeprecatedAlgorithms = deprecated.Order().ToImmutableArray(),
WeakAlgorithms = weak.Order().ToImmutableArray(),
QuantumVulnerableAlgorithms = quantumVulnerable.Order().ToImmutableArray(),
PostQuantumAlgorithms = postQuantum.Order().ToImmutableArray(),
MigrationRecommendations = recommendations.ToImmutableArray()
};
}
private static bool IsDeprecatedAlgorithm(string alg)
{
return alg switch
{
"MD5" or "MD4" or "MD2" => true,
"SHA1" or "SHA-1" => true,
"DES" or "3DES" or "TRIPLEDES" or "TRIPLE-DES" => true,
"RC2" or "RC4" => true,
"BLOWFISH" => true,
_ when alg.Contains("MD5") => true,
_ when alg.Contains("SHA1") || alg.Contains("SHA-1") => true,
_ when alg.Contains("DES") && !alg.Contains("AES") => true,
_ => false
};
}
private static bool IsWeakAlgorithm(string alg, int? keySize)
{
// RSA with key size < 2048
if ((alg.Contains("RSA") || alg == "RSA") && keySize.HasValue && keySize.Value < 2048)
return true;
// AES with key size < 128
if ((alg.Contains("AES") || alg == "AES") && keySize.HasValue && keySize.Value < 128)
return true;
// ECDSA/ECDH with curve < 256 bits
if ((alg.Contains("ECD") || alg.Contains("EC-")) && keySize.HasValue && keySize.Value < 256)
return true;
return false;
}
private static bool IsQuantumVulnerable(string alg, CryptoPrimitive? primitive)
{
// All asymmetric algorithms based on factoring or discrete log are quantum-vulnerable
if (primitive is CryptoPrimitive.Rsa or CryptoPrimitive.Dlog or CryptoPrimitive.Ec)
return true;
return alg switch
{
"RSA" or "DSA" or "ECDSA" or "ECDH" or "DH" or "ECDHE" or "DHE" => true,
_ when alg.Contains("RSA") => true,
_ when alg.Contains("ECDSA") || alg.Contains("ECDH") => true,
_ when alg.Contains("DSA") && !alg.Contains("ML-DSA") => true,
_ => false
};
}
private static bool IsPostQuantumReady(string alg, CryptoPrimitive? primitive)
{
if (primitive is CryptoPrimitive.Lattice)
return true;
// NIST post-quantum standards
return alg switch
{
"ML-KEM" or "ML-DSA" or "SLH-DSA" or "FALCON" => true,
"KYBER" or "DILITHIUM" or "SPHINCS+" => true,
_ when alg.Contains("ML-KEM") || alg.Contains("ML-DSA") => true,
_ when alg.Contains("KYBER") || alg.Contains("DILITHIUM") => true,
_ => false
};
}
private static void AddMigrationRecommendation(
List<CryptoMigrationRecommendation> recommendations,
string fromAlg,
CryptoRiskSeverity severity)
{
var algUpper = fromAlg.ToUpperInvariant();
string? toAlg = null;
string? reason = null;
if (algUpper.Contains("MD5") || algUpper.Contains("SHA1") || algUpper.Contains("SHA-1"))
{
toAlg = "SHA-256 or SHA-3";
reason = "Algorithm is cryptographically broken and should not be used for security purposes";
}
else if (algUpper.Contains("DES") && !algUpper.Contains("AES"))
{
toAlg = "AES-256-GCM";
reason = "DES and 3DES are deprecated due to small key/block size";
}
else if (algUpper.Contains("RC2") || algUpper.Contains("RC4"))
{
toAlg = "AES-256-GCM or ChaCha20-Poly1305";
reason = "RC2/RC4 are deprecated due to known weaknesses";
}
else if (algUpper.Contains("RSA") && !algUpper.Contains("2048") && !algUpper.Contains("4096"))
{
toAlg = "RSA-2048+ or ECDSA P-256+";
reason = "RSA key size should be at least 2048 bits";
}
if (toAlg != null && reason != null)
{
// Avoid duplicates
if (!recommendations.Any(r => r.From.Equals(fromAlg, StringComparison.OrdinalIgnoreCase)))
{
recommendations.Add(new CryptoMigrationRecommendation
{
From = fromAlg,
To = toAlg,
Priority = severity,
Reason = reason
});
}
}
}
}

View File

@@ -0,0 +1,373 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Scanner.Emit.Cbom;
/// <summary>
/// Serializer for CycloneDX 1.7 CBOM (Cryptographic Bill of Materials) extension.
/// Injects cryptographicProperties into CycloneDX JSON output.
/// </summary>
public static class CbomSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Injects CBOM cryptographicProperties into a CycloneDX 1.7 JSON string.
/// </summary>
/// <param name="cycloneDxJson">The CycloneDX JSON (v1.7).</param>
/// <param name="cbomResult">The CBOM aggregation result with crypto assets by component.</param>
/// <returns>Enhanced CycloneDX JSON with cryptographicProperties.</returns>
public static string InjectCbom(string cycloneDxJson, CbomAggregationResult cbomResult)
{
ArgumentNullException.ThrowIfNull(cycloneDxJson);
ArgumentNullException.ThrowIfNull(cbomResult);
if (cbomResult.Assets.IsDefaultOrEmpty || cbomResult.Assets.Length == 0)
{
return cycloneDxJson;
}
var root = JsonNode.Parse(cycloneDxJson);
if (root is not JsonObject rootObj)
{
return cycloneDxJson;
}
var components = rootObj["components"] as JsonArray;
if (components is null || components.Count == 0)
{
return cycloneDxJson;
}
// Index crypto assets by component key (bom-ref)
var assetsByComponent = cbomResult.ByComponent;
foreach (var componentNode in components)
{
if (componentNode is not JsonObject componentObj)
{
continue;
}
var bomRef = componentObj["bom-ref"]?.GetValue<string>();
if (string.IsNullOrEmpty(bomRef))
{
continue;
}
if (!assetsByComponent.TryGetValue(bomRef, out var cryptoAssets) || cryptoAssets.IsDefaultOrEmpty)
{
continue;
}
// Convert crypto assets to CycloneDX cryptographicProperties format
var cryptoPropsArray = new JsonArray();
foreach (var asset in cryptoAssets)
{
var cryptoProp = SerializeCryptoAsset(asset);
if (cryptoProp is not null)
{
cryptoPropsArray.Add(cryptoProp);
}
}
if (cryptoPropsArray.Count > 0)
{
componentObj["cryptographicProperties"] = cryptoPropsArray;
}
}
// Add CBOM metadata properties
AddCbomMetadata(rootObj, cbomResult);
return rootObj.ToJsonString(SerializerOptions);
}
/// <summary>
/// Serializes a CryptoAsset to CycloneDX 1.7 cryptographicProperties format.
/// </summary>
private static JsonObject? SerializeCryptoAsset(CryptoAsset asset)
{
var cryptoProp = new JsonObject
{
["assetType"] = asset.AssetType.ToString().ToLowerInvariant()
};
// Add OID if available
if (!string.IsNullOrEmpty(asset.Oid))
{
cryptoProp["oid"] = asset.Oid;
}
// Serialize based on asset type
switch (asset.AssetType)
{
case CryptoAssetType.Algorithm:
cryptoProp["algorithmProperties"] = SerializeAlgorithmProperties(asset);
break;
case CryptoAssetType.Certificate:
if (!string.IsNullOrEmpty(asset.CertificateSubject) || !string.IsNullOrEmpty(asset.CertificateIssuer))
{
cryptoProp["certificateProperties"] = SerializeCertificateProperties(asset);
}
break;
case CryptoAssetType.Protocol:
if (!string.IsNullOrEmpty(asset.ProtocolName))
{
cryptoProp["protocolProperties"] = SerializeProtocolProperties(asset);
}
break;
case CryptoAssetType.RelatedCryptoMaterial:
cryptoProp["relatedCryptoMaterialProperties"] = SerializeRelatedCryptoMaterialProperties(asset);
break;
}
return cryptoProp;
}
private static JsonObject SerializeAlgorithmProperties(CryptoAsset asset)
{
var props = new JsonObject();
if (asset.Primitive.HasValue)
{
props["primitive"] = asset.Primitive.Value.ToString().ToLowerInvariant();
}
if (asset.Mode.HasValue)
{
props["mode"] = asset.Mode.Value.ToString().ToLowerInvariant();
}
if (asset.Padding.HasValue)
{
props["padding"] = asset.Padding.Value.ToString().ToLowerInvariant();
}
if (!asset.Functions.IsDefaultOrEmpty && asset.Functions.Length > 0)
{
var funcsArray = new JsonArray();
foreach (var func in asset.Functions)
{
funcsArray.Add(func.ToString().ToLowerInvariant());
}
props["cryptoFunctions"] = funcsArray;
}
if (asset.KeySizeBits.HasValue)
{
props["parameterSetIdentifier"] = $"{asset.KeySizeBits.Value}-bit";
}
if (!string.IsNullOrEmpty(asset.Curve))
{
props["curve"] = asset.Curve;
}
if (asset.ExecutionEnvironment.HasValue)
{
props["executionEnvironment"] = asset.ExecutionEnvironment.Value.ToString().ToLowerInvariant();
}
if (!string.IsNullOrEmpty(asset.ImplementationPlatform))
{
props["implementationPlatform"] = asset.ImplementationPlatform;
}
if (asset.NistQuantumSecurityLevel.HasValue)
{
props["nistQuantumSecurityLevel"] = asset.NistQuantumSecurityLevel.Value;
}
if (asset.ClassicalSecurityLevel.HasValue)
{
props["classicalSecurityLevel"] = asset.ClassicalSecurityLevel.Value;
}
return props;
}
private static JsonObject SerializeCertificateProperties(CryptoAsset asset)
{
var props = new JsonObject();
if (!string.IsNullOrEmpty(asset.CertificateSubject))
{
props["subjectName"] = asset.CertificateSubject;
}
if (!string.IsNullOrEmpty(asset.CertificateIssuer))
{
props["issuerName"] = asset.CertificateIssuer;
}
if (!string.IsNullOrEmpty(asset.CertificateNotBefore))
{
props["notValidBefore"] = asset.CertificateNotBefore;
}
if (!string.IsNullOrEmpty(asset.CertificateNotAfter))
{
props["notValidAfter"] = asset.CertificateNotAfter;
}
if (!string.IsNullOrEmpty(asset.SignatureAlgorithmRef))
{
props["signatureAlgorithmRef"] = asset.SignatureAlgorithmRef;
}
if (asset.CertificateFormat.HasValue)
{
props["certificateFormat"] = asset.CertificateFormat.Value.ToString().ToLowerInvariant();
}
return props;
}
private static JsonObject SerializeProtocolProperties(CryptoAsset asset)
{
var props = new JsonObject();
if (!string.IsNullOrEmpty(asset.ProtocolName))
{
props["type"] = asset.ProtocolName.ToLowerInvariant();
}
if (!string.IsNullOrEmpty(asset.ProtocolVersion))
{
props["version"] = asset.ProtocolVersion;
}
if (!asset.CipherSuites.IsDefaultOrEmpty && asset.CipherSuites.Length > 0)
{
var suitesArray = new JsonArray();
foreach (var suite in asset.CipherSuites)
{
suitesArray.Add(new JsonObject { ["name"] = suite });
}
props["cipherSuites"] = suitesArray;
}
return props;
}
private static JsonObject SerializeRelatedCryptoMaterialProperties(CryptoAsset asset)
{
var props = new JsonObject();
if (asset.MaterialType.HasValue)
{
props["type"] = asset.MaterialType.Value.ToString().ToLowerInvariant();
}
if (!string.IsNullOrEmpty(asset.MaterialId))
{
props["id"] = asset.MaterialId;
}
if (asset.MaterialState.HasValue)
{
props["state"] = asset.MaterialState.Value.ToString().ToLowerInvariant();
}
if (!string.IsNullOrEmpty(asset.AlgorithmRef))
{
props["algorithmRef"] = asset.AlgorithmRef;
}
if (asset.KeySizeBits.HasValue)
{
props["size"] = asset.KeySizeBits.Value;
}
return props;
}
private static void AddCbomMetadata(JsonObject rootObj, CbomAggregationResult cbomResult)
{
var metadata = rootObj["metadata"] as JsonObject;
if (metadata is null)
{
return;
}
var properties = metadata["properties"] as JsonArray ?? new JsonArray();
// Add CBOM summary properties
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.generatedAt",
["value"] = cbomResult.GeneratedAt
});
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.totalAssets",
["value"] = cbomResult.Assets.Length.ToString()
});
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.uniqueAlgorithms",
["value"] = cbomResult.UniqueAlgorithms.Length.ToString()
});
if (cbomResult.RiskAssessment is not null)
{
var risk = cbomResult.RiskAssessment;
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.riskScore",
["value"] = risk.OverallScore.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)
});
if (risk.DeprecatedAlgorithms.Length > 0)
{
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.deprecatedAlgorithms",
["value"] = string.Join(",", risk.DeprecatedAlgorithms)
});
}
if (risk.WeakAlgorithms.Length > 0)
{
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.weakAlgorithms",
["value"] = string.Join(",", risk.WeakAlgorithms)
});
}
if (risk.QuantumVulnerableAlgorithms.Length > 0)
{
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.quantumVulnerable",
["value"] = string.Join(",", risk.QuantumVulnerableAlgorithms)
});
}
if (risk.PostQuantumAlgorithms.Length > 0)
{
properties.Add(new JsonObject
{
["name"] = "stellaops:cbom.postQuantumReady",
["value"] = string.Join(",", risk.PostQuantumAlgorithms)
});
}
}
metadata["properties"] = properties;
}
}

View File

@@ -0,0 +1,467 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Emit.Cbom;
/// <summary>
/// CycloneDX 1.7 Cryptographic Properties (CBOM).
/// Per CycloneDX 1.7 specification for cryptographic asset inventory.
/// </summary>
public sealed record CryptoProperties
{
/// <summary>Type of cryptographic asset.</summary>
[JsonPropertyName("assetType")]
public required CryptoAssetType AssetType { get; init; }
/// <summary>Algorithm reference when asset type is Algorithm.</summary>
[JsonPropertyName("algorithmProperties")]
public AlgorithmProperties? AlgorithmProperties { get; init; }
/// <summary>Certificate reference when asset type is Certificate.</summary>
[JsonPropertyName("certificateProperties")]
public CertificateProperties? CertificateProperties { get; init; }
/// <summary>Protocol reference when asset type is Protocol.</summary>
[JsonPropertyName("protocolProperties")]
public ProtocolProperties? ProtocolProperties { get; init; }
/// <summary>Key properties when asset type is Key.</summary>
[JsonPropertyName("relatedCryptoMaterialProperties")]
public RelatedCryptoMaterialProperties? RelatedCryptoMaterialProperties { get; init; }
/// <summary>Object Identifier per IANA/ISO.</summary>
[JsonPropertyName("oid")]
public string? Oid { get; init; }
}
/// <summary>
/// CycloneDX 1.7 Algorithm Properties.
/// </summary>
public sealed record AlgorithmProperties
{
/// <summary>Algorithm primitive (block-cipher, stream-cipher, hash, etc.).</summary>
[JsonPropertyName("primitive")]
public CryptoPrimitive? Primitive { get; init; }
/// <summary>Algorithm mode (CBC, GCM, CTR, etc.).</summary>
[JsonPropertyName("mode")]
public CryptoMode? Mode { get; init; }
/// <summary>Padding scheme (PKCS7, OAEP, etc.).</summary>
[JsonPropertyName("padding")]
public CryptoPadding? Padding { get; init; }
/// <summary>Cryptographic functions this algorithm performs.</summary>
[JsonPropertyName("cryptoFunctions")]
public ImmutableArray<CryptoFunction> CryptoFunctions { get; init; } = ImmutableArray<CryptoFunction>.Empty;
/// <summary>Key size in bits.</summary>
[JsonPropertyName("parameterSetIdentifier")]
public string? ParameterSetIdentifier { get; init; }
/// <summary>Elliptic curve identifier for EC algorithms.</summary>
[JsonPropertyName("curve")]
public string? Curve { get; init; }
/// <summary>Execution environment (software, hardware, HSM, TEE).</summary>
[JsonPropertyName("executionEnvironment")]
public ExecutionEnvironment? ExecutionEnvironment { get; init; }
/// <summary>Implementation platform (native, OpenSSL, BouncyCastle, etc.).</summary>
[JsonPropertyName("implementationPlatform")]
public string? ImplementationPlatform { get; init; }
/// <summary>NIST post-quantum security level (1-5).</summary>
[JsonPropertyName("nistQuantumSecurityLevel")]
public int? NistQuantumSecurityLevel { get; init; }
/// <summary>Classical security level equivalent in bits.</summary>
[JsonPropertyName("classicalSecurityLevel")]
public int? ClassicalSecurityLevel { get; init; }
}
/// <summary>
/// CycloneDX 1.7 Certificate Properties.
/// </summary>
public sealed record CertificateProperties
{
/// <summary>Certificate subject distinguished name.</summary>
[JsonPropertyName("subjectName")]
public string? SubjectName { get; init; }
/// <summary>Certificate issuer distinguished name.</summary>
[JsonPropertyName("issuerName")]
public string? IssuerName { get; init; }
/// <summary>Certificate not valid before date (ISO 8601).</summary>
[JsonPropertyName("notValidBefore")]
public string? NotValidBefore { get; init; }
/// <summary>Certificate not valid after date (ISO 8601).</summary>
[JsonPropertyName("notValidAfter")]
public string? NotValidAfter { get; init; }
/// <summary>Signature algorithm OID.</summary>
[JsonPropertyName("signatureAlgorithmRef")]
public string? SignatureAlgorithmRef { get; init; }
/// <summary>Subject public key algorithm OID.</summary>
[JsonPropertyName("subjectPublicKeyRef")]
public string? SubjectPublicKeyRef { get; init; }
/// <summary>Certificate format (X.509, PGP, etc.).</summary>
[JsonPropertyName("certificateFormat")]
public CertificateFormat? CertificateFormat { get; init; }
/// <summary>Certificate extension key usages.</summary>
[JsonPropertyName("certificateExtension")]
public string? CertificateExtension { get; init; }
}
/// <summary>
/// CycloneDX 1.7 Protocol Properties.
/// </summary>
public sealed record ProtocolProperties
{
/// <summary>Protocol type (TLS, SSH, IPsec, etc.).</summary>
[JsonPropertyName("type")]
public ProtocolType? Type { get; init; }
/// <summary>Protocol version (e.g., "1.3" for TLS 1.3).</summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
/// <summary>Cipher suites supported.</summary>
[JsonPropertyName("cipherSuites")]
public ImmutableArray<CipherSuite> CipherSuites { get; init; } = ImmutableArray<CipherSuite>.Empty;
/// <summary>IKE version for IPsec.</summary>
[JsonPropertyName("ikev2TransformTypes")]
public IkeV2TransformTypes? IkeV2TransformTypes { get; init; }
}
/// <summary>
/// CycloneDX 1.7 Related Crypto Material Properties.
/// Describes keys, nonces, salts, IVs, etc.
/// </summary>
public sealed record RelatedCryptoMaterialProperties
{
/// <summary>Type of crypto material.</summary>
[JsonPropertyName("type")]
public RelatedCryptoMaterialType? Type { get; init; }
/// <summary>Material identifier/reference.</summary>
[JsonPropertyName("id")]
public string? Id { get; init; }
/// <summary>State of the crypto material.</summary>
[JsonPropertyName("state")]
public CryptoMaterialState? State { get; init; }
/// <summary>Algorithm reference this material is used with.</summary>
[JsonPropertyName("algorithmRef")]
public string? AlgorithmRef { get; init; }
/// <summary>Creation date (ISO 8601).</summary>
[JsonPropertyName("creationDate")]
public string? CreationDate { get; init; }
/// <summary>Activation date (ISO 8601).</summary>
[JsonPropertyName("activationDate")]
public string? ActivationDate { get; init; }
/// <summary>Expiration date (ISO 8601).</summary>
[JsonPropertyName("expirationDate")]
public string? ExpirationDate { get; init; }
/// <summary>Size in bits.</summary>
[JsonPropertyName("size")]
public int? Size { get; init; }
/// <summary>Format of the crypto material.</summary>
[JsonPropertyName("format")]
public string? Format { get; init; }
/// <summary>Secured by reference to another component.</summary>
[JsonPropertyName("securedBy")]
public SecuredBy? SecuredBy { get; init; }
}
/// <summary>
/// Cipher suite representation for protocols.
/// </summary>
public sealed record CipherSuite
{
/// <summary>IANA cipher suite name.</summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>Algorithms used in this suite.</summary>
[JsonPropertyName("algorithms")]
public ImmutableArray<string> Algorithms { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Identifiers (hex codes).</summary>
[JsonPropertyName("identifiers")]
public ImmutableArray<string> Identifiers { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// IKEv2 transform types for IPsec.
/// </summary>
public sealed record IkeV2TransformTypes
{
[JsonPropertyName("encr")]
public ImmutableArray<string> Encr { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("prf")]
public ImmutableArray<string> Prf { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("integ")]
public ImmutableArray<string> Integ { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("ke")]
public ImmutableArray<string> Ke { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("esn")]
public bool? Esn { get; init; }
[JsonPropertyName("auth")]
public ImmutableArray<string> Auth { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Reference to security mechanism protecting crypto material.
/// </summary>
public sealed record SecuredBy
{
[JsonPropertyName("mechanism")]
public string? Mechanism { get; init; }
[JsonPropertyName("algorithmRef")]
public string? AlgorithmRef { get; init; }
}
#region Enums
/// <summary>
/// Type of cryptographic asset per CycloneDX 1.7.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoAssetType
{
/// <summary>Cryptographic algorithm.</summary>
Algorithm,
/// <summary>X.509 or other certificate.</summary>
Certificate,
/// <summary>Cryptographic protocol.</summary>
Protocol,
/// <summary>Related cryptographic material (keys, nonces, etc.).</summary>
RelatedCryptoMaterial
}
/// <summary>
/// Cryptographic primitive types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoPrimitive
{
/// <summary>Discrete logarithm (DH, DSA).</summary>
Dlog,
/// <summary>Elliptic curve cryptography.</summary>
Ec,
/// <summary>RSA family.</summary>
Rsa,
/// <summary>Lattice-based cryptography.</summary>
Lattice,
/// <summary>Hash-based cryptography.</summary>
Hash,
/// <summary>Block cipher.</summary>
BlockCipher,
/// <summary>Stream cipher.</summary>
StreamCipher,
/// <summary>Authenticated encryption with associated data.</summary>
Aead,
/// <summary>Message authentication code.</summary>
Mac,
/// <summary>Key derivation function.</summary>
Kdf,
/// <summary>Key encapsulation mechanism.</summary>
Kem,
/// <summary>Password-based key derivation.</summary>
Pbkdf,
/// <summary>Digital signature.</summary>
Signature,
/// <summary>Key agreement.</summary>
KeyAgree,
/// <summary>Pseudorandom number generator.</summary>
Prng,
/// <summary>Unknown or other.</summary>
Other
}
/// <summary>
/// Block cipher modes of operation.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoMode
{
Cbc,
Ecb,
Ccm,
Gcm,
Cfb,
Ofb,
Ctr,
Xts,
Wrap,
Other
}
/// <summary>
/// Padding schemes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoPadding
{
Pkcs7,
Oaep,
Pkcs1v15,
Pss,
X923,
Raw,
None,
Other
}
/// <summary>
/// Cryptographic functions an algorithm can perform.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoFunction
{
/// <summary>Random number generation.</summary>
Generate,
/// <summary>Key generation.</summary>
Keygen,
/// <summary>Key derivation.</summary>
Derive,
/// <summary>Digital signature creation.</summary>
Sign,
/// <summary>Signature verification.</summary>
Verify,
/// <summary>Encryption.</summary>
Encrypt,
/// <summary>Decryption.</summary>
Decrypt,
/// <summary>Authenticated encryption.</summary>
Encapsulate,
/// <summary>Authenticated decryption.</summary>
Decapsulate,
/// <summary>Hashing/digest.</summary>
Digest,
/// <summary>Message authentication.</summary>
Tag,
/// <summary>Key wrapping.</summary>
KeyWrap,
/// <summary>Key unwrapping.</summary>
KeyUnwrap,
/// <summary>Key agreement.</summary>
KeyAgree,
/// <summary>Other function.</summary>
Other
}
/// <summary>
/// Execution environment for crypto operations.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ExecutionEnvironment
{
/// <summary>Software implementation.</summary>
Software,
/// <summary>Hardware security module.</summary>
HardwareSecurityModule,
/// <summary>Trusted execution environment.</summary>
TrustedExecutionEnvironment,
/// <summary>Hardware accelerator.</summary>
Hardware,
/// <summary>Unknown environment.</summary>
Unknown
}
/// <summary>
/// Certificate formats.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CertificateFormat
{
X509,
Pgp,
Pkcs7,
Other
}
/// <summary>
/// Cryptographic protocol types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProtocolType
{
Tls,
Ssh,
Ipsec,
Ike,
Sstp,
Wpa,
Other
}
/// <summary>
/// Types of related cryptographic material.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RelatedCryptoMaterialType
{
/// <summary>Private key.</summary>
PrivateKey,
/// <summary>Public key.</summary>
PublicKey,
/// <summary>Secret/symmetric key.</summary>
SecretKey,
/// <summary>Session key.</summary>
Key,
/// <summary>Cryptographic nonce.</summary>
Nonce,
/// <summary>Cryptographic seed.</summary>
Seed,
/// <summary>Initialization vector.</summary>
Iv,
/// <summary>Salt for key derivation.</summary>
Salt,
/// <summary>Shared secret (DH/ECDH).</summary>
SharedSecret,
/// <summary>Other material.</summary>
Other
}
/// <summary>
/// State of cryptographic material.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CryptoMaterialState
{
/// <summary>Pre-activation.</summary>
PreActivation,
/// <summary>Active/in-use.</summary>
Active,
/// <summary>Suspended.</summary>
Suspended,
/// <summary>Deactivated.</summary>
Deactivated,
/// <summary>Compromised.</summary>
Compromised,
/// <summary>Destroyed.</summary>
Destroyed
}
#endregion

View File

@@ -0,0 +1,196 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Cbom;
/// <summary>
/// Interface for crypto asset extractors that analyze components for cryptographic usage.
/// Each language analyzer implements this to detect crypto patterns.
/// </summary>
public interface ICryptoAssetExtractor
{
/// <summary>
/// Ecosystems this extractor supports.
/// </summary>
ImmutableArray<string> SupportedEcosystems { get; }
/// <summary>
/// Extracts cryptographic assets from a component.
/// </summary>
/// <param name="component">The component to analyze.</param>
/// <param name="analysisContext">Analysis context with file access.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Extracted crypto assets.</returns>
Task<ImmutableArray<CryptoAsset>> ExtractAsync(
AggregatedComponent component,
CryptoAnalysisContext analysisContext,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Context for crypto analysis providing access to component artifacts.
/// </summary>
public sealed record CryptoAnalysisContext
{
/// <summary>Layer digest for file access.</summary>
public required string LayerDigest { get; init; }
/// <summary>File system path mappings.</summary>
public ImmutableDictionary<string, string> FilePaths { get; init; } = ImmutableDictionary<string, string>.Empty;
/// <summary>Optional: Pre-analyzed metadata from language analyzer.</summary>
public ImmutableDictionary<string, object> Metadata { get; init; } = ImmutableDictionary<string, object>.Empty;
}
/// <summary>
/// Extracted cryptographic asset from analysis.
/// </summary>
public sealed record CryptoAsset
{
/// <summary>Unique identifier for this crypto asset.</summary>
public required string Id { get; init; }
/// <summary>Component key this asset belongs to.</summary>
public required string ComponentKey { get; init; }
/// <summary>Type of crypto asset.</summary>
public required CryptoAssetType AssetType { get; init; }
/// <summary>Algorithm name (e.g., "AES-256-GCM", "RSA-2048", "SHA-256").</summary>
public string? AlgorithmName { get; init; }
/// <summary>OID if available (e.g., "2.16.840.1.101.3.4.1.46" for AES-256-GCM).</summary>
public string? Oid { get; init; }
/// <summary>Key size in bits if applicable.</summary>
public int? KeySizeBits { get; init; }
/// <summary>Cryptographic primitive category.</summary>
public CryptoPrimitive? Primitive { get; init; }
/// <summary>Functions this algorithm performs.</summary>
public ImmutableArray<CryptoFunction> Functions { get; init; } = ImmutableArray<CryptoFunction>.Empty;
/// <summary>Mode of operation for block ciphers.</summary>
public CryptoMode? Mode { get; init; }
/// <summary>Padding scheme if applicable.</summary>
public CryptoPadding? Padding { get; init; }
/// <summary>Elliptic curve name for EC algorithms.</summary>
public string? Curve { get; init; }
/// <summary>Execution environment.</summary>
public ExecutionEnvironment? ExecutionEnvironment { get; init; }
/// <summary>Implementation library (e.g., "OpenSSL", "BouncyCastle", "System.Security.Cryptography").</summary>
public string? ImplementationPlatform { get; init; }
/// <summary>Confidence of detection (0.0 - 1.0).</summary>
public double Confidence { get; init; } = 1.0;
/// <summary>Source evidence (file path, method, etc.).</summary>
public ImmutableArray<string> Evidence { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Risk flags identified for this crypto asset.</summary>
public ImmutableArray<CryptoRiskFlag> RiskFlags { get; init; } = ImmutableArray<CryptoRiskFlag>.Empty;
/// <summary>Full CycloneDX crypto properties for serialization.</summary>
public CryptoProperties? CryptoProperties { get; init; }
#region Certificate Properties (for AssetType.Certificate)
/// <summary>Certificate subject distinguished name.</summary>
public string? CertificateSubject { get; init; }
/// <summary>Certificate issuer distinguished name.</summary>
public string? CertificateIssuer { get; init; }
/// <summary>Certificate not valid before (ISO 8601).</summary>
public string? CertificateNotBefore { get; init; }
/// <summary>Certificate not valid after (ISO 8601).</summary>
public string? CertificateNotAfter { get; init; }
/// <summary>Signature algorithm reference.</summary>
public string? SignatureAlgorithmRef { get; init; }
/// <summary>Certificate format.</summary>
public CertificateFormat? CertificateFormat { get; init; }
#endregion
#region Protocol Properties (for AssetType.Protocol)
/// <summary>Protocol name (TLS, SSH, etc.).</summary>
public string? ProtocolName { get; init; }
/// <summary>Protocol version.</summary>
public string? ProtocolVersion { get; init; }
/// <summary>Cipher suites supported.</summary>
public ImmutableArray<string> CipherSuites { get; init; } = ImmutableArray<string>.Empty;
#endregion
#region Related Crypto Material Properties (for AssetType.RelatedCryptoMaterial)
/// <summary>Material type (key, nonce, salt, etc.).</summary>
public RelatedCryptoMaterialType? MaterialType { get; init; }
/// <summary>Material identifier.</summary>
public string? MaterialId { get; init; }
/// <summary>Material state.</summary>
public CryptoMaterialState? MaterialState { get; init; }
/// <summary>Algorithm reference for this material.</summary>
public string? AlgorithmRef { get; init; }
#endregion
#region Post-Quantum Properties
/// <summary>NIST post-quantum security level (1-5).</summary>
public int? NistQuantumSecurityLevel { get; init; }
/// <summary>Classical security level equivalent in bits.</summary>
public int? ClassicalSecurityLevel { get; init; }
#endregion
}
/// <summary>
/// Risk flags for cryptographic assets.
/// </summary>
public sealed record CryptoRiskFlag
{
/// <summary>Risk identifier.</summary>
public required string RiskId { get; init; }
/// <summary>Risk severity (Low, Medium, High, Critical).</summary>
public required CryptoRiskSeverity Severity { get; init; }
/// <summary>Human-readable description.</summary>
public required string Description { get; init; }
/// <summary>Recommended action.</summary>
public string? Recommendation { get; init; }
}
/// <summary>
/// Crypto risk severity levels.
/// </summary>
public enum CryptoRiskSeverity
{
/// <summary>Informational only.</summary>
Info,
/// <summary>Low risk, may need future attention.</summary>
Low,
/// <summary>Medium risk, should be addressed.</summary>
Medium,
/// <summary>High risk, needs prompt attention.</summary>
High,
/// <summary>Critical risk, immediate action required.</summary>
Critical
}

View File

@@ -0,0 +1,508 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Scanner.Emit.Cbom;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Enhances CycloneDX 1.7 JSON with CBOM (Cryptographic BOM) properties.
/// This is a post-processor that injects cryptographicProperties into components
/// since CycloneDX.Core doesn't natively support 1.7 CBOM yet.
/// </summary>
public static class CycloneDxCbomWriter
{
/// <summary>
/// Enhances a CycloneDX JSON with CBOM data.
/// </summary>
/// <param name="cycloneDxJson">The CycloneDX JSON (should be 1.7 format).</param>
/// <param name="cryptoAssetsByComponent">Crypto assets indexed by component bom-ref.</param>
/// <returns>Enhanced JSON with cryptographicProperties.</returns>
public static string InjectCbom(
string cycloneDxJson,
ImmutableDictionary<string, ImmutableArray<CryptoAsset>> cryptoAssetsByComponent)
{
if (string.IsNullOrEmpty(cycloneDxJson) || cryptoAssetsByComponent.IsEmpty)
{
return cycloneDxJson;
}
var root = JsonNode.Parse(cycloneDxJson);
if (root is not JsonObject bomObj)
{
return cycloneDxJson;
}
// Ensure specVersion is 1.7
if (bomObj["specVersion"]?.GetValue<string>() is not "1.7")
{
bomObj["specVersion"] = "1.7";
}
// Process components array
if (bomObj["components"] is JsonArray componentsArray)
{
foreach (var componentNode in componentsArray)
{
if (componentNode is not JsonObject componentObj)
continue;
var bomRef = componentObj["bom-ref"]?.GetValue<string>();
if (string.IsNullOrEmpty(bomRef))
continue;
if (cryptoAssetsByComponent.TryGetValue(bomRef, out var assets) && !assets.IsEmpty)
{
var cryptoProps = BuildCryptoProperties(assets);
if (cryptoProps != null)
{
componentObj["cryptographicProperties"] = cryptoProps;
}
}
}
}
return bomObj.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Enhances CycloneDX artifact bytes with CBOM data.
/// </summary>
public static byte[] InjectCbomBytes(
byte[] cycloneDxJsonBytes,
ImmutableDictionary<string, ImmutableArray<CryptoAsset>> cryptoAssetsByComponent)
{
var json = System.Text.Encoding.UTF8.GetString(cycloneDxJsonBytes);
var enhanced = InjectCbom(json, cryptoAssetsByComponent);
return System.Text.Encoding.UTF8.GetBytes(enhanced);
}
private static JsonArray? BuildCryptoProperties(ImmutableArray<CryptoAsset> assets)
{
if (assets.IsDefaultOrEmpty)
return null;
var cryptoArray = new JsonArray();
foreach (var asset in assets)
{
var cryptoObj = new JsonObject
{
["assetType"] = MapAssetType(asset.AssetType)
};
// Add OID if present
if (!string.IsNullOrEmpty(asset.Oid))
{
cryptoObj["oid"] = asset.Oid;
}
// Build algorithm properties
if (asset.AssetType == CryptoAssetType.Algorithm)
{
var algProps = BuildAlgorithmProperties(asset);
if (algProps != null)
{
cryptoObj["algorithmProperties"] = algProps;
}
}
// Build related crypto material properties
else if (asset.AssetType == CryptoAssetType.RelatedCryptoMaterial && asset.CryptoProperties?.RelatedCryptoMaterialProperties != null)
{
var matProps = BuildRelatedCryptoMaterialProperties(asset.CryptoProperties.RelatedCryptoMaterialProperties);
if (matProps != null)
{
cryptoObj["relatedCryptoMaterialProperties"] = matProps;
}
}
// Build certificate properties
else if (asset.AssetType == CryptoAssetType.Certificate && asset.CryptoProperties?.CertificateProperties != null)
{
var certProps = BuildCertificateProperties(asset.CryptoProperties.CertificateProperties);
if (certProps != null)
{
cryptoObj["certificateProperties"] = certProps;
}
}
// Build protocol properties
else if (asset.AssetType == CryptoAssetType.Protocol && asset.CryptoProperties?.ProtocolProperties != null)
{
var protoProps = BuildProtocolProperties(asset.CryptoProperties.ProtocolProperties);
if (protoProps != null)
{
cryptoObj["protocolProperties"] = protoProps;
}
}
cryptoArray.Add(cryptoObj);
}
return cryptoArray.Count > 0 ? cryptoArray : null;
}
private static JsonObject? BuildAlgorithmProperties(CryptoAsset asset)
{
var props = new JsonObject();
// Primitive
if (asset.Primitive.HasValue)
{
props["primitive"] = MapPrimitive(asset.Primitive.Value);
}
// Crypto functions
if (!asset.Functions.IsDefaultOrEmpty)
{
var funcsArray = new JsonArray();
foreach (var func in asset.Functions)
{
funcsArray.Add(MapFunction(func));
}
props["cryptoFunctions"] = funcsArray;
}
// Mode
if (asset.Mode.HasValue)
{
props["mode"] = MapMode(asset.Mode.Value);
}
// Padding
if (asset.Padding.HasValue)
{
props["padding"] = MapPadding(asset.Padding.Value);
}
// Curve
if (!string.IsNullOrEmpty(asset.Curve))
{
props["curve"] = asset.Curve;
}
// Key size as parameter set identifier
if (asset.KeySizeBits.HasValue)
{
props["parameterSetIdentifier"] = $"{asset.KeySizeBits.Value}";
props["classicalSecurityLevel"] = asset.KeySizeBits.Value;
}
// Execution environment
if (asset.ExecutionEnvironment.HasValue)
{
props["executionEnvironment"] = MapExecutionEnvironment(asset.ExecutionEnvironment.Value);
}
// Implementation platform
if (!string.IsNullOrEmpty(asset.ImplementationPlatform))
{
props["implementationPlatform"] = asset.ImplementationPlatform;
}
// Check for post-quantum and add NIST security level
if (IsPostQuantumAlgorithm(asset.AlgorithmName))
{
// Typical NIST level for ML-KEM/ML-DSA is 1, 3, or 5
props["nistQuantumSecurityLevel"] = GetNistQuantumLevel(asset.AlgorithmName);
}
return props.Count > 0 ? props : null;
}
private static JsonObject? BuildRelatedCryptoMaterialProperties(RelatedCryptoMaterialProperties material)
{
var props = new JsonObject();
if (material.Type.HasValue)
{
props["type"] = MapRelatedMaterialType(material.Type.Value);
}
if (!string.IsNullOrEmpty(material.Id))
{
props["id"] = material.Id;
}
if (material.State.HasValue)
{
props["state"] = MapMaterialState(material.State.Value);
}
if (material.Size.HasValue)
{
props["size"] = material.Size.Value;
}
if (!string.IsNullOrEmpty(material.AlgorithmRef))
{
props["algorithmRef"] = material.AlgorithmRef;
}
if (!string.IsNullOrEmpty(material.CreationDate))
{
props["creationDate"] = material.CreationDate;
}
if (!string.IsNullOrEmpty(material.ActivationDate))
{
props["activationDate"] = material.ActivationDate;
}
if (!string.IsNullOrEmpty(material.ExpirationDate))
{
props["expirationDate"] = material.ExpirationDate;
}
if (!string.IsNullOrEmpty(material.Format))
{
props["format"] = material.Format;
}
return props.Count > 0 ? props : null;
}
private static JsonObject? BuildCertificateProperties(CertificateProperties cert)
{
var props = new JsonObject();
if (!string.IsNullOrEmpty(cert.SubjectName))
{
props["subjectName"] = cert.SubjectName;
}
if (!string.IsNullOrEmpty(cert.IssuerName))
{
props["issuerName"] = cert.IssuerName;
}
if (!string.IsNullOrEmpty(cert.NotValidBefore))
{
props["notValidBefore"] = cert.NotValidBefore;
}
if (!string.IsNullOrEmpty(cert.NotValidAfter))
{
props["notValidAfter"] = cert.NotValidAfter;
}
if (!string.IsNullOrEmpty(cert.SignatureAlgorithmRef))
{
props["signatureAlgorithmRef"] = cert.SignatureAlgorithmRef;
}
if (!string.IsNullOrEmpty(cert.SubjectPublicKeyRef))
{
props["subjectPublicKeyRef"] = cert.SubjectPublicKeyRef;
}
if (cert.CertificateFormat.HasValue)
{
props["certificateFormat"] = MapCertificateFormat(cert.CertificateFormat.Value);
}
return props.Count > 0 ? props : null;
}
private static JsonObject? BuildProtocolProperties(ProtocolProperties protocol)
{
var props = new JsonObject();
if (protocol.Type.HasValue)
{
props["type"] = MapProtocolType(protocol.Type.Value);
}
if (!string.IsNullOrEmpty(protocol.Version))
{
props["version"] = protocol.Version;
}
if (!protocol.CipherSuites.IsDefaultOrEmpty)
{
var suitesArray = new JsonArray();
foreach (var suite in protocol.CipherSuites)
{
var suiteObj = new JsonObject();
if (!string.IsNullOrEmpty(suite.Name))
{
suiteObj["name"] = suite.Name;
}
if (!suite.Algorithms.IsDefaultOrEmpty)
{
var algsArray = new JsonArray();
foreach (var alg in suite.Algorithms)
{
algsArray.Add(alg);
}
suiteObj["algorithms"] = algsArray;
}
suitesArray.Add(suiteObj);
}
props["cipherSuites"] = suitesArray;
}
return props.Count > 0 ? props : null;
}
private static string MapAssetType(CryptoAssetType type) => type switch
{
CryptoAssetType.Algorithm => "algorithm",
CryptoAssetType.Certificate => "certificate",
CryptoAssetType.Protocol => "protocol",
CryptoAssetType.RelatedCryptoMaterial => "related-crypto-material",
_ => "unknown"
};
private static string MapPrimitive(CryptoPrimitive primitive) => primitive switch
{
CryptoPrimitive.Dlog => "dlog",
CryptoPrimitive.Ec => "ec",
CryptoPrimitive.Rsa => "rsa",
CryptoPrimitive.Lattice => "lattice",
CryptoPrimitive.Hash => "hash",
CryptoPrimitive.BlockCipher => "block-cipher",
CryptoPrimitive.StreamCipher => "stream-cipher",
CryptoPrimitive.Aead => "aead",
CryptoPrimitive.Mac => "mac",
CryptoPrimitive.Kdf => "kdf",
CryptoPrimitive.Kem => "kem",
CryptoPrimitive.Pbkdf => "pbkdf",
CryptoPrimitive.Signature => "signature",
CryptoPrimitive.KeyAgree => "key-agree",
CryptoPrimitive.Prng => "prng",
_ => "other"
};
private static string MapFunction(CryptoFunction func) => func switch
{
CryptoFunction.Generate => "generate",
CryptoFunction.Keygen => "keygen",
CryptoFunction.Derive => "derive",
CryptoFunction.Sign => "sign",
CryptoFunction.Verify => "verify",
CryptoFunction.Encrypt => "encrypt",
CryptoFunction.Decrypt => "decrypt",
CryptoFunction.Encapsulate => "encapsulate",
CryptoFunction.Decapsulate => "decapsulate",
CryptoFunction.Digest => "digest",
CryptoFunction.Tag => "tag",
CryptoFunction.KeyWrap => "key-wrap",
CryptoFunction.KeyUnwrap => "key-unwrap",
CryptoFunction.KeyAgree => "key-agree",
_ => "other"
};
private static string MapMode(CryptoMode mode) => mode switch
{
CryptoMode.Cbc => "cbc",
CryptoMode.Ecb => "ecb",
CryptoMode.Ccm => "ccm",
CryptoMode.Gcm => "gcm",
CryptoMode.Cfb => "cfb",
CryptoMode.Ofb => "ofb",
CryptoMode.Ctr => "ctr",
CryptoMode.Xts => "xts",
CryptoMode.Wrap => "wrap",
_ => "other"
};
private static string MapPadding(CryptoPadding padding) => padding switch
{
CryptoPadding.Pkcs7 => "pkcs7",
CryptoPadding.Oaep => "oaep",
CryptoPadding.Pkcs1v15 => "pkcs1v15",
CryptoPadding.Pss => "pss",
CryptoPadding.X923 => "x923",
CryptoPadding.Raw => "raw",
CryptoPadding.None => "none",
_ => "other"
};
private static string MapExecutionEnvironment(ExecutionEnvironment env) => env switch
{
ExecutionEnvironment.Software => "software",
ExecutionEnvironment.HardwareSecurityModule => "hardware-security-module",
ExecutionEnvironment.TrustedExecutionEnvironment => "trusted-execution-environment",
ExecutionEnvironment.Hardware => "hardware",
_ => "unknown"
};
private static string MapRelatedMaterialType(RelatedCryptoMaterialType type) => type switch
{
RelatedCryptoMaterialType.PrivateKey => "private-key",
RelatedCryptoMaterialType.PublicKey => "public-key",
RelatedCryptoMaterialType.SecretKey => "secret-key",
RelatedCryptoMaterialType.Key => "key",
RelatedCryptoMaterialType.Nonce => "nonce",
RelatedCryptoMaterialType.Seed => "seed",
RelatedCryptoMaterialType.Iv => "iv",
RelatedCryptoMaterialType.Salt => "salt",
RelatedCryptoMaterialType.SharedSecret => "shared-secret",
_ => "other"
};
private static string MapMaterialState(CryptoMaterialState state) => state switch
{
CryptoMaterialState.PreActivation => "pre-activation",
CryptoMaterialState.Active => "active",
CryptoMaterialState.Suspended => "suspended",
CryptoMaterialState.Deactivated => "deactivated",
CryptoMaterialState.Compromised => "compromised",
CryptoMaterialState.Destroyed => "destroyed",
_ => "unknown"
};
private static string MapCertificateFormat(CertificateFormat format) => format switch
{
CertificateFormat.X509 => "X.509",
CertificateFormat.Pgp => "PGP",
CertificateFormat.Pkcs7 => "PKCS#7",
_ => "other"
};
private static string MapProtocolType(ProtocolType type) => type switch
{
ProtocolType.Tls => "tls",
ProtocolType.Ssh => "ssh",
ProtocolType.Ipsec => "ipsec",
ProtocolType.Ike => "ike",
ProtocolType.Sstp => "sstp",
ProtocolType.Wpa => "wpa",
_ => "other"
};
private static bool IsPostQuantumAlgorithm(string? algorithmName)
{
if (string.IsNullOrEmpty(algorithmName))
return false;
var upper = algorithmName.ToUpperInvariant();
return upper.Contains("KYBER") || upper.Contains("ML-KEM") ||
upper.Contains("DILITHIUM") || upper.Contains("ML-DSA") ||
upper.Contains("SPHINCS") || upper.Contains("SLH-DSA") ||
upper.Contains("FALCON") || upper.Contains("NTRU") ||
upper.Contains("FRODO") || upper.Contains("SABER");
}
private static int GetNistQuantumLevel(string? algorithmName)
{
if (string.IsNullOrEmpty(algorithmName))
return 1;
var upper = algorithmName.ToUpperInvariant();
// ML-KEM-768 / Dilithium3 => Level 3
// ML-KEM-1024 / Dilithium5 => Level 5
if (upper.Contains("1024") || upper.Contains("5"))
return 5;
if (upper.Contains("768") || upper.Contains("3"))
return 3;
// Default to Level 1 (ML-KEM-512 / Dilithium2)
return 1;
}
}

View File

@@ -14,8 +14,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
<PackageReference Include="CycloneDX.Core" />
<PackageReference Include="RoaringBitmap" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,11 +8,11 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />

View File

@@ -299,6 +299,15 @@ public sealed class FuncProofBuilder
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Computes a BLAKE3-style hash for section content.
/// Uses ICryptoHash for regional compliance if set, otherwise uses SHA-256 as fallback.
/// </summary>
private string ComputeBlake3Hash(byte[] content)
{
return ComputeHashForGraph(content, _cryptoHash);
}
}
/// <summary>

View File

@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

View File

@@ -302,11 +302,9 @@ public sealed class SbomFuncProofLinker : ISbomFuncProofLinker
ProofDigest = proofDigest,
Location = proofLocation,
FunctionCount = funcProof.Functions.Length,
TraceCount = funcProof.Traces?.Length ?? 0,
GeneratedAt = funcProof.Metadata?.Timestamp != null
? DateTimeOffset.Parse(funcProof.Metadata.Timestamp)
: null,
TransparencyLogEntry = funcProof.Metadata?.Properties?.TryGetValue("rekorEntryId", out var rekorId) == true
TraceCount = funcProof.Traces.Length,
GeneratedAt = funcProof.Meta?.BuildTime,
TransparencyLogEntry = funcProof.Meta?.Properties?.TryGetValue("rekorEntryId", out var rekorId) == true
? rekorId
: null
};

View File

@@ -8,8 +8,8 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,6 @@
<RootNamespace>StellaOps.Scanner.Orchestration</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

@@ -7,15 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" 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="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="NATS.Client.Core" Version="2.0.0" />
<PackageReference Include="NATS.Client.JetStream" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NATS.Client.JetStream" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,492 @@
// <copyright file="EbpfSignalMerger.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Scanner.Reachability.Runtime;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Signals.Ebpf.Schema;
/// <summary>
/// Merges eBPF runtime signals with static reachability analysis.
/// </summary>
/// <remarks>
/// This extends the existing RuntimeStaticMerger with specific support for
/// eBPF-collected call paths from the Signals module.
/// </remarks>
public sealed class EbpfSignalMerger
{
private readonly RuntimeStaticMerger _baseMerger;
private readonly ILogger<EbpfSignalMerger> _logger;
private readonly TimeProvider _timeProvider;
public EbpfSignalMerger(
RuntimeStaticMerger baseMerger,
ILogger<EbpfSignalMerger> logger,
TimeProvider? timeProvider = null)
{
_baseMerger = baseMerger;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Merges eBPF signal summary with static reachability graph.
/// </summary>
/// <param name="staticGraph">Static call graph from analysis.</param>
/// <param name="runtimeSignals">Runtime signals from eBPF collection.</param>
/// <param name="options">Merge options.</param>
/// <returns>Merged graph with runtime evidence annotations.</returns>
public EbpfMergeResult Merge(
RichGraph staticGraph,
RuntimeSignalSummary? runtimeSignals,
EbpfMergeOptions? options = null)
{
ArgumentNullException.ThrowIfNull(staticGraph);
options ??= new EbpfMergeOptions();
if (runtimeSignals is null || runtimeSignals.TotalEvents == 0)
{
_logger.LogDebug("No runtime signals to merge");
return new EbpfMergeResult
{
MergedGraph = staticGraph,
Evidence = ImmutableArray<RuntimeEvidence>.Empty,
Statistics = new EbpfMergeStatistics
{
StaticEdgeCount = staticGraph.Edges.Count,
RuntimeEventCount = 0,
},
};
}
_logger.LogInformation(
"Merging {EventCount} eBPF events, {CallPathCount} call paths with static graph ({EdgeCount} edges)",
runtimeSignals.TotalEvents,
runtimeSignals.CallPaths.Count,
staticGraph.Edges.Count);
// Convert eBPF call paths to RuntimeCallEvents for base merger
var runtimeEvents = ConvertToRuntimeEvents(runtimeSignals);
// Use base merger for graph merging
var baseResult = _baseMerger.Merge(staticGraph, runtimeEvents);
// Build runtime evidence annotations
var evidence = BuildRuntimeEvidence(runtimeSignals, baseResult, options);
// Calculate statistics
var statistics = new EbpfMergeStatistics
{
StaticEdgeCount = staticGraph.Edges.Count,
RuntimeEventCount = (int)runtimeSignals.TotalEvents,
CallPathCount = runtimeSignals.CallPaths.Count,
ConfirmedPathCount = baseResult.ObservedEdges.Length,
RuntimeOnlyPathCount = baseResult.RuntimeOnlyEdges.Length,
UnreachedStaticCount = baseResult.Statistics.UnmatchedStaticEdgeCount,
DroppedEventCount = runtimeSignals.DroppedEvents,
CoverageRatio = baseResult.Statistics.CoverageRatio,
};
_logger.LogInformation(
"eBPF merge complete: {Confirmed}/{Static} paths confirmed ({Coverage:P1}), {RuntimeOnly} runtime-only",
statistics.ConfirmedPathCount,
statistics.StaticEdgeCount,
statistics.CoverageRatio,
statistics.RuntimeOnlyPathCount);
return new EbpfMergeResult
{
MergedGraph = baseResult.MergedGraph,
Evidence = evidence,
Statistics = statistics,
ObservedEdges = baseResult.ObservedEdges,
RuntimeOnlyEdges = baseResult.RuntimeOnlyEdges,
};
}
/// <summary>
/// Validates a specific call path against the static graph.
/// </summary>
public PathValidationResult ValidatePath(
RichGraph staticGraph,
ObservedCallPath callPath)
{
ArgumentNullException.ThrowIfNull(staticGraph);
ArgumentNullException.ThrowIfNull(callPath);
if (callPath.Symbols.Count < 2)
{
return new PathValidationResult
{
IsValid = false,
Reason = "Call path must have at least 2 symbols",
PathType = PathType.Invalid,
};
}
var edgeIndex = BuildEdgeIndex(staticGraph);
var matchedEdges = 0;
var missingEdges = new List<(string From, string To)>();
for (var i = 0; i < callPath.Symbols.Count - 1; i++)
{
var from = callPath.Symbols[i];
var to = callPath.Symbols[i + 1];
var key = $"{from}->{to}";
if (edgeIndex.Contains(key))
{
matchedEdges++;
}
else
{
missingEdges.Add((from, to));
}
}
var totalEdges = callPath.Symbols.Count - 1;
if (matchedEdges == totalEdges)
{
return new PathValidationResult
{
IsValid = true,
PathType = PathType.Confirmed,
MatchRatio = 1.0,
MatchedEdgeCount = matchedEdges,
};
}
else if (matchedEdges > 0)
{
return new PathValidationResult
{
IsValid = true,
PathType = PathType.Partial,
MatchRatio = (double)matchedEdges / totalEdges,
MatchedEdgeCount = matchedEdges,
MissingEdges = missingEdges,
};
}
else
{
return new PathValidationResult
{
IsValid = true,
PathType = PathType.RuntimeOnly,
MatchRatio = 0.0,
MissingEdges = missingEdges,
};
}
}
private static IEnumerable<RuntimeCallEvent> ConvertToRuntimeEvents(
RuntimeSignalSummary signals)
{
foreach (var path in signals.CallPaths)
{
if (path.Symbols.Count < 2)
{
continue;
}
// Create events for each edge in the path
for (var i = 0; i < path.Symbols.Count - 1; i++)
{
for (var count = 0; count < path.ObservationCount; count++)
{
yield return new RuntimeCallEvent
{
Timestamp = (ulong)path.FirstObservedAt.ToUnixTimeMilliseconds() * 1_000_000,
Pid = 0,
Tid = 0,
CallerSymbol = path.Symbols[i],
CalleeSymbol = path.Symbols[i + 1],
BinaryPath = path.Purl ?? "unknown",
TraceDigest = null,
};
}
}
}
}
private static ImmutableArray<RuntimeEvidence> BuildRuntimeEvidence(
RuntimeSignalSummary signals,
RuntimeStaticMergeResult mergeResult,
EbpfMergeOptions options)
{
var evidence = new List<RuntimeEvidence>();
// Add evidence for confirmed paths
foreach (var observed in mergeResult.ObservedEdges)
{
evidence.Add(new RuntimeEvidence
{
Type = RuntimeEvidenceType.RuntimeConfirmed,
SourceSymbol = observed.From,
TargetSymbol = observed.To,
Confidence = 1.0,
ObservationCount = observed.ObservationCount,
FirstObservedAt = observed.FirstObserved,
LastObservedAt = observed.LastObserved,
Source = EvidenceSource.Ebpf,
ContainerId = signals.ContainerId,
});
}
// Add evidence for runtime-only paths
foreach (var runtimeOnly in mergeResult.RuntimeOnlyEdges)
{
evidence.Add(new RuntimeEvidence
{
Type = RuntimeEvidenceType.RuntimeOnly,
SourceSymbol = runtimeOnly.From,
TargetSymbol = runtimeOnly.To,
Confidence = options.RuntimeOnlyConfidence,
ObservationCount = runtimeOnly.ObservationCount,
FirstObservedAt = runtimeOnly.FirstObserved,
LastObservedAt = runtimeOnly.LastObserved,
Source = EvidenceSource.Ebpf,
ContainerId = signals.ContainerId,
});
}
// Add evidence for detected runtimes
foreach (var runtime in signals.DetectedRuntimes)
{
evidence.Add(new RuntimeEvidence
{
Type = RuntimeEvidenceType.RuntimeDetected,
RuntimeType = runtime.ToString(),
Confidence = 1.0,
ObservationCount = 1,
FirstObservedAt = signals.StartedAt,
LastObservedAt = signals.StoppedAt,
Source = EvidenceSource.Ebpf,
ContainerId = signals.ContainerId,
});
}
return evidence.ToImmutableArray();
}
private static HashSet<string> BuildEdgeIndex(RichGraph graph)
{
var index = new HashSet<string>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
index.Add($"{edge.From}->{edge.To}");
}
return index;
}
}
/// <summary>
/// Options for eBPF signal merging.
/// </summary>
public sealed record EbpfMergeOptions
{
/// <summary>
/// Confidence score for runtime-only edges.
/// </summary>
public double RuntimeOnlyConfidence { get; init; } = 0.9;
/// <summary>
/// Minimum observation count to include a path.
/// </summary>
public int MinObservationCount { get; init; } = 1;
/// <summary>
/// Whether to add runtime-only edges to the graph.
/// </summary>
public bool AddRuntimeOnlyEdges { get; init; } = true;
/// <summary>
/// Maximum age of observations to consider.
/// </summary>
public TimeSpan FreshnessWindow { get; init; } = TimeSpan.FromDays(7);
}
/// <summary>
/// Result of eBPF signal merging.
/// </summary>
public sealed record EbpfMergeResult
{
/// <summary>
/// Merged graph with runtime annotations.
/// </summary>
public required RichGraph MergedGraph { get; init; }
/// <summary>
/// Runtime evidence items.
/// </summary>
public required ImmutableArray<RuntimeEvidence> Evidence { get; init; }
/// <summary>
/// Merge statistics.
/// </summary>
public required EbpfMergeStatistics Statistics { get; init; }
/// <summary>
/// Edges that were observed at runtime.
/// </summary>
public ImmutableArray<ObservedEdge> ObservedEdges { get; init; } = ImmutableArray<ObservedEdge>.Empty;
/// <summary>
/// Edges only found at runtime.
/// </summary>
public ImmutableArray<RuntimeOnlyEdge> RuntimeOnlyEdges { get; init; } = ImmutableArray<RuntimeOnlyEdge>.Empty;
}
/// <summary>
/// Statistics from eBPF signal merging.
/// </summary>
public sealed record EbpfMergeStatistics
{
public int StaticEdgeCount { get; init; }
public int RuntimeEventCount { get; init; }
public int CallPathCount { get; init; }
public int ConfirmedPathCount { get; init; }
public int RuntimeOnlyPathCount { get; init; }
public int UnreachedStaticCount { get; init; }
public long DroppedEventCount { get; init; }
public double CoverageRatio { get; init; }
}
/// <summary>
/// Result of path validation.
/// </summary>
public sealed record PathValidationResult
{
public required bool IsValid { get; init; }
public string? Reason { get; init; }
public required PathType PathType { get; init; }
public double MatchRatio { get; init; }
public int MatchedEdgeCount { get; init; }
public IReadOnlyList<(string From, string To)>? MissingEdges { get; init; }
}
/// <summary>
/// Type of call path.
/// </summary>
public enum PathType
{
/// <summary>Invalid path (too short, etc).</summary>
Invalid,
/// <summary>All edges confirmed in static graph.</summary>
Confirmed,
/// <summary>Some edges in static graph.</summary>
Partial,
/// <summary>No edges in static graph (runtime-only).</summary>
RuntimeOnly,
}
/// <summary>
/// Runtime evidence from eBPF signals.
/// </summary>
public sealed record RuntimeEvidence
{
/// <summary>
/// Type of evidence.
/// </summary>
public required RuntimeEvidenceType Type { get; init; }
/// <summary>
/// Source symbol (for edge evidence).
/// </summary>
public string? SourceSymbol { get; init; }
/// <summary>
/// Target symbol (for edge evidence).
/// </summary>
public string? TargetSymbol { get; init; }
/// <summary>
/// Runtime type (for runtime detection evidence).
/// </summary>
public string? RuntimeType { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Number of observations.
/// </summary>
public required int ObservationCount { get; init; }
/// <summary>
/// First observation timestamp.
/// </summary>
public required DateTimeOffset FirstObservedAt { get; init; }
/// <summary>
/// Last observation timestamp.
/// </summary>
public required DateTimeOffset LastObservedAt { get; init; }
/// <summary>
/// Source of evidence.
/// </summary>
public required EvidenceSource Source { get; init; }
/// <summary>
/// Container ID where observed.
/// </summary>
public string? ContainerId { get; init; }
/// <summary>
/// PURL of the package (if known).
/// </summary>
public string? Purl { get; init; }
}
/// <summary>
/// Type of runtime evidence.
/// </summary>
public enum RuntimeEvidenceType
{
/// <summary>Function call observed via eBPF.</summary>
RuntimeObserved,
/// <summary>Static path confirmed by runtime.</summary>
RuntimeConfirmed,
/// <summary>Path discovered only at runtime.</summary>
RuntimeOnly,
/// <summary>Runtime type detected.</summary>
RuntimeDetected,
/// <summary>Path marked as unreachable at runtime.</summary>
RuntimeUnreached,
}
/// <summary>
/// Source of evidence.
/// </summary>
public enum EvidenceSource
{
/// <summary>Static analysis.</summary>
Static,
/// <summary>eBPF runtime probes.</summary>
Ebpf,
/// <summary>ETW on Windows.</summary>
Etw,
/// <summary>DTrace on Solaris/BSD.</summary>
DTrace,
/// <summary>Manual observation.</summary>
Manual,
}

View File

@@ -22,6 +22,10 @@ public enum SinkCategory
[JsonStringEnumMemberName("SQL_RAW")]
SqlRaw,
/// <summary>SQL injection (e.g., unparameterized queries with user input)</summary>
[JsonStringEnumMemberName("SQL_INJECTION")]
SqlInjection,
/// <summary>Server-side request forgery (e.g., HttpClient with user input)</summary>
[JsonStringEnumMemberName("SSRF")]
Ssrf,

View File

@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
@@ -23,5 +23,6 @@
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
</ItemGroup>
</Project>

View File

@@ -8,10 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" 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="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a CLI (external submission) source.
/// Receives SBOM uploads from CI/CD pipelines via the CLI.
/// </summary>
public sealed record CliSourceConfig
{
/// <summary>Allowed scanner/tools that can submit to this source.</summary>
[JsonPropertyName("allowedTools")]
public required string[] AllowedTools { get; init; }
/// <summary>Allowed CI systems (optional filter).</summary>
[JsonPropertyName("allowedCiSystems")]
public string[]? AllowedCiSystems { get; init; }
/// <summary>Validation rules for incoming SBOMs.</summary>
[JsonPropertyName("validation")]
public required CliValidationRules Validation { get; init; }
/// <summary>Required attribution fields.</summary>
[JsonPropertyName("attribution")]
public required CliAttributionRules Attribution { get; init; }
/// <summary>Post-processing options.</summary>
[JsonPropertyName("postProcessing")]
public CliPostProcessing? PostProcessing { get; init; }
}
/// <summary>
/// Validation rules for CLI SBOM submissions.
/// </summary>
public sealed record CliValidationRules
{
/// <summary>Require signed SBOMs.</summary>
[JsonPropertyName("requireSignedSbom")]
public bool RequireSignedSbom { get; init; }
/// <summary>Allowed signer public key fingerprints.</summary>
[JsonPropertyName("allowedSigners")]
public string[]? AllowedSigners { get; init; }
/// <summary>Maximum SBOM size in bytes.</summary>
[JsonPropertyName("maxSbomSizeBytes")]
public long MaxSbomSizeBytes { get; init; } = 50 * 1024 * 1024; // 50 MB default
/// <summary>Allowed SBOM formats.</summary>
[JsonPropertyName("allowedFormats")]
public required SbomFormat[] AllowedFormats { get; init; }
/// <summary>Minimum SBOM spec version (e.g., "2.3" for CycloneDX).</summary>
[JsonPropertyName("minSpecVersion")]
public string? MinSpecVersion { get; init; }
/// <summary>Require specific fields in the SBOM.</summary>
[JsonPropertyName("requiredFields")]
public string[]? RequiredFields { get; init; }
}
/// <summary>
/// Supported SBOM formats.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SbomFormat
{
/// <summary>SPDX JSON format.</summary>
SpdxJson,
/// <summary>CycloneDX JSON format.</summary>
CycloneDxJson,
/// <summary>CycloneDX XML format.</summary>
CycloneDxXml
}
/// <summary>
/// Attribution rules for CLI submissions.
/// </summary>
public sealed record CliAttributionRules
{
/// <summary>Require build ID.</summary>
[JsonPropertyName("requireBuildId")]
public bool RequireBuildId { get; init; }
/// <summary>Require repository reference.</summary>
[JsonPropertyName("requireRepository")]
public bool RequireRepository { get; init; }
/// <summary>Require commit SHA.</summary>
[JsonPropertyName("requireCommitSha")]
public bool RequireCommitSha { get; init; }
/// <summary>Require pipeline/workflow ID.</summary>
[JsonPropertyName("requirePipelineId")]
public bool RequirePipelineId { get; init; }
/// <summary>Allowed repository URL patterns.</summary>
[JsonPropertyName("allowedRepositories")]
public string[]? AllowedRepositories { get; init; }
}
/// <summary>
/// Post-processing options for CLI submissions.
/// </summary>
public sealed record CliPostProcessing
{
/// <summary>Run vulnerability matching after upload.</summary>
[JsonPropertyName("runVulnMatching")]
public bool RunVulnMatching { get; init; } = true;
/// <summary>Run reachability analysis after upload.</summary>
[JsonPropertyName("runReachability")]
public bool RunReachability { get; init; }
/// <summary>Apply VEX suppression after upload.</summary>
[JsonPropertyName("applyVex")]
public bool ApplyVex { get; init; } = true;
/// <summary>Generate attestation for the SBOM.</summary>
[JsonPropertyName("generateAttestation")]
public bool GenerateAttestation { get; init; }
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Docker (direct image scan) source.
/// Scans specific images on a schedule or on-demand.
/// </summary>
public sealed record DockerSourceConfig
{
/// <summary>Registry URL (e.g., https://registry-1.docker.io).</summary>
[JsonPropertyName("registryUrl")]
public required string RegistryUrl { get; init; }
/// <summary>Images to scan.</summary>
[JsonPropertyName("images")]
public required ImageSpec[] Images { get; init; }
/// <summary>Scan options.</summary>
[JsonPropertyName("scanOptions")]
public required ScanOptions ScanOptions { get; init; }
/// <summary>Discovery options for tag enumeration.</summary>
[JsonPropertyName("discovery")]
public DiscoveryOptions? Discovery { get; init; }
}
/// <summary>
/// Specification for an image to scan.
/// </summary>
public sealed record ImageSpec
{
/// <summary>Image reference (e.g., "nginx:latest", "myrepo/app:v1.2.3").</summary>
[JsonPropertyName("reference")]
public required string Reference { get; init; }
/// <summary>Tag patterns to scan (if discovering tags).</summary>
[JsonPropertyName("tagPatterns")]
public string[]? TagPatterns { get; init; }
/// <summary>Pin to specific digest after first scan.</summary>
[JsonPropertyName("digestPin")]
public bool DigestPin { get; init; }
/// <summary>Maximum number of tags to scan per discovery.</summary>
[JsonPropertyName("maxTags")]
public int MaxTags { get; init; } = 10;
/// <summary>Only scan tags newer than this age (hours).</summary>
[JsonPropertyName("maxAgeHours")]
public int? MaxAgeHours { get; init; }
}
/// <summary>
/// Options for tag discovery.
/// </summary>
public sealed record DiscoveryOptions
{
/// <summary>Include pre-release tags (e.g., alpha, beta, rc).</summary>
[JsonPropertyName("includePreRelease")]
public bool IncludePreRelease { get; init; }
/// <summary>Sort order for tag selection.</summary>
[JsonPropertyName("sortOrder")]
public TagSortOrder SortOrder { get; init; } = TagSortOrder.SemVerDescending;
/// <summary>Skip tags that match these patterns.</summary>
[JsonPropertyName("excludePatterns")]
public string[]? ExcludePatterns { get; init; }
}
/// <summary>
/// Sort order for tag selection during discovery.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TagSortOrder
{
/// <summary>Semantic version descending (newest first).</summary>
SemVerDescending,
/// <summary>Semantic version ascending (oldest first).</summary>
SemVerAscending,
/// <summary>Alphabetical descending.</summary>
AlphaDescending,
/// <summary>Alphabetical ascending.</summary>
AlphaAscending,
/// <summary>By creation date descending (newest first).</summary>
DateDescending,
/// <summary>By creation date ascending (oldest first).</summary>
DateAscending
}

View File

@@ -0,0 +1,183 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Git (repository) source.
/// Scans source code repositories for dependencies.
/// </summary>
public sealed record GitSourceConfig
{
/// <summary>Git provider type.</summary>
[JsonPropertyName("provider")]
public required GitProvider Provider { get; init; }
/// <summary>Repository URL.</summary>
[JsonPropertyName("repositoryUrl")]
public required string RepositoryUrl { get; init; }
/// <summary>Branch configuration.</summary>
[JsonPropertyName("branches")]
public required GitBranchConfig Branches { get; init; }
/// <summary>Trigger configuration.</summary>
[JsonPropertyName("triggers")]
public required GitTriggerConfig Triggers { get; init; }
/// <summary>Scan options.</summary>
[JsonPropertyName("scanOptions")]
public required GitScanOptions ScanOptions { get; init; }
/// <summary>Authentication method.</summary>
[JsonPropertyName("authMethod")]
public GitAuthMethod AuthMethod { get; init; } = GitAuthMethod.Token;
}
/// <summary>
/// Supported Git providers.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GitProvider
{
/// <summary>GitHub.</summary>
GitHub,
/// <summary>GitLab.</summary>
GitLab,
/// <summary>Bitbucket.</summary>
Bitbucket,
/// <summary>Azure DevOps.</summary>
AzureDevOps,
/// <summary>Gitea.</summary>
Gitea,
/// <summary>Generic Git (no webhook support).</summary>
Generic
}
/// <summary>
/// Authentication method for Git.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GitAuthMethod
{
/// <summary>Personal access token.</summary>
Token,
/// <summary>SSH key.</summary>
Ssh,
/// <summary>OAuth app credentials.</summary>
OAuth,
/// <summary>GitHub App installation.</summary>
GitHubApp
}
/// <summary>
/// Branch filter configuration.
/// </summary>
public sealed record GitBranchConfig
{
/// <summary>Branch patterns to include.</summary>
[JsonPropertyName("include")]
public required string[] Include { get; init; }
/// <summary>Branch patterns to exclude.</summary>
[JsonPropertyName("exclude")]
public string[]? Exclude { get; init; }
/// <summary>Default branch name (if not auto-detected).</summary>
[JsonPropertyName("defaultBranch")]
public string? DefaultBranch { get; init; }
}
/// <summary>
/// Trigger configuration for Git sources.
/// </summary>
public sealed record GitTriggerConfig
{
/// <summary>Trigger on push events.</summary>
[JsonPropertyName("onPush")]
public bool OnPush { get; init; }
/// <summary>Trigger on pull request events.</summary>
[JsonPropertyName("onPullRequest")]
public bool OnPullRequest { get; init; }
/// <summary>Trigger on tag events.</summary>
[JsonPropertyName("onTag")]
public bool OnTag { get; init; }
/// <summary>Tag patterns to trigger on.</summary>
[JsonPropertyName("tagPatterns")]
public string[]? TagPatterns { get; init; }
/// <summary>Pull request actions to trigger on.</summary>
[JsonPropertyName("prActions")]
public PullRequestAction[]? PrActions { get; init; }
}
/// <summary>
/// Pull request actions that can trigger scans.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PullRequestAction
{
/// <summary>PR opened.</summary>
Opened,
/// <summary>PR synchronized (new commits pushed).</summary>
Synchronize,
/// <summary>PR reopened.</summary>
Reopened,
/// <summary>PR ready for review.</summary>
ReadyForReview
}
/// <summary>
/// Scan options for Git sources.
/// </summary>
public sealed record GitScanOptions
{
/// <summary>Analyzers to run.</summary>
[JsonPropertyName("analyzers")]
public required string[] Analyzers { get; init; }
/// <summary>Paths to scan (relative to repo root).</summary>
[JsonPropertyName("scanPaths")]
public string[]? ScanPaths { get; init; }
/// <summary>Paths to exclude from scanning.</summary>
[JsonPropertyName("excludePaths")]
public string[]? ExcludePaths { get; init; }
/// <summary>Only analyze lockfiles (skip manifest-only).</summary>
[JsonPropertyName("lockfileOnly")]
public bool LockfileOnly { get; init; }
/// <summary>Enable reachability analysis.</summary>
[JsonPropertyName("enableReachability")]
public bool EnableReachability { get; init; }
/// <summary>Enable VEX lookup.</summary>
[JsonPropertyName("enableVexLookup")]
public bool EnableVexLookup { get; init; }
/// <summary>Clone depth (0 = full clone).</summary>
[JsonPropertyName("cloneDepth")]
public int CloneDepth { get; init; } = 1;
/// <summary>Include submodules.</summary>
[JsonPropertyName("includeSubmodules")]
public bool IncludeSubmodules { get; init; }
/// <summary>Maximum repository size in MB (skip if larger).</summary>
[JsonPropertyName("maxRepoSizeMb")]
public int MaxRepoSizeMb { get; init; } = 500;
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Validation result for source configuration.
/// </summary>
public sealed record ConfigValidationResult
{
public bool IsValid { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public static ConfigValidationResult Success() => new() { IsValid = true };
public static ConfigValidationResult Failure(params string[] errors) =>
new() { IsValid = false, Errors = errors };
public static ConfigValidationResult Failure(IEnumerable<string> errors) =>
new() { IsValid = false, Errors = errors.ToList() };
public static ConfigValidationResult WithWarnings(params string[] warnings) =>
new() { IsValid = true, Warnings = warnings };
}
/// <summary>
/// Interface for validating source configurations.
/// </summary>
public interface ISourceConfigValidator
{
/// <summary>
/// Validates configuration for the specified source type.
/// </summary>
ConfigValidationResult Validate(SbomSourceType sourceType, JsonDocument configuration);
/// <summary>
/// Validates configuration and returns typed configuration if valid.
/// </summary>
ConfigValidationResult ValidateAndParse<T>(SbomSourceType sourceType, JsonDocument configuration, out T? parsed)
where T : class;
/// <summary>
/// Gets the JSON schema for a source type configuration.
/// </summary>
string? GetConfigurationSchema(SbomSourceType sourceType);
}

View File

@@ -0,0 +1,525 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Validates source configurations based on source type.
/// </summary>
public sealed class SourceConfigValidator : ISourceConfigValidator
{
private readonly ILogger<SourceConfigValidator> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SourceConfigValidator(ILogger<SourceConfigValidator> logger)
{
_logger = logger;
}
public ConfigValidationResult Validate(SbomSourceType sourceType, JsonDocument configuration)
{
return sourceType switch
{
SbomSourceType.Zastava => ValidateZastavaConfig(configuration),
SbomSourceType.Docker => ValidateDockerConfig(configuration),
SbomSourceType.Cli => ValidateCliConfig(configuration),
SbomSourceType.Git => ValidateGitConfig(configuration),
_ => ConfigValidationResult.Failure($"Unknown source type: {sourceType}")
};
}
public ConfigValidationResult ValidateAndParse<T>(
SbomSourceType sourceType,
JsonDocument configuration,
out T? parsed) where T : class
{
parsed = null;
var validationResult = Validate(sourceType, configuration);
if (!validationResult.IsValid)
{
return validationResult;
}
try
{
parsed = configuration.Deserialize<T>(JsonOptions);
if (parsed == null)
{
return ConfigValidationResult.Failure("Failed to parse configuration");
}
return validationResult;
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse configuration for {SourceType}", sourceType);
return ConfigValidationResult.Failure($"JSON parse error: {ex.Message}");
}
}
public string? GetConfigurationSchema(SbomSourceType sourceType)
{
// Return JSON schema for the source type (for UI validation)
return sourceType switch
{
SbomSourceType.Zastava => GetZastavaSchema(),
SbomSourceType.Docker => GetDockerSchema(),
SbomSourceType.Cli => GetCliSchema(),
SbomSourceType.Git => GetGitSchema(),
_ => null
};
}
private ConfigValidationResult ValidateZastavaConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: registryType
if (!root.TryGetProperty("registryType", out var registryType))
{
errors.Add("registryType is required");
}
else
{
var registryTypeStr = registryType.GetString();
if (!Enum.TryParse<RegistryType>(registryTypeStr, true, out _))
{
errors.Add($"Invalid registryType: {registryTypeStr}. Valid values: {string.Join(", ", Enum.GetNames<RegistryType>())}");
}
}
// Required: registryUrl
if (!root.TryGetProperty("registryUrl", out var registryUrl) ||
string.IsNullOrWhiteSpace(registryUrl.GetString()))
{
errors.Add("registryUrl is required");
}
else
{
var url = registryUrl.GetString();
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
errors.Add("registryUrl must be a valid HTTP/HTTPS URL");
}
}
// Optional but recommended: filters
if (!root.TryGetProperty("filters", out _))
{
warnings.Add("No filters specified - all images will be processed");
}
// Optional: scanOptions
if (root.TryGetProperty("scanOptions", out var scanOptions))
{
ValidateScanOptions(scanOptions, errors);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Zastava configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateDockerConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: images (at least one) OR discoveryOptions
var hasImages = root.TryGetProperty("images", out var images) &&
images.ValueKind == JsonValueKind.Array &&
images.GetArrayLength() > 0;
var hasDiscovery = root.TryGetProperty("discoveryOptions", out var discovery) &&
discovery.ValueKind == JsonValueKind.Object;
if (!hasImages && !hasDiscovery)
{
errors.Add("Either 'images' array or 'discoveryOptions' must be specified");
}
// Validate images if present
if (hasImages)
{
var imageIndex = 0;
foreach (var image in images.EnumerateArray())
{
ValidateImageSpec(image, imageIndex++, errors);
}
}
// Optional: registryUrl
if (root.TryGetProperty("registryUrl", out var registryUrl))
{
var url = registryUrl.GetString();
if (!string.IsNullOrEmpty(url) &&
!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
errors.Add("registryUrl must be a valid URL");
}
}
// Optional: scanOptions
if (root.TryGetProperty("scanOptions", out var scanOptions))
{
ValidateScanOptions(scanOptions, errors);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Docker configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateCliConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Optional: acceptedFormats
if (root.TryGetProperty("acceptedFormats", out var formats) &&
formats.ValueKind == JsonValueKind.Array)
{
foreach (var format in formats.EnumerateArray())
{
var formatStr = format.GetString();
if (!Enum.TryParse<SbomFormat>(formatStr, true, out _))
{
errors.Add($"Invalid SBOM format: {formatStr}. Valid values: {string.Join(", ", Enum.GetNames<SbomFormat>())}");
}
}
}
// Optional: validationRules
if (root.TryGetProperty("validationRules", out var validation))
{
if (validation.TryGetProperty("maxFileSizeBytes", out var maxSize))
{
if (maxSize.TryGetInt64(out var size) && size <= 0)
{
errors.Add("maxFileSizeBytes must be positive");
}
}
}
// Warnings for missing recommended settings
if (!root.TryGetProperty("validationRules", out _))
{
warnings.Add("No validation rules specified - using defaults");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating CLI configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private ConfigValidationResult ValidateGitConfig(JsonDocument config)
{
var errors = new List<string>();
var warnings = new List<string>();
try
{
var root = config.RootElement;
// Required: repositoryUrl
if (!root.TryGetProperty("repositoryUrl", out var repoUrl) ||
string.IsNullOrWhiteSpace(repoUrl.GetString()))
{
errors.Add("repositoryUrl is required");
}
else
{
var url = repoUrl.GetString()!;
// Allow git://, https://, ssh:// URLs
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
// Also check for SSH-style URLs (git@github.com:org/repo.git)
if (!url.Contains('@') || !url.Contains(':'))
{
errors.Add("repositoryUrl must be a valid Git URL (https://, git://, ssh://, or git@host:path)");
}
}
}
// Optional: provider (for better integration)
if (root.TryGetProperty("provider", out var provider))
{
var providerStr = provider.GetString();
if (!Enum.TryParse<GitProvider>(providerStr, true, out _))
{
errors.Add($"Invalid provider: {providerStr}. Valid values: {string.Join(", ", Enum.GetNames<GitProvider>())}");
}
}
// Optional: authMethod
if (root.TryGetProperty("authMethod", out var authMethod))
{
var authStr = authMethod.GetString();
if (!Enum.TryParse<GitAuthMethod>(authStr, true, out _))
{
errors.Add($"Invalid authMethod: {authStr}. Valid values: {string.Join(", ", Enum.GetNames<GitAuthMethod>())}");
}
}
// Optional: branchConfig
if (root.TryGetProperty("branchConfig", out var branchConfig))
{
ValidateBranchConfig(branchConfig, errors);
}
// Warnings
if (!root.TryGetProperty("branchConfig", out _))
{
warnings.Add("No branch configuration - using default branch only");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating Git configuration");
errors.Add($"Configuration validation error: {ex.Message}");
}
return errors.Count > 0
? ConfigValidationResult.Failure(errors)
: warnings.Count > 0
? new ConfigValidationResult { IsValid = true, Warnings = warnings }
: ConfigValidationResult.Success();
}
private static void ValidateScanOptions(JsonElement scanOptions, List<string> errors)
{
if (scanOptions.TryGetProperty("timeoutSeconds", out var timeout))
{
if (timeout.TryGetInt32(out var seconds) && seconds <= 0)
{
errors.Add("scanOptions.timeoutSeconds must be positive");
}
}
if (scanOptions.TryGetProperty("maxConcurrency", out var concurrency))
{
if (concurrency.TryGetInt32(out var value) && value <= 0)
{
errors.Add("scanOptions.maxConcurrency must be positive");
}
}
}
private static void ValidateImageSpec(JsonElement image, int index, List<string> errors)
{
if (!image.TryGetProperty("repository", out var repo) ||
string.IsNullOrWhiteSpace(repo.GetString()))
{
errors.Add($"images[{index}].repository is required");
}
// At least one of: tag, tags, tagPattern
var hasTag = image.TryGetProperty("tag", out var tag) &&
!string.IsNullOrWhiteSpace(tag.GetString());
var hasTags = image.TryGetProperty("tags", out var tags) &&
tags.ValueKind == JsonValueKind.Array &&
tags.GetArrayLength() > 0;
var hasPattern = image.TryGetProperty("tagPattern", out var pattern) &&
!string.IsNullOrWhiteSpace(pattern.GetString());
if (!hasTag && !hasTags && !hasPattern)
{
errors.Add($"images[{index}] must specify at least one of: tag, tags, tagPattern");
}
}
private static void ValidateBranchConfig(JsonElement branchConfig, List<string> errors)
{
if (branchConfig.TryGetProperty("branchPatterns", out var patterns) &&
patterns.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in patterns.EnumerateArray())
{
var patternStr = pattern.GetString();
if (string.IsNullOrWhiteSpace(patternStr))
{
errors.Add("branchConfig.branchPatterns contains empty pattern");
}
}
}
}
#region JSON Schemas
private static string GetZastavaSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["registryType", "registryUrl"],
"properties": {
"registryType": {
"type": "string",
"enum": ["DockerHub", "Harbor", "Ecr", "Gcr", "Acr", "Ghcr", "Quay", "JFrog", "Nexus", "GitLab", "Custom"]
},
"registryUrl": {
"type": "string",
"format": "uri"
},
"filters": {
"type": "object",
"properties": {
"repositoryPatterns": { "type": "array", "items": { "type": "string" } },
"tagPatterns": { "type": "array", "items": { "type": "string" } },
"excludePatterns": { "type": "array", "items": { "type": "string" } }
}
},
"scanOptions": {
"type": "object",
"properties": {
"scanOnPush": { "type": "boolean" },
"scanOnPull": { "type": "boolean" },
"timeoutSeconds": { "type": "integer", "minimum": 1 }
}
}
}
}
""";
private static string GetDockerSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"registryUrl": { "type": "string", "format": "uri" },
"images": {
"type": "array",
"items": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"tagPattern": { "type": "string" }
}
}
},
"discoveryOptions": {
"type": "object",
"properties": {
"repositoryPattern": { "type": "string" },
"tagPattern": { "type": "string" },
"maxTagsPerRepo": { "type": "integer", "minimum": 1 }
}
}
}
}
""";
private static string GetCliSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"acceptedFormats": {
"type": "array",
"items": {
"type": "string",
"enum": ["CycloneDX", "SPDX", "Syft", "Auto"]
}
},
"validationRules": {
"type": "object",
"properties": {
"requireSignature": { "type": "boolean" },
"maxFileSizeBytes": { "type": "integer", "minimum": 1 },
"maxComponents": { "type": "integer", "minimum": 1 }
}
},
"attributionRules": {
"type": "object",
"properties": {
"requirePipelineId": { "type": "boolean" },
"requireArtifactRef": { "type": "boolean" }
}
}
}
}
""";
private static string GetGitSchema() => """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["repositoryUrl"],
"properties": {
"repositoryUrl": { "type": "string" },
"provider": {
"type": "string",
"enum": ["GitHub", "GitLab", "Bitbucket", "AzureDevOps", "Gitea", "Custom"]
},
"authMethod": {
"type": "string",
"enum": ["None", "Token", "SshKey", "App", "BasicAuth"]
},
"branchConfig": {
"type": "object",
"properties": {
"defaultBranch": { "type": "string" },
"branchPatterns": { "type": "array", "items": { "type": "string" } },
"excludeBranches": { "type": "array", "items": { "type": "string" } }
}
},
"triggerConfig": {
"type": "object",
"properties": {
"onPush": { "type": "boolean" },
"onPullRequest": { "type": "boolean" },
"onTag": { "type": "boolean" }
}
}
}
}
""";
#endregion
}

View File

@@ -0,0 +1,147 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sources.Configuration;
/// <summary>
/// Configuration for a Zastava (registry webhook) source.
/// Receives push events from container registries and triggers scans.
/// </summary>
public sealed record ZastavaSourceConfig
{
/// <summary>Type of container registry.</summary>
[JsonPropertyName("registryType")]
public required RegistryType RegistryType { get; init; }
/// <summary>Registry URL (e.g., https://registry-1.docker.io).</summary>
[JsonPropertyName("registryUrl")]
public required string RegistryUrl { get; init; }
/// <summary>Filter configuration for repositories and tags.</summary>
[JsonPropertyName("filters")]
public required ZastavaFilters Filters { get; init; }
/// <summary>Scan options for images from this source.</summary>
[JsonPropertyName("scanOptions")]
public required ScanOptions ScanOptions { get; init; }
/// <summary>Optional custom payload mapping for generic webhooks.</summary>
[JsonPropertyName("payloadMapping")]
public PayloadMapping? PayloadMapping { get; init; }
}
/// <summary>
/// Supported container registry types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RegistryType
{
/// <summary>Docker Hub.</summary>
DockerHub,
/// <summary>Harbor registry.</summary>
Harbor,
/// <summary>Quay.io.</summary>
Quay,
/// <summary>AWS Elastic Container Registry.</summary>
Ecr,
/// <summary>Google Container Registry.</summary>
Gcr,
/// <summary>Azure Container Registry.</summary>
Acr,
/// <summary>GitHub Container Registry.</summary>
Ghcr,
/// <summary>JFrog Artifactory.</summary>
Artifactory,
/// <summary>Generic registry with configurable payload mapping.</summary>
Generic
}
/// <summary>
/// Filter configuration for Zastava sources.
/// </summary>
public sealed record ZastavaFilters
{
/// <summary>Repository patterns to include (glob patterns).</summary>
[JsonPropertyName("repositories")]
public required string[] Repositories { get; init; }
/// <summary>Tag patterns to include (glob patterns).</summary>
[JsonPropertyName("tags")]
public required string[] Tags { get; init; }
/// <summary>Repository patterns to exclude (glob patterns).</summary>
[JsonPropertyName("excludeRepositories")]
public string[]? ExcludeRepositories { get; init; }
/// <summary>Tag patterns to exclude (glob patterns).</summary>
[JsonPropertyName("excludeTags")]
public string[]? ExcludeTags { get; init; }
}
/// <summary>
/// Custom payload mapping for generic webhooks.
/// Uses JSONPath expressions to extract values.
/// </summary>
public sealed record PayloadMapping
{
/// <summary>JSONPath to repository name.</summary>
[JsonPropertyName("repositoryPath")]
public required string RepositoryPath { get; init; }
/// <summary>JSONPath to tag name.</summary>
[JsonPropertyName("tagPath")]
public required string TagPath { get; init; }
/// <summary>JSONPath to digest (optional).</summary>
[JsonPropertyName("digestPath")]
public string? DigestPath { get; init; }
/// <summary>JSONPath to timestamp (optional).</summary>
[JsonPropertyName("timestampPath")]
public string? TimestampPath { get; init; }
/// <summary>Expected header for webhook signature verification.</summary>
[JsonPropertyName("signatureHeader")]
public string? SignatureHeader { get; init; }
/// <summary>Signature algorithm (hmac-sha256, etc.).</summary>
[JsonPropertyName("signatureAlgorithm")]
public string? SignatureAlgorithm { get; init; }
}
/// <summary>
/// Common scan options for all source types.
/// </summary>
public sealed record ScanOptions
{
/// <summary>Analyzers to run (e.g., "os", "lang.node", "lang.python").</summary>
[JsonPropertyName("analyzers")]
public required string[] Analyzers { get; init; }
/// <summary>Enable reachability analysis.</summary>
[JsonPropertyName("enableReachability")]
public bool EnableReachability { get; init; }
/// <summary>Enable VEX lookup for vulnerability suppression.</summary>
[JsonPropertyName("enableVexLookup")]
public bool EnableVexLookup { get; init; }
/// <summary>Target platforms for multi-arch images.</summary>
[JsonPropertyName("platforms")]
public string[]? Platforms { get; init; }
/// <summary>Maximum scan timeout in seconds.</summary>
[JsonPropertyName("timeoutSeconds")]
public int TimeoutSeconds { get; init; } = 600;
/// <summary>Priority for scan jobs (higher = more urgent).</summary>
[JsonPropertyName("priority")]
public int Priority { get; init; } = 0;
}

View File

@@ -0,0 +1,348 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Contracts;
// =============================================================================
// Request DTOs
// =============================================================================
/// <summary>
/// Request to create a new SBOM source.
/// </summary>
public sealed record CreateSourceRequest
{
/// <summary>Human-readable name for the source.</summary>
public required string Name { get; init; }
/// <summary>Optional description.</summary>
public string? Description { get; init; }
/// <summary>Type of source.</summary>
public required SbomSourceType SourceType { get; init; }
/// <summary>Type-specific configuration.</summary>
public required JsonDocument Configuration { get; init; }
/// <summary>Reference to credentials in vault.</summary>
public string? AuthRef { get; init; }
/// <summary>Cron schedule for scheduled sources.</summary>
public string? CronSchedule { get; init; }
/// <summary>Timezone for cron schedule.</summary>
public string? CronTimezone { get; init; }
/// <summary>Maximum scans per hour (rate limiting).</summary>
public int? MaxScansPerHour { get; init; }
/// <summary>Tags for organization.</summary>
public List<string>? Tags { get; init; }
/// <summary>Custom metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to update an existing source.
/// </summary>
public sealed record UpdateSourceRequest
{
/// <summary>Updated name.</summary>
public string? Name { get; init; }
/// <summary>Updated description.</summary>
public string? Description { get; init; }
/// <summary>Updated configuration.</summary>
public JsonDocument? Configuration { get; init; }
/// <summary>Updated auth reference.</summary>
public string? AuthRef { get; init; }
/// <summary>Updated cron schedule.</summary>
public string? CronSchedule { get; init; }
/// <summary>Updated cron timezone.</summary>
public string? CronTimezone { get; init; }
/// <summary>Updated rate limit.</summary>
public int? MaxScansPerHour { get; init; }
/// <summary>Updated tags.</summary>
public List<string>? Tags { get; init; }
/// <summary>Updated metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to pause a source.
/// </summary>
public sealed record PauseSourceRequest
{
/// <summary>Reason for pausing.</summary>
public required string Reason { get; init; }
/// <summary>Optional ticket reference.</summary>
public string? Ticket { get; init; }
}
/// <summary>
/// Request to list sources with filters.
/// </summary>
public sealed record ListSourcesRequest
{
/// <summary>Filter by source type.</summary>
public SbomSourceType? SourceType { get; init; }
/// <summary>Filter by status.</summary>
public SbomSourceStatus? Status { get; init; }
/// <summary>Filter by tags (any match).</summary>
public List<string>? Tags { get; init; }
/// <summary>Search term (matches name, description).</summary>
public string? Search { get; init; }
/// <summary>Page size.</summary>
public int Limit { get; init; } = 25;
/// <summary>Cursor for pagination.</summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Request to list source runs.
/// </summary>
public sealed record ListSourceRunsRequest
{
/// <summary>Filter by trigger type.</summary>
public SbomSourceRunTrigger? Trigger { get; init; }
/// <summary>Filter by status.</summary>
public SbomSourceRunStatus? Status { get; init; }
/// <summary>Filter by start date (from).</summary>
public DateTimeOffset? From { get; init; }
/// <summary>Filter by start date (to).</summary>
public DateTimeOffset? To { get; init; }
/// <summary>Page size.</summary>
public int Limit { get; init; } = 25;
/// <summary>Cursor for pagination.</summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Request to trigger a manual scan.
/// </summary>
public sealed record TriggerScanRequest
{
/// <summary>Optional specific targets to scan (overrides discovery).</summary>
public string[]? Targets { get; init; }
/// <summary>Force scan even if rate limited.</summary>
public bool Force { get; init; }
}
/// <summary>
/// Request to test source connection.
/// </summary>
public sealed record TestConnectionRequest
{
/// <summary>Source type.</summary>
public required SbomSourceType SourceType { get; init; }
/// <summary>Configuration to test.</summary>
public required JsonDocument Configuration { get; init; }
/// <summary>Credentials to use.</summary>
public string? AuthRef { get; init; }
/// <summary>Inline credentials for testing (not stored).</summary>
public TestCredentials? TestCredentials { get; init; }
}
/// <summary>
/// Inline credentials for connection testing.
/// </summary>
public sealed record TestCredentials
{
/// <summary>Username (registry auth, git).</summary>
public string? Username { get; init; }
/// <summary>Password or token.</summary>
public string? Password { get; init; }
/// <summary>SSH private key (git).</summary>
public string? SshKey { get; init; }
}
// =============================================================================
// Response DTOs
// =============================================================================
/// <summary>
/// Response containing source details.
/// </summary>
public sealed record SourceResponse
{
public required Guid SourceId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required SbomSourceType SourceType { get; init; }
public required SbomSourceStatus Status { get; init; }
public required JsonDocument Configuration { get; init; }
public string? WebhookEndpoint { get; init; }
public string? CronSchedule { get; init; }
public string? CronTimezone { get; init; }
public DateTimeOffset? NextScheduledRun { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public SbomSourceRunStatus? LastRunStatus { get; init; }
public string? LastRunError { get; init; }
public int ConsecutiveFailures { get; init; }
public bool Paused { get; init; }
public string? PauseReason { get; init; }
public string? PauseTicket { get; init; }
public DateTimeOffset? PausedAt { get; init; }
public int? MaxScansPerHour { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = null!;
public DateTimeOffset UpdatedAt { get; init; }
public string UpdatedBy { get; init; } = null!;
public List<string> Tags { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = [];
public static SourceResponse FromDomain(SbomSource source) => new()
{
SourceId = source.SourceId,
TenantId = source.TenantId,
Name = source.Name,
Description = source.Description,
SourceType = source.SourceType,
Status = source.Status,
Configuration = source.Configuration,
WebhookEndpoint = source.WebhookEndpoint,
CronSchedule = source.CronSchedule,
CronTimezone = source.CronTimezone,
NextScheduledRun = source.NextScheduledRun,
LastRunAt = source.LastRunAt,
LastRunStatus = source.LastRunStatus,
LastRunError = source.LastRunError,
ConsecutiveFailures = source.ConsecutiveFailures,
Paused = source.Paused,
PauseReason = source.PauseReason,
PauseTicket = source.PauseTicket,
PausedAt = source.PausedAt,
MaxScansPerHour = source.MaxScansPerHour,
CreatedAt = source.CreatedAt,
CreatedBy = source.CreatedBy,
UpdatedAt = source.UpdatedAt,
UpdatedBy = source.UpdatedBy,
Tags = source.Tags,
Metadata = source.Metadata
};
}
/// <summary>
/// Response containing source run details.
/// </summary>
public sealed record SourceRunResponse
{
public required Guid RunId { get; init; }
public required Guid SourceId { get; init; }
public required SbomSourceRunTrigger Trigger { get; init; }
public string? TriggerDetails { get; init; }
public required SbomSourceRunStatus Status { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public long DurationMs { get; init; }
public int ItemsDiscovered { get; init; }
public int ItemsScanned { get; init; }
public int ItemsSucceeded { get; init; }
public int ItemsFailed { get; init; }
public int ItemsSkipped { get; init; }
public List<Guid> ScanJobIds { get; init; } = [];
public string? ErrorMessage { get; init; }
public required string CorrelationId { get; init; }
public static SourceRunResponse FromDomain(SbomSourceRun run) => new()
{
RunId = run.RunId,
SourceId = run.SourceId,
Trigger = run.Trigger,
TriggerDetails = run.TriggerDetails,
Status = run.Status,
StartedAt = run.StartedAt,
CompletedAt = run.CompletedAt,
DurationMs = run.DurationMs,
ItemsDiscovered = run.ItemsDiscovered,
ItemsScanned = run.ItemsScanned,
ItemsSucceeded = run.ItemsSucceeded,
ItemsFailed = run.ItemsFailed,
ItemsSkipped = run.ItemsSkipped,
ScanJobIds = run.ScanJobIds,
ErrorMessage = run.ErrorMessage,
CorrelationId = run.CorrelationId
};
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record PagedResponse<T>
{
public required IReadOnlyList<T> Items { get; init; }
public required int TotalCount { get; init; }
public string? NextCursor { get; init; }
public bool HasMore => NextCursor != null;
}
/// <summary>
/// Connection test result.
/// </summary>
public sealed record ConnectionTestResult
{
public required bool Success { get; init; }
public string? Message { get; init; }
public string? ErrorCode { get; init; }
public List<ConnectionTestCheck> Checks { get; init; } = [];
public static ConnectionTestResult Succeeded(string? message = null) => new()
{
Success = true,
Message = message ?? "Connection successful"
};
public static ConnectionTestResult Failed(string message, string? errorCode = null) => new()
{
Success = false,
Message = message,
ErrorCode = errorCode
};
}
/// <summary>
/// Individual check within a connection test.
/// </summary>
public sealed record ConnectionTestCheck
{
public required string Name { get; init; }
public required bool Passed { get; init; }
public string? Message { get; init; }
}
/// <summary>
/// Result of triggering a scan.
/// </summary>
public sealed record TriggerScanResult
{
public required Guid RunId { get; init; }
public required SbomSourceRunStatus Status { get; init; }
public int TargetsQueued { get; init; }
public string? Message { get; init; }
}

View File

@@ -0,0 +1,406 @@
using System.Text.Json;
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Represents a configured SBOM ingestion source.
/// Sources can be registry webhooks (Zastava), direct Docker image scans,
/// CLI submissions, or Git repository scans.
/// </summary>
public sealed class SbomSource
{
/// <summary>Unique source identifier.</summary>
public Guid SourceId { get; init; }
/// <summary>Tenant owning this source.</summary>
public string TenantId { get; init; } = null!;
/// <summary>Human-readable source name.</summary>
public string Name { get; init; } = null!;
/// <summary>Optional description.</summary>
public string? Description { get; set; }
/// <summary>Type of source (Zastava, Docker, CLI, Git).</summary>
public SbomSourceType SourceType { get; init; }
/// <summary>Current status of the source.</summary>
public SbomSourceStatus Status { get; private set; } = SbomSourceStatus.Pending;
/// <summary>Type-specific configuration (JSON).</summary>
public JsonDocument Configuration { get; set; } = null!;
/// <summary>Reference to credentials in vault (never the actual secret).</summary>
public string? AuthRef { get; set; }
/// <summary>Generated webhook endpoint for webhook-based sources.</summary>
public string? WebhookEndpoint { get; private set; }
/// <summary>Reference to webhook secret in vault.</summary>
public string? WebhookSecretRef { get; private set; }
/// <summary>Cron schedule expression for scheduled sources.</summary>
public string? CronSchedule { get; set; }
/// <summary>Timezone for cron schedule (default: UTC).</summary>
public string? CronTimezone { get; set; }
/// <summary>Next scheduled run time.</summary>
public DateTimeOffset? NextScheduledRun { get; private set; }
/// <summary>When the source last ran.</summary>
public DateTimeOffset? LastRunAt { get; private set; }
/// <summary>Status of the last run.</summary>
public SbomSourceRunStatus? LastRunStatus { get; private set; }
/// <summary>Error message from last run (if failed).</summary>
public string? LastRunError { get; private set; }
/// <summary>Number of consecutive failures.</summary>
public int ConsecutiveFailures { get; private set; }
/// <summary>Whether the source is paused.</summary>
public bool Paused { get; private set; }
/// <summary>Reason for pause (operator-provided).</summary>
public string? PauseReason { get; private set; }
/// <summary>Ticket reference for pause audit.</summary>
public string? PauseTicket { get; private set; }
/// <summary>When the source was paused.</summary>
public DateTimeOffset? PausedAt { get; private set; }
/// <summary>Who paused the source.</summary>
public string? PausedBy { get; private set; }
/// <summary>Maximum scans per hour (rate limiting).</summary>
public int? MaxScansPerHour { get; set; }
/// <summary>Current scans in the hour window.</summary>
public int CurrentHourScans { get; private set; }
/// <summary>Start of the current hour window.</summary>
public DateTimeOffset? HourWindowStart { get; private set; }
/// <summary>When the source was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Who created the source.</summary>
public string CreatedBy { get; init; } = null!;
/// <summary>When the source was last updated.</summary>
public DateTimeOffset UpdatedAt { get; private set; }
/// <summary>Who last updated the source.</summary>
public string UpdatedBy { get; private set; } = null!;
/// <summary>Tags for organization.</summary>
public List<string> Tags { get; set; } = [];
/// <summary>Custom metadata key-value pairs.</summary>
public Dictionary<string, string> Metadata { get; set; } = [];
// -------------------------------------------------------------------------
// Factory Methods
// -------------------------------------------------------------------------
/// <summary>
/// Create a new SBOM source.
/// </summary>
public static SbomSource Create(
string tenantId,
string name,
SbomSourceType sourceType,
JsonDocument configuration,
string createdBy,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
string? cronTimezone = null)
{
var now = DateTimeOffset.UtcNow;
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
TenantId = tenantId,
Name = name,
Description = description,
SourceType = sourceType,
Status = SbomSourceStatus.Pending,
Configuration = configuration,
AuthRef = authRef,
CronSchedule = cronSchedule,
CronTimezone = cronTimezone ?? "UTC",
CreatedAt = now,
CreatedBy = createdBy,
UpdatedAt = now,
UpdatedBy = createdBy
};
// Generate webhook endpoint for webhook-based sources
if (sourceType == SbomSourceType.Zastava || sourceType == SbomSourceType.Git)
{
source.GenerateWebhookEndpoint();
}
// Calculate next scheduled run
if (!string.IsNullOrEmpty(cronSchedule))
{
source.CalculateNextScheduledRun();
}
return source;
}
// -------------------------------------------------------------------------
// State Transitions
// -------------------------------------------------------------------------
/// <summary>
/// Activate the source (after successful validation).
/// </summary>
public void Activate(string updatedBy)
{
if (Status == SbomSourceStatus.Disabled)
throw new InvalidOperationException("Cannot activate a disabled source. Enable it first.");
Status = SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
/// <summary>
/// Pause the source with a reason.
/// </summary>
public void Pause(string reason, string? ticket, string pausedBy)
{
if (Paused) return;
Paused = true;
PauseReason = reason;
PauseTicket = ticket;
PausedAt = DateTimeOffset.UtcNow;
PausedBy = pausedBy;
Status = SbomSourceStatus.Paused;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = pausedBy;
}
/// <summary>
/// Resume a paused source.
/// </summary>
public void Resume(string resumedBy)
{
if (!Paused) return;
Paused = false;
PauseReason = null;
PauseTicket = null;
PausedAt = null;
PausedBy = null;
Status = ConsecutiveFailures > 0 ? SbomSourceStatus.Error : SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = resumedBy;
}
/// <summary>
/// Disable the source administratively.
/// </summary>
public void Disable(string disabledBy)
{
Status = SbomSourceStatus.Disabled;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = disabledBy;
}
/// <summary>
/// Enable a disabled source.
/// </summary>
public void Enable(string enabledBy)
{
if (Status != SbomSourceStatus.Disabled)
throw new InvalidOperationException("Source is not disabled.");
Status = SbomSourceStatus.Pending;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = enabledBy;
}
// -------------------------------------------------------------------------
// Run Tracking
// -------------------------------------------------------------------------
/// <summary>
/// Record a successful run.
/// </summary>
public void RecordSuccessfulRun(DateTimeOffset runAt)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Succeeded;
LastRunError = null;
ConsecutiveFailures = 0;
if (Status == SbomSourceStatus.Error)
{
Status = SbomSourceStatus.Active;
}
IncrementHourScans();
CalculateNextScheduledRun();
}
/// <summary>
/// Record a failed run.
/// </summary>
public void RecordFailedRun(DateTimeOffset runAt, string error)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Failed;
LastRunError = error;
ConsecutiveFailures++;
if (!Paused)
{
Status = SbomSourceStatus.Error;
}
IncrementHourScans();
CalculateNextScheduledRun();
}
/// <summary>
/// Record a partial success run.
/// </summary>
public void RecordPartialRun(DateTimeOffset runAt, string? warning = null)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.PartialSuccess;
LastRunError = warning;
// Don't reset consecutive failures for partial success
IncrementHourScans();
CalculateNextScheduledRun();
}
// -------------------------------------------------------------------------
// Rate Limiting
// -------------------------------------------------------------------------
/// <summary>
/// Check if the source is rate limited.
/// </summary>
public bool IsRateLimited()
{
if (!MaxScansPerHour.HasValue) return false;
// Check if we're in a new hour window
var now = DateTimeOffset.UtcNow;
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
return false; // New window, not rate limited
}
return CurrentHourScans >= MaxScansPerHour.Value;
}
private void IncrementHourScans()
{
var now = DateTimeOffset.UtcNow;
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
HourWindowStart = now;
CurrentHourScans = 1;
}
else
{
CurrentHourScans++;
}
}
// -------------------------------------------------------------------------
// Webhook Management
// -------------------------------------------------------------------------
/// <summary>
/// Generate a new webhook endpoint.
/// </summary>
public void GenerateWebhookEndpoint()
{
var typePrefix = SourceType switch
{
SbomSourceType.Zastava => "zastava",
SbomSourceType.Git => "git",
_ => throw new InvalidOperationException($"Source type {SourceType} does not support webhooks")
};
WebhookEndpoint = $"/api/v1/webhooks/{typePrefix}/{SourceId}";
WebhookSecretRef = $"webhook.{SourceId}.secret";
}
/// <summary>
/// Regenerate webhook secret (for rotation).
/// </summary>
public void RotateWebhookSecret(string updatedBy)
{
if (WebhookEndpoint == null)
throw new InvalidOperationException("Source does not have a webhook endpoint.");
// The actual secret rotation happens in the credential store
// This just updates the audit trail
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
// -------------------------------------------------------------------------
// Scheduling
// -------------------------------------------------------------------------
/// <summary>
/// Calculate the next scheduled run time.
/// </summary>
public void CalculateNextScheduledRun()
{
if (string.IsNullOrEmpty(CronSchedule))
{
NextScheduledRun = null;
return;
}
try
{
var cron = Cronos.CronExpression.Parse(CronSchedule);
var timezone = TimeZoneInfo.FindSystemTimeZoneById(CronTimezone ?? "UTC");
NextScheduledRun = cron.GetNextOccurrence(DateTimeOffset.UtcNow, timezone);
}
catch
{
NextScheduledRun = null;
}
}
// -------------------------------------------------------------------------
// Configuration Access
// -------------------------------------------------------------------------
/// <summary>
/// Get the typed configuration.
/// </summary>
public T GetConfiguration<T>() where T : class
{
return Configuration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize configuration as {typeof(T).Name}");
}
/// <summary>
/// Update the configuration.
/// </summary>
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy)
{
Configuration = newConfiguration;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedBy = updatedBy;
}
}

View File

@@ -0,0 +1,85 @@
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Type of SBOM ingestion source.
/// </summary>
public enum SbomSourceType
{
/// <summary>Registry webhook source (receives push events from container registries).</summary>
Zastava = 0,
/// <summary>Direct Docker image scanning (scheduled or on-demand).</summary>
Docker = 1,
/// <summary>External CLI submissions (receives SBOMs from CI/CD pipelines).</summary>
Cli = 2,
/// <summary>Git repository source scanning.</summary>
Git = 3
}
/// <summary>
/// Status of an SBOM source.
/// </summary>
public enum SbomSourceStatus
{
/// <summary>Source is pending initial validation/test.</summary>
Pending = 0,
/// <summary>Source is active and processing events.</summary>
Active = 1,
/// <summary>Source is manually paused by operator.</summary>
Paused = 2,
/// <summary>Source encountered an error (last run failed).</summary>
Error = 3,
/// <summary>Source is administratively disabled.</summary>
Disabled = 4
}
/// <summary>
/// Status of an individual source run.
/// </summary>
public enum SbomSourceRunStatus
{
/// <summary>Run is in progress.</summary>
Running = 0,
/// <summary>Run completed successfully.</summary>
Succeeded = 1,
/// <summary>Run failed.</summary>
Failed = 2,
/// <summary>Run partially succeeded (some items failed).</summary>
PartialSuccess = 3,
/// <summary>Run was skipped (no matching items).</summary>
Skipped = 4,
/// <summary>Run was cancelled.</summary>
Cancelled = 5
}
/// <summary>
/// Trigger type for a source run.
/// </summary>
public enum SbomSourceRunTrigger
{
/// <summary>Scheduled trigger (cron-based).</summary>
Scheduled = 0,
/// <summary>Webhook trigger (registry push, git push).</summary>
Webhook = 1,
/// <summary>Manual trigger (user-initiated).</summary>
Manual = 2,
/// <summary>Backfill trigger (historical scan).</summary>
Backfill = 3,
/// <summary>Retry trigger (retry of failed run).</summary>
Retry = 4
}

View File

@@ -0,0 +1,169 @@
namespace StellaOps.Scanner.Sources.Domain;
/// <summary>
/// Represents a single execution run of an SBOM source.
/// Tracks status, timing, item counts, and any errors.
/// </summary>
public sealed class SbomSourceRun
{
/// <summary>Unique run identifier.</summary>
public Guid RunId { get; init; }
/// <summary>Source that was run.</summary>
public Guid SourceId { get; init; }
/// <summary>Tenant owning the source.</summary>
public string TenantId { get; init; } = null!;
/// <summary>What triggered this run.</summary>
public SbomSourceRunTrigger Trigger { get; init; }
/// <summary>Additional trigger details (webhook payload digest, cron expression, etc.).</summary>
public string? TriggerDetails { get; init; }
/// <summary>Current status of the run.</summary>
public SbomSourceRunStatus Status { get; private set; } = SbomSourceRunStatus.Running;
/// <summary>When the run started.</summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>When the run completed (if finished).</summary>
public DateTimeOffset? CompletedAt { get; private set; }
/// <summary>Duration in milliseconds.</summary>
public long DurationMs => CompletedAt.HasValue
? (long)(CompletedAt.Value - StartedAt).TotalMilliseconds
: (long)(DateTimeOffset.UtcNow - StartedAt).TotalMilliseconds;
/// <summary>Number of items discovered to scan.</summary>
public int ItemsDiscovered { get; private set; }
/// <summary>Number of items that were scanned.</summary>
public int ItemsScanned { get; private set; }
/// <summary>Number of items that succeeded.</summary>
public int ItemsSucceeded { get; private set; }
/// <summary>Number of items that failed.</summary>
public int ItemsFailed { get; private set; }
/// <summary>Number of items that were skipped.</summary>
public int ItemsSkipped { get; private set; }
/// <summary>IDs of scan jobs created by this run.</summary>
public List<Guid> ScanJobIds { get; init; } = [];
/// <summary>Error message if failed.</summary>
public string? ErrorMessage { get; private set; }
/// <summary>Error stack trace if failed.</summary>
public string? ErrorStackTrace { get; private set; }
/// <summary>Correlation ID for distributed tracing.</summary>
public string CorrelationId { get; init; } = null!;
// -------------------------------------------------------------------------
// Factory Methods
// -------------------------------------------------------------------------
/// <summary>
/// Create a new source run.
/// </summary>
public static SbomSourceRun Create(
Guid sourceId,
string tenantId,
SbomSourceRunTrigger trigger,
string correlationId,
string? triggerDetails = null)
{
return new SbomSourceRun
{
RunId = Guid.NewGuid(),
SourceId = sourceId,
TenantId = tenantId,
Trigger = trigger,
TriggerDetails = triggerDetails,
Status = SbomSourceRunStatus.Running,
StartedAt = DateTimeOffset.UtcNow,
CorrelationId = correlationId
};
}
// -------------------------------------------------------------------------
// Progress Updates
// -------------------------------------------------------------------------
/// <summary>
/// Set the number of discovered items.
/// </summary>
public void SetDiscoveredItems(int count)
{
ItemsDiscovered = count;
}
/// <summary>
/// Record a successfully scanned item.
/// </summary>
public void RecordItemSuccess(Guid scanJobId)
{
ItemsScanned++;
ItemsSucceeded++;
ScanJobIds.Add(scanJobId);
}
/// <summary>
/// Record a failed item.
/// </summary>
public void RecordItemFailure()
{
ItemsScanned++;
ItemsFailed++;
}
/// <summary>
/// Record a skipped item.
/// </summary>
public void RecordItemSkipped()
{
ItemsSkipped++;
}
// -------------------------------------------------------------------------
// Completion
// -------------------------------------------------------------------------
/// <summary>
/// Complete the run successfully.
/// </summary>
public void Complete()
{
Status = ItemsFailed > 0
? SbomSourceRunStatus.PartialSuccess
: ItemsSucceeded > 0
? SbomSourceRunStatus.Succeeded
: SbomSourceRunStatus.Skipped;
CompletedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// Fail the run with an error.
/// </summary>
public void Fail(string message, string? stackTrace = null)
{
Status = SbomSourceRunStatus.Failed;
ErrorMessage = message;
ErrorStackTrace = stackTrace;
CompletedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// Cancel the run.
/// </summary>
public void Cancel(string reason)
{
Status = SbomSourceRunStatus.Cancelled;
ErrorMessage = reason;
CompletedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -0,0 +1,112 @@
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// Repository for SBOM source persistence operations.
/// </summary>
public interface ISbomSourceRepository
{
/// <summary>
/// Get a source by ID.
/// </summary>
Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>
Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default);
/// <summary>
/// List sources with optional filters.
/// </summary>
Task<PagedResponse<SbomSource>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default);
/// <summary>
/// Get sources that are due for scheduled execution.
/// </summary>
Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Create a new source.
/// </summary>
Task CreateAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Update an existing source.
/// </summary>
Task UpdateAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Delete a source.
/// </summary>
Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Check if a source name exists in the tenant.
/// </summary>
Task<bool> NameExistsAsync(string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default);
}
/// <summary>
/// Repository for SBOM source run persistence operations.
/// </summary>
public interface ISbomSourceRunRepository
{
/// <summary>
/// Get a run by ID.
/// </summary>
Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default);
/// <summary>
/// List runs for a source.
/// </summary>
Task<PagedResponse<SbomSourceRun>> ListForSourceAsync(
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default);
/// <summary>
/// Create a new run.
/// </summary>
Task CreateAsync(SbomSourceRun run, CancellationToken ct = default);
/// <summary>
/// Update an existing run.
/// </summary>
Task UpdateAsync(SbomSourceRun run, CancellationToken ct = default);
/// <summary>
/// Get runs that are still running (for cleanup/recovery).
/// </summary>
Task<IReadOnlyList<SbomSourceRun>> GetStaleRunsAsync(
TimeSpan olderThan,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Get aggregate statistics for a source.
/// </summary>
Task<SourceRunStats> GetStatsAsync(Guid sourceId, CancellationToken ct = default);
}
/// <summary>
/// Aggregate run statistics for a source.
/// </summary>
public sealed record SourceRunStats
{
public int TotalRuns { get; init; }
public int SuccessfulRuns { get; init; }
public int FailedRuns { get; init; }
public int PartialRuns { get; init; }
public long AverageDurationMs { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public DateTimeOffset? LastFailureAt { get; init; }
}

View File

@@ -0,0 +1,434 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Persistence;
/// <summary>
/// PostgreSQL implementation of SBOM source repository.
/// </summary>
public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSource>, ISbomSourceRepository
{
private const string Schema = "scanner";
private const string Table = "sbom_sources";
private const string FullTable = $"{Schema}.{Table}";
public SbomSourceRepository(
ScannerSourcesDataSource dataSource,
ILogger<SbomSourceRepository> logger)
: base(dataSource, logger)
{
}
public async Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
},
MapSource,
ct);
}
public async Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE tenant_id = @tenantId AND name = @name
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "name", name);
},
MapSource,
ct);
}
public async Task<PagedResponse<SbomSource>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default)
{
var sb = new StringBuilder($"SELECT * FROM {FullTable} WHERE tenant_id = @tenantId");
var countSb = new StringBuilder($"SELECT COUNT(*) FROM {FullTable} WHERE tenant_id = @tenantId");
void AddFilters(NpgsqlCommand cmd)
{
AddParameter(cmd, "tenantId", tenantId);
if (request.SourceType.HasValue)
{
sb.Append(" AND source_type = @sourceType");
countSb.Append(" AND source_type = @sourceType");
AddParameter(cmd, "sourceType", request.SourceType.Value.ToString());
}
if (request.Status.HasValue)
{
sb.Append(" AND status = @status");
countSb.Append(" AND status = @status");
AddParameter(cmd, "status", request.Status.Value.ToString());
}
if (request.Tags?.Count > 0)
{
sb.Append(" AND tags && @tags");
countSb.Append(" AND tags && @tags");
AddTextArrayParameter(cmd, "tags", request.Tags.ToArray());
}
if (!string.IsNullOrWhiteSpace(request.Search))
{
sb.Append(" AND (name ILIKE @search OR description ILIKE @search)");
countSb.Append(" AND (name ILIKE @search OR description ILIKE @search)");
AddParameter(cmd, "search", $"%{request.Search}%");
}
}
sb.Append(" ORDER BY created_at DESC, source_id");
sb.Append($" LIMIT {request.Limit + 1}");
if (!string.IsNullOrEmpty(request.Cursor))
{
// Cursor is base64 encoded offset
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
sb.Append($" OFFSET {offset}");
}
var items = await QueryAsync(
tenantId,
sb.ToString(),
AddFilters,
MapSource,
ct);
var totalCount = await ExecuteScalarAsync<long>(
tenantId,
countSb.ToString(),
AddFilters,
ct) ?? 0;
string? nextCursor = null;
if (items.Count > request.Limit)
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
items = items.Take(request.Limit).ToList();
}
return new PagedResponse<SbomSource>
{
Items = items,
TotalCount = (int)totalCount,
NextCursor = nextCursor
};
}
public async Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE next_scheduled_run <= @asOf
AND status = 'Active'
AND paused = false
AND cron_schedule IS NOT NULL
ORDER BY next_scheduled_run
LIMIT @limit
""";
// Use a system tenant context for cross-tenant queries
return await QueryAsync(
"__system__",
sql,
cmd =>
{
AddParameter(cmd, "asOf", asOf);
AddParameter(cmd, "limit", limit);
},
MapSource,
ct);
}
public async Task CreateAsync(SbomSource source, CancellationToken ct = default)
{
const string sql = $"""
INSERT INTO {FullTable} (
source_id, tenant_id, name, description, source_type, status,
configuration, auth_ref, webhook_endpoint, webhook_secret_ref,
cron_schedule, cron_timezone, next_scheduled_run,
last_run_at, last_run_status, last_run_error, consecutive_failures,
paused, pause_reason, pause_ticket, paused_at, paused_by,
max_scans_per_hour, current_hour_scans, hour_window_start,
created_at, created_by, updated_at, updated_by, tags, metadata
) VALUES (
@sourceId, @tenantId, @name, @description, @sourceType, @status,
@configuration, @authRef, @webhookEndpoint, @webhookSecretRef,
@cronSchedule, @cronTimezone, @nextScheduledRun,
@lastRunAt, @lastRunStatus, @lastRunError, @consecutiveFailures,
@paused, @pauseReason, @pauseTicket, @pausedAt, @pausedBy,
@maxScansPerHour, @currentHourScans, @hourWindowStart,
@createdAt, @createdBy, @updatedAt, @updatedBy, @tags, @metadata
)
""";
await ExecuteAsync(
source.TenantId,
sql,
cmd => ConfigureSourceParams(cmd, source),
ct);
}
public async Task UpdateAsync(SbomSource source, CancellationToken ct = default)
{
const string sql = $"""
UPDATE {FullTable} SET
name = @name,
description = @description,
status = @status,
configuration = @configuration,
auth_ref = @authRef,
webhook_endpoint = @webhookEndpoint,
webhook_secret_ref = @webhookSecretRef,
cron_schedule = @cronSchedule,
cron_timezone = @cronTimezone,
next_scheduled_run = @nextScheduledRun,
last_run_at = @lastRunAt,
last_run_status = @lastRunStatus,
last_run_error = @lastRunError,
consecutive_failures = @consecutiveFailures,
paused = @paused,
pause_reason = @pauseReason,
pause_ticket = @pauseTicket,
paused_at = @pausedAt,
paused_by = @pausedBy,
max_scans_per_hour = @maxScansPerHour,
current_hour_scans = @currentHourScans,
hour_window_start = @hourWindowStart,
updated_at = @updatedAt,
updated_by = @updatedBy,
tags = @tags,
metadata = @metadata
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
await ExecuteAsync(
source.TenantId,
sql,
cmd => ConfigureSourceParams(cmd, source),
ct);
}
public async Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
DELETE FROM {FullTable}
WHERE tenant_id = @tenantId AND source_id = @sourceId
""";
await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "sourceId", sourceId);
},
ct);
}
public async Task<bool> NameExistsAsync(
string tenantId,
string name,
Guid? excludeSourceId = null,
CancellationToken ct = default)
{
var sql = $"""
SELECT EXISTS(
SELECT 1 FROM {FullTable}
WHERE tenant_id = @tenantId AND name = @name
""";
if (excludeSourceId.HasValue)
{
sql += " AND source_id != @excludeSourceId";
}
sql += ")";
return await ExecuteScalarAsync<bool>(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "name", name);
if (excludeSourceId.HasValue)
{
AddParameter(cmd, "excludeSourceId", excludeSourceId.Value);
}
},
ct);
}
private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source)
{
AddParameter(cmd, "sourceId", source.SourceId);
AddParameter(cmd, "tenantId", source.TenantId);
AddParameter(cmd, "name", source.Name);
AddParameter(cmd, "description", source.Description);
AddParameter(cmd, "sourceType", source.SourceType.ToString());
AddParameter(cmd, "status", source.Status.ToString());
// JSONB configuration
cmd.Parameters.Add(new NpgsqlParameter("configuration", NpgsqlDbType.Jsonb)
{
Value = source.Configuration.RootElement.GetRawText()
});
AddParameter(cmd, "authRef", source.AuthRef);
AddParameter(cmd, "webhookEndpoint", source.WebhookEndpoint);
AddParameter(cmd, "webhookSecretRef", source.WebhookSecretRef);
AddParameter(cmd, "cronSchedule", source.CronSchedule);
AddParameter(cmd, "cronTimezone", source.CronTimezone);
AddParameter(cmd, "nextScheduledRun", source.NextScheduledRun);
AddParameter(cmd, "lastRunAt", source.LastRunAt);
AddParameter(cmd, "lastRunStatus", source.LastRunStatus?.ToString());
AddParameter(cmd, "lastRunError", source.LastRunError);
AddParameter(cmd, "consecutiveFailures", source.ConsecutiveFailures);
AddParameter(cmd, "paused", source.Paused);
AddParameter(cmd, "pauseReason", source.PauseReason);
AddParameter(cmd, "pauseTicket", source.PauseTicket);
AddParameter(cmd, "pausedAt", source.PausedAt);
AddParameter(cmd, "pausedBy", source.PausedBy);
AddParameter(cmd, "maxScansPerHour", source.MaxScansPerHour);
AddParameter(cmd, "currentHourScans", source.CurrentHourScans);
AddParameter(cmd, "hourWindowStart", source.HourWindowStart);
AddParameter(cmd, "createdAt", source.CreatedAt);
AddParameter(cmd, "createdBy", source.CreatedBy);
AddParameter(cmd, "updatedAt", source.UpdatedAt);
AddParameter(cmd, "updatedBy", source.UpdatedBy);
AddTextArrayParameter(cmd, "tags", source.Tags.ToArray());
// JSONB metadata
cmd.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb)
{
Value = JsonSerializer.Serialize(source.Metadata)
});
}
private static SbomSource MapSource(NpgsqlDataReader reader)
{
var sourceIdOrd = reader.GetOrdinal("source_id");
var tenantIdOrd = reader.GetOrdinal("tenant_id");
var nameOrd = reader.GetOrdinal("name");
var descriptionOrd = reader.GetOrdinal("description");
var sourceTypeOrd = reader.GetOrdinal("source_type");
var statusOrd = reader.GetOrdinal("status");
var configurationOrd = reader.GetOrdinal("configuration");
var authRefOrd = reader.GetOrdinal("auth_ref");
var webhookEndpointOrd = reader.GetOrdinal("webhook_endpoint");
var webhookSecretRefOrd = reader.GetOrdinal("webhook_secret_ref");
var cronScheduleOrd = reader.GetOrdinal("cron_schedule");
var cronTimezoneOrd = reader.GetOrdinal("cron_timezone");
var nextScheduledRunOrd = reader.GetOrdinal("next_scheduled_run");
var lastRunAtOrd = reader.GetOrdinal("last_run_at");
var lastRunStatusOrd = reader.GetOrdinal("last_run_status");
var lastRunErrorOrd = reader.GetOrdinal("last_run_error");
var consecutiveFailuresOrd = reader.GetOrdinal("consecutive_failures");
var pausedOrd = reader.GetOrdinal("paused");
var pauseReasonOrd = reader.GetOrdinal("pause_reason");
var pauseTicketOrd = reader.GetOrdinal("pause_ticket");
var pausedAtOrd = reader.GetOrdinal("paused_at");
var pausedByOrd = reader.GetOrdinal("paused_by");
var maxScansPerHourOrd = reader.GetOrdinal("max_scans_per_hour");
var currentHourScansOrd = reader.GetOrdinal("current_hour_scans");
var hourWindowStartOrd = reader.GetOrdinal("hour_window_start");
var createdAtOrd = reader.GetOrdinal("created_at");
var createdByOrd = reader.GetOrdinal("created_by");
var updatedAtOrd = reader.GetOrdinal("updated_at");
var updatedByOrd = reader.GetOrdinal("updated_by");
var tagsOrd = reader.GetOrdinal("tags");
var metadataOrd = reader.GetOrdinal("metadata");
var configJson = reader.GetString(configurationOrd);
var metadataJson = GetNullableString(reader, metadataOrd) ?? "{}";
// Use reflection to set private setters (domain model encapsulation)
var source = new SbomSource
{
SourceId = reader.GetGuid(sourceIdOrd),
TenantId = reader.GetString(tenantIdOrd),
Name = reader.GetString(nameOrd),
Description = GetNullableString(reader, descriptionOrd),
SourceType = Enum.Parse<SbomSourceType>(reader.GetString(sourceTypeOrd)),
Configuration = JsonDocument.Parse(configJson),
AuthRef = GetNullableString(reader, authRefOrd),
CronSchedule = GetNullableString(reader, cronScheduleOrd),
CronTimezone = GetNullableString(reader, cronTimezoneOrd),
MaxScansPerHour = GetNullableInt32(reader, maxScansPerHourOrd),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(createdAtOrd),
CreatedBy = reader.GetString(createdByOrd),
Tags = reader.GetFieldValue<string[]>(tagsOrd).ToList(),
Metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson) ?? []
};
// Set private properties via reflection (maintaining domain encapsulation)
SetPrivateProperty(source, "Status", Enum.Parse<SbomSourceStatus>(reader.GetString(statusOrd)));
SetPrivateProperty(source, "WebhookEndpoint", GetNullableString(reader, webhookEndpointOrd));
SetPrivateProperty(source, "WebhookSecretRef", GetNullableString(reader, webhookSecretRefOrd));
SetPrivateProperty(source, "NextScheduledRun", GetNullableDateTimeOffset(reader, nextScheduledRunOrd));
SetPrivateProperty(source, "LastRunAt", GetNullableDateTimeOffset(reader, lastRunAtOrd));
var lastRunStatusStr = GetNullableString(reader, lastRunStatusOrd);
if (lastRunStatusStr != null)
{
SetPrivateProperty(source, "LastRunStatus", Enum.Parse<SbomSourceRunStatus>(lastRunStatusStr));
}
SetPrivateProperty(source, "LastRunError", GetNullableString(reader, lastRunErrorOrd));
SetPrivateProperty(source, "ConsecutiveFailures", reader.GetInt32(consecutiveFailuresOrd));
SetPrivateProperty(source, "Paused", reader.GetBoolean(pausedOrd));
SetPrivateProperty(source, "PauseReason", GetNullableString(reader, pauseReasonOrd));
SetPrivateProperty(source, "PauseTicket", GetNullableString(reader, pauseTicketOrd));
SetPrivateProperty(source, "PausedAt", GetNullableDateTimeOffset(reader, pausedAtOrd));
SetPrivateProperty(source, "PausedBy", GetNullableString(reader, pausedByOrd));
SetPrivateProperty(source, "CurrentHourScans", reader.GetInt32(currentHourScansOrd));
SetPrivateProperty(source, "HourWindowStart", GetNullableDateTimeOffset(reader, hourWindowStartOrd));
SetPrivateProperty(source, "UpdatedAt", reader.GetFieldValue<DateTimeOffset>(updatedAtOrd));
SetPrivateProperty(source, "UpdatedBy", reader.GetString(updatedByOrd));
return source;
}
private static void SetPrivateProperty(object obj, string propertyName, object? value)
{
var property = obj.GetType().GetProperty(propertyName);
property?.SetValue(obj, value);
}
}

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