sprints and audit work
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CycloneDX;
|
||||
using CycloneDX.Models;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
using JsonSerializer = CycloneDX.Json.Serializer;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Composition;
|
||||
|
||||
/// <summary>
|
||||
/// Writes per-layer SBOMs in CycloneDX 1.7 format.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxLayerWriter : ILayerSbomWriter
|
||||
{
|
||||
private static readonly Guid SerialNamespace = new("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Format => "cyclonedx";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
|
||||
var bom = BuildLayerBom(request, generatedAt);
|
||||
|
||||
var json16 = JsonSerializer.Serialize(bom);
|
||||
var json = CycloneDx17Extensions.UpgradeJsonTo17(json16);
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
var jsonDigest = ComputeSha256(jsonBytes);
|
||||
|
||||
var output = new LayerSbomOutput
|
||||
{
|
||||
LayerDigest = request.LayerDigest,
|
||||
Format = Format,
|
||||
JsonBytes = jsonBytes,
|
||||
JsonDigest = jsonDigest,
|
||||
MediaType = CycloneDx17Extensions.MediaTypes.InventoryJson,
|
||||
ComponentCount = request.Components.Length,
|
||||
};
|
||||
|
||||
return Task.FromResult(output);
|
||||
}
|
||||
|
||||
private static Bom BuildLayerBom(LayerSbomRequest request, DateTimeOffset generatedAt)
|
||||
{
|
||||
// Note: CycloneDX.Core 10.x does not yet have v1_7 enum; serialize as v1_6 then upgrade via UpgradeJsonTo17()
|
||||
var bom = new Bom
|
||||
{
|
||||
SpecVersion = SpecificationVersion.v1_6,
|
||||
Version = 1,
|
||||
Metadata = BuildMetadata(request, generatedAt),
|
||||
Components = BuildComponents(request.Components),
|
||||
Dependencies = BuildDependencies(request.Components),
|
||||
};
|
||||
|
||||
var serialPayload = $"{request.Image.ImageDigest}|layer:{request.LayerDigest}|{ScannerTimestamps.ToIso8601(generatedAt)}";
|
||||
bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}";
|
||||
|
||||
return bom;
|
||||
}
|
||||
|
||||
private static Metadata BuildMetadata(LayerSbomRequest request, DateTimeOffset generatedAt)
|
||||
{
|
||||
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
|
||||
var bomRef = $"layer:{layerDigestShort}";
|
||||
|
||||
var metadata = new Metadata
|
||||
{
|
||||
Timestamp = generatedAt.UtcDateTime,
|
||||
Component = new Component
|
||||
{
|
||||
BomRef = bomRef,
|
||||
Type = Component.Classification.Container,
|
||||
Name = $"layer-{request.LayerOrder}",
|
||||
Version = layerDigestShort,
|
||||
Properties = new List<Property>
|
||||
{
|
||||
new() { Name = "stellaops:layer.digest", Value = request.LayerDigest },
|
||||
new() { Name = "stellaops:layer.order", Value = request.LayerOrder.ToString(CultureInfo.InvariantCulture) },
|
||||
new() { Name = "stellaops:image.digest", Value = request.Image.ImageDigest },
|
||||
},
|
||||
},
|
||||
Properties = new List<Property>
|
||||
{
|
||||
new() { Name = "stellaops:sbom.type", Value = "layer" },
|
||||
new() { Name = "stellaops:sbom.view", Value = "inventory" },
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Image.ImageReference))
|
||||
{
|
||||
metadata.Component.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:image.reference",
|
||||
Value = request.Image.ImageReference,
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.GeneratorName))
|
||||
{
|
||||
metadata.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:generator.name",
|
||||
Value = request.GeneratorName,
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.GeneratorVersion))
|
||||
{
|
||||
metadata.Properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:generator.version",
|
||||
Value = request.GeneratorVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static List<Component> BuildComponents(ImmutableArray<ComponentRecord> components)
|
||||
{
|
||||
var result = new List<Component>(components.Length);
|
||||
|
||||
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var model = new Component
|
||||
{
|
||||
BomRef = component.Identity.Key,
|
||||
Name = component.Identity.Name,
|
||||
Version = component.Identity.Version,
|
||||
Purl = component.Identity.Purl,
|
||||
Group = component.Identity.Group,
|
||||
Type = MapClassification(component.Identity.ComponentType),
|
||||
Scope = MapScope(component.Metadata?.Scope),
|
||||
Properties = BuildProperties(component),
|
||||
};
|
||||
|
||||
result.Add(model);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Property>? BuildProperties(ComponentRecord component)
|
||||
{
|
||||
var properties = new List<Property>();
|
||||
|
||||
if (component.Metadata?.Properties is not null)
|
||||
{
|
||||
foreach (var property in component.Metadata.Properties.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
properties.Add(new Property
|
||||
{
|
||||
Name = property.Key,
|
||||
Value = property.Value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(component.Metadata?.BuildId))
|
||||
{
|
||||
properties.Add(new Property
|
||||
{
|
||||
Name = "stellaops:buildId",
|
||||
Value = component.Metadata!.BuildId,
|
||||
});
|
||||
}
|
||||
|
||||
properties.Add(new Property { Name = "stellaops:layerDigest", Value = component.LayerDigest });
|
||||
|
||||
for (var index = 0; index < component.Evidence.Length; index++)
|
||||
{
|
||||
var evidence = component.Evidence[index];
|
||||
var builder = new StringBuilder(evidence.Kind);
|
||||
builder.Append(':').Append(evidence.Value);
|
||||
if (!string.IsNullOrWhiteSpace(evidence.Source))
|
||||
{
|
||||
builder.Append('@').Append(evidence.Source);
|
||||
}
|
||||
|
||||
properties.Add(new Property
|
||||
{
|
||||
Name = $"stellaops:evidence[{index}]",
|
||||
Value = builder.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? null : properties;
|
||||
}
|
||||
|
||||
private static List<Dependency>? BuildDependencies(ImmutableArray<ComponentRecord> components)
|
||||
{
|
||||
var componentKeys = components.Select(static c => c.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
|
||||
var dependencies = new List<Dependency>();
|
||||
|
||||
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var filtered = component.Dependencies.Where(componentKeys.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
if (filtered.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dependencies.Add(new Dependency
|
||||
{
|
||||
Ref = component.Identity.Key,
|
||||
Dependencies = filtered.Select(key => new Dependency { Ref = key }).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
return dependencies.Count == 0 ? null : dependencies;
|
||||
}
|
||||
|
||||
private static Component.Classification MapClassification(string? type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
return Component.Classification.Library;
|
||||
}
|
||||
|
||||
return type.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"application" => Component.Classification.Application,
|
||||
"framework" => Component.Classification.Framework,
|
||||
"container" => Component.Classification.Container,
|
||||
"operating-system" or "os" => Component.Classification.Operating_System,
|
||||
"device" => Component.Classification.Device,
|
||||
"firmware" => Component.Classification.Firmware,
|
||||
"file" => Component.Classification.File,
|
||||
_ => Component.Classification.Library,
|
||||
};
|
||||
}
|
||||
|
||||
private static Component.ComponentScope? MapScope(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scope.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"runtime" or "required" => Component.ComponentScope.Required,
|
||||
"development" or "optional" => Component.ComponentScope.Optional,
|
||||
"excluded" => Component.ComponentScope.Excluded,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user