Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<Solution>
|
||||
</Solution>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Scanner.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62540;http://localhost:62542"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -206,8 +206,6 @@ public sealed class AttestationChainVerifier : IAttestationChainVerifier
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -737,7 +737,7 @@ public sealed class OfflineAttestationVerifier : IOfflineAttestationVerifier
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(certBytes);
|
||||
return X509CertificateLoader.LoadCertificate(certBytes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="BenchmarkDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="BenchmarkDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="BenchmarkDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user