using System.Collections.Immutable; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.SbomService.Models; namespace StellaOps.SbomService.Services; internal static class SbomContextAssembler { private const string Schema = "stellaops.sbom.context/1.0"; private static readonly JsonSerializerOptions HashSerializerOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; private static readonly IReadOnlyDictionary EmptyDictionary = ImmutableDictionary.Empty; private static readonly IReadOnlyList EmptyVersions = ImmutableArray.Empty; private static readonly IReadOnlyList EmptyPaths = ImmutableArray.Empty; public static SbomContextResponse Build( string artifactId, string? purl, DateTimeOffset generated, IReadOnlyList timeline, IReadOnlyList paths, bool includeEnvironmentFlags, bool includeBlastRadius) { var versions = timeline.Count == 0 ? EmptyVersions : BuildVersions(timeline); var dependencyPaths = paths.Count == 0 ? EmptyPaths : BuildDependencyPaths(paths); var environmentFlags = includeEnvironmentFlags ? BuildEnvironmentFlags(dependencyPaths) : EmptyDictionary; var blastRadius = includeBlastRadius ? BuildBlastRadius(dependencyPaths) : null; var metadata = BuildMetadata(artifactId, generated, versions.Count, dependencyPaths.Count, environmentFlags.Count, blastRadius is not null); var response = new SbomContextResponse( Schema, generated, artifactId, purl, versions, dependencyPaths, environmentFlags, blastRadius, metadata, Hash: string.Empty); var hash = ComputeHash(response); return response with { Hash = hash }; } private static IReadOnlyList BuildVersions(IReadOnlyList versions) { return versions .OrderByDescending(v => v.CreatedAt) .ThenBy(v => v.Version, StringComparer.Ordinal) .Select(v => { var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); metadata["digest"] = v.Digest; metadata["source_bundle_hash"] = v.SourceBundleHash; if (!string.IsNullOrWhiteSpace(v.Provenance)) { metadata["provenance"] = v.Provenance!; } return new SbomContextVersion( v.Version, v.CreatedAt, v.CreatedAt, "observed", string.IsNullOrWhiteSpace(v.Provenance) ? "sbom" : v.Provenance!.Trim(), false, metadata.ToImmutable()); }) .ToImmutableArray(); } private static IReadOnlyList BuildDependencyPaths(IReadOnlyList paths) { return paths .Select(path => { var nodes = path.Nodes .Select(node => new SbomContextDependencyNode( Identifier: string.IsNullOrWhiteSpace(node.Name) ? "unknown" : node.Name, Version: null)) .ToImmutableArray(); var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(path.Scope)) { metadata["scope"] = path.Scope!; } if (!string.IsNullOrWhiteSpace(path.Environment)) { metadata["environment"] = path.Environment!; } if (!string.IsNullOrWhiteSpace(path.Artifact)) { metadata["artifact"] = path.Artifact!; } if (!string.IsNullOrWhiteSpace(path.NearestSafeVersion)) { metadata["nearest_safe_version"] = path.NearestSafeVersion!; } if (!string.IsNullOrWhiteSpace(path.BlastRadius)) { metadata["blast_radius"] = path.BlastRadius!; } metadata["path_length"] = nodes.Length.ToString(CultureInfo.InvariantCulture); return new SbomContextDependencyPath( nodes, path.RuntimeFlag, "sbom.paths", metadata.ToImmutable()); }) .ToImmutableArray(); } private static IReadOnlyDictionary BuildEnvironmentFlags(IReadOnlyList paths) { if (paths.Count == 0) { return EmptyDictionary; } var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); var environmentCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var path in paths) { if (path.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)) { var key = environment.Trim(); environmentCounts[key] = environmentCounts.TryGetValue(key, out var count) ? count + 1 : 1; } } if (environmentCounts.Count == 0) { return EmptyDictionary; } foreach (var pair in environmentCounts.OrderBy(p => p.Key, StringComparer.Ordinal)) { builder[pair.Key] = pair.Value.ToString(CultureInfo.InvariantCulture); } return builder.ToImmutable(); } private static SbomContextBlastRadius? BuildBlastRadius(IReadOnlyList paths) { if (paths.Count == 0) { return null; } var impactedAssets = paths .SelectMany(p => p.Metadata.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) ? new[] { scope.Trim() } : Array.Empty()) .Distinct(StringComparer.Ordinal) .Count(); var impactedNamespaces = paths .SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment) ? new[] { environment.Trim() } : Array.Empty()) .Distinct(StringComparer.Ordinal) .Count(); var impactedWorkloads = paths.Count(p => p.IsRuntime); double? impactedPercentage = paths.Count == 0 ? null : Math.Round((double)impactedWorkloads / paths.Count, 3); var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); metadataBuilder["path_sample_count"] = paths.Count.ToString(CultureInfo.InvariantCulture); var blastTags = paths .Select(p => p.Metadata.TryGetValue("blast_radius", out var tag) ? tag : null) .Where(tag => !string.IsNullOrWhiteSpace(tag)) .Select(tag => tag!.Trim()) .Distinct(StringComparer.Ordinal) .ToArray(); if (blastTags.Length > 0) { metadataBuilder["blast_radius_tags"] = string.Join(",", blastTags); } return new SbomContextBlastRadius( impactedAssets, impactedWorkloads, impactedNamespaces, impactedPercentage, metadataBuilder.ToImmutable()); } private static IReadOnlyDictionary BuildMetadata( string artifactId, DateTimeOffset generated, int versionCount, int dependencyCount, int environmentFlagCount, bool hasBlastRadius) { var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); builder["generated_at"] = generated.ToString("O", CultureInfo.InvariantCulture); builder["artifact"] = artifactId; builder["version_count"] = versionCount.ToString(CultureInfo.InvariantCulture); builder["dependency_count"] = dependencyCount.ToString(CultureInfo.InvariantCulture); builder["environment_flag_count"] = environmentFlagCount.ToString(CultureInfo.InvariantCulture); builder["blast_radius_present"] = hasBlastRadius.ToString(); builder["source"] = "sbom-service"; return builder.ToImmutable(); } private static string ComputeHash(SbomContextResponse response) { var snapshot = new { response.Schema, response.Generated, response.ArtifactId, response.Purl, response.Versions, response.DependencyPaths, response.EnvironmentFlags, response.BlastRadius, response.Metadata }; var json = JsonSerializer.Serialize(snapshot, HashSerializerOptions); var bytes = Encoding.UTF8.GetBytes(json); var hash = SHA256.HashData(bytes); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } }