Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Services/SbomContextAssembler.cs
master f30805ad7f up
2025-12-09 10:50:15 +02:00

260 lines
9.4 KiB
C#

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<string, string> EmptyDictionary =
ImmutableDictionary<string, string>.Empty;
private static readonly IReadOnlyList<SbomContextVersion> EmptyVersions =
ImmutableArray<SbomContextVersion>.Empty;
private static readonly IReadOnlyList<SbomContextDependencyPath> EmptyPaths =
ImmutableArray<SbomContextDependencyPath>.Empty;
public static SbomContextResponse Build(
string artifactId,
string? purl,
DateTimeOffset generated,
IReadOnlyList<SbomVersion> timeline,
IReadOnlyList<SbomPath> 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<SbomContextVersion> BuildVersions(IReadOnlyList<SbomVersion> versions)
{
return versions
.OrderByDescending(v => v.CreatedAt)
.ThenBy(v => v.Version, StringComparer.Ordinal)
.Select(v =>
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(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<SbomContextDependencyPath> BuildDependencyPaths(IReadOnlyList<SbomPath> 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<string, string>(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<string, string> BuildEnvironmentFlags(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return EmptyDictionary;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var environmentCounts = new Dictionary<string, int>(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<SbomContextDependencyPath> 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<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedNamespaces = paths
.SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)
? new[] { environment.Trim() }
: Array.Empty<string>())
.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<string, string>(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<string, string> BuildMetadata(
string artifactId,
DateTimeOffset generated,
int versionCount,
int dependencyCount,
int environmentFlagCount,
bool hasBlastRadius)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(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()}";
}
}