260 lines
9.4 KiB
C#
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()}";
|
|
}
|
|
}
|