Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,20 @@
# StellaOps.Scanner.Emit — Agent Charter
## Mission
Assemble deterministic SBOM artifacts (inventory, usage, BOM index) from analyzer fragments and usage telemetry, and prepare them for storage, signing, and distribution.
## Responsibilities
- Merge per-layer/component fragments into CycloneDX JSON/Protobuf SBOMs.
- 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.
## Interfaces & Dependencies
- Consumes analyzer outputs (OS, language, native) and EntryTrace usage annotations.
- Produces artifacts persisted via `StellaOps.Scanner.Storage` and referenced by policy/report pipelines.
- Relies on deterministic primitives from `StellaOps.Scanner.Core` for timestamps, hashing, and serialization defaults.
## Testing Expectations
- Golden SBOM and BOM index fixtures with determinism checks.
- Schema validation for CycloneDX outputs and BOM index binary layout.
- Integration tests exercising packaging helpers with in-memory storage fakes.

View File

@@ -0,0 +1,594 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using CycloneDX;
using CycloneDX.Models;
using CycloneDX.Models.Vulnerabilities;
using JsonSerializer = CycloneDX.Json.Serializer;
using ProtoSerializer = CycloneDX.Protobuf.Serializer;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
public sealed class CycloneDxComposer
{
private static readonly Guid SerialNamespace = new("0d3a422b-6e1b-4d9b-9c35-654b706c97e8");
private const string InventoryMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6";
private const string UsageMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6; view=usage";
private const string InventoryMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6";
private const string UsageMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6; view=usage";
public SbomCompositionResult Compose(SbomCompositionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
throw new ArgumentException("At least one layer fragment is required.", nameof(request));
}
var graph = ComponentGraphBuilder.Build(request.LayerFragments);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var inventoryArtifact = BuildArtifact(
request,
graph,
SbomView.Inventory,
graph.Components,
generatedAt,
InventoryMediaTypeJson,
InventoryMediaTypeProtobuf);
var usageComponents = graph.Components
.Where(static component => component.Usage.UsedByEntrypoint)
.ToImmutableArray();
CycloneDxArtifact? usageArtifact = null;
if (!usageComponents.IsEmpty)
{
usageArtifact = BuildArtifact(
request,
graph,
SbomView.Usage,
usageComponents,
generatedAt,
UsageMediaTypeJson,
UsageMediaTypeProtobuf);
}
return new SbomCompositionResult
{
Inventory = inventoryArtifact,
Usage = usageArtifact,
Graph = graph,
};
}
private CycloneDxArtifact BuildArtifact(
SbomCompositionRequest request,
ComponentGraph graph,
SbomView view,
ImmutableArray<AggregatedComponent> components,
DateTimeOffset generatedAt,
string jsonMediaType,
string protobufMediaType)
{
var bom = BuildBom(request, graph, view, components, generatedAt);
var json = JsonSerializer.Serialize(bom);
var jsonBytes = Encoding.UTF8.GetBytes(json);
var protobufBytes = ProtoSerializer.Serialize(bom);
var jsonHash = ComputeSha256(jsonBytes);
var protobufHash = ComputeSha256(protobufBytes);
return new CycloneDxArtifact
{
View = view,
SerialNumber = bom.SerialNumber ?? string.Empty,
GeneratedAt = generatedAt,
Components = components,
JsonBytes = jsonBytes,
JsonSha256 = jsonHash,
JsonMediaType = jsonMediaType,
ProtobufBytes = protobufBytes,
ProtobufSha256 = protobufHash,
ProtobufMediaType = protobufMediaType,
};
}
private Bom BuildBom(
SbomCompositionRequest request,
ComponentGraph graph,
SbomView view,
ImmutableArray<AggregatedComponent> components,
DateTimeOffset generatedAt)
{
var bom = new Bom
{
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = BuildMetadata(request, view, generatedAt),
Components = BuildComponents(components),
Dependencies = BuildDependencies(components),
};
var vulnerabilities = BuildVulnerabilities(request, graph, components);
if (vulnerabilities is not null)
{
bom.Vulnerabilities = vulnerabilities;
}
var serialPayload = $"{request.Image.ImageDigest}|{view}|{ScannerTimestamps.ToIso8601(generatedAt)}";
bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}";
return bom;
}
private static Metadata BuildMetadata(SbomCompositionRequest request, SbomView view, DateTimeOffset generatedAt)
{
var metadata = new Metadata
{
Timestamp = generatedAt.UtcDateTime,
Component = BuildMetadataComponent(request.Image),
};
if (request.AdditionalProperties is not null && request.AdditionalProperties.Count > 0)
{
metadata.Properties = request.AdditionalProperties
.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair => new Property
{
Name = pair.Key,
Value = pair.Value,
})
.ToList();
}
if (metadata.Properties is null)
{
metadata.Properties = new List<Property>();
}
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,
});
}
}
metadata.Properties.Add(new Property
{
Name = "stellaops:sbom.view",
Value = view.ToString().ToLowerInvariant(),
});
return metadata;
}
private static Component BuildMetadataComponent(ImageArtifactDescriptor image)
{
var digest = image.ImageDigest;
var digestValue = digest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var bomRef = $"image:{digestValue}";
var name = image.ImageReference ?? image.Repository ?? digest;
var component = new Component
{
BomRef = bomRef,
Type = Component.Classification.Container,
Name = name,
Version = digestValue,
Purl = BuildImagePurl(image),
Properties = new List<Property>
{
new() { Name = "stellaops:image.digest", Value = image.ImageDigest },
},
};
if (!string.IsNullOrWhiteSpace(image.ImageReference))
{
component.Properties.Add(new Property { Name = "stellaops:image.reference", Value = image.ImageReference });
}
if (!string.IsNullOrWhiteSpace(image.Repository))
{
component.Properties.Add(new Property { Name = "stellaops:image.repository", Value = image.Repository });
}
if (!string.IsNullOrWhiteSpace(image.Tag))
{
component.Properties.Add(new Property { Name = "stellaops:image.tag", Value = image.Tag });
}
if (!string.IsNullOrWhiteSpace(image.Architecture))
{
component.Properties.Add(new Property { Name = "stellaops:image.architecture", Value = image.Architecture });
}
return component;
}
private static string? BuildImagePurl(ImageArtifactDescriptor image)
{
if (string.IsNullOrWhiteSpace(image.Repository))
{
return null;
}
var repo = image.Repository.Trim();
var tag = string.IsNullOrWhiteSpace(image.Tag) ? null : image.Tag.Trim();
var digest = image.ImageDigest.Trim();
var purlBuilder = new StringBuilder("pkg:oci/");
purlBuilder.Append(repo.Replace("/", "%2F", StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(tag))
{
purlBuilder.Append('@').Append(tag);
}
purlBuilder.Append("?digest=").Append(Uri.EscapeDataString(digest));
if (!string.IsNullOrWhiteSpace(image.Architecture))
{
purlBuilder.Append("&arch=").Append(Uri.EscapeDataString(image.Architecture.Trim()));
}
return purlBuilder.ToString();
}
private static List<Component> BuildComponents(ImmutableArray<AggregatedComponent> components)
{
var result = new List<Component>(components.Length);
foreach (var component in components)
{
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(AggregatedComponent 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:firstLayerDigest", Value = component.FirstLayerDigest });
if (component.LastLayerDigest is not null)
{
properties.Add(new Property { Name = "stellaops:lastLayerDigest", Value = component.LastLayerDigest });
}
if (!component.LayerDigests.IsDefaultOrEmpty)
{
properties.Add(new Property
{
Name = "stellaops:layerDigests",
Value = string.Join(",", component.LayerDigests),
});
}
if (component.Usage.UsedByEntrypoint)
{
properties.Add(new Property { Name = "stellaops:usage.usedByEntrypoint", Value = "true" });
}
if (!component.Usage.Entrypoints.IsDefaultOrEmpty && component.Usage.Entrypoints.Length > 0)
{
for (var index = 0; index < component.Usage.Entrypoints.Length; index++)
{
properties.Add(new Property
{
Name = $"stellaops:usage.entrypoint[{index}]",
Value = component.Usage.Entrypoints[index],
});
}
}
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;
}
private static List<Dependency>? BuildDependencies(ImmutableArray<AggregatedComponent> components)
{
var componentKeys = components.Select(static component => component.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
var dependencies = new List<Dependency>();
foreach (var component in components)
{
if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0)
{
continue;
}
var filtered = component.Dependencies.Where(componentKeys.Contains).ToArray();
if (filtered.Length == 0)
{
continue;
}
dependencies.Add(new Dependency
{
Ref = component.Identity.Key,
Dependencies = filtered
.Select(dependencyKey => new Dependency { Ref = dependencyKey })
.ToList(),
});
}
return dependencies.Count == 0 ? null : dependencies;
}
private static List<Vulnerability>? BuildVulnerabilities(
SbomCompositionRequest request,
ComponentGraph graph,
ImmutableArray<AggregatedComponent> viewComponents)
{
if (request.PolicyFindings.IsDefaultOrEmpty || request.PolicyFindings.Length == 0)
{
return null;
}
if (viewComponents.IsDefaultOrEmpty || viewComponents.Length == 0)
{
return null;
}
var componentKeys = viewComponents
.Select(static component => component.Identity.Key)
.ToImmutableHashSet(StringComparer.Ordinal);
if (componentKeys.Count == 0)
{
return null;
}
var vulnerabilities = new List<Vulnerability>(request.PolicyFindings.Length);
foreach (var finding in request.PolicyFindings)
{
if (!graph.ComponentMap.TryGetValue(finding.ComponentKey, out var component))
{
continue;
}
if (!componentKeys.Contains(component.Identity.Key))
{
continue;
}
var ratings = BuildRatings(finding.Score);
var properties = BuildVulnerabilityProperties(finding);
var vulnerability = new Vulnerability
{
BomRef = finding.FindingId,
Id = finding.VulnerabilityId ?? finding.FindingId,
Source = new Source { Name = "StellaOps.Policy" },
Affects = new List<Affects>
{
new() { Ref = component.Identity.Key }
},
Ratings = ratings,
Properties = properties,
};
vulnerabilities.Add(vulnerability);
}
return vulnerabilities.Count == 0 ? null : vulnerabilities;
}
private static List<Rating>? BuildRatings(double score)
{
if (double.IsNaN(score) || double.IsInfinity(score))
{
return null;
}
return new List<Rating>
{
new()
{
Method = ScoreMethod.Other,
Justification = "StellaOps Policy score",
Score = score,
Severity = Severity.Unknown,
Source = new Source { Name = "StellaOps.Policy" },
}
};
}
private static List<Property>? BuildVulnerabilityProperties(SbomPolicyFinding finding)
{
var properties = new List<Property>();
AddStringProperty(properties, "stellaops:policy.status", finding.Status);
AddStringProperty(properties, "stellaops:policy.configVersion", finding.ConfigVersion);
AddBooleanProperty(properties, "stellaops:policy.quiet", finding.Quiet);
AddStringProperty(properties, "stellaops:policy.quietedBy", finding.QuietedBy);
AddStringProperty(properties, "stellaops:policy.confidenceBand", finding.ConfidenceBand);
AddStringProperty(properties, "stellaops:policy.sourceTrust", finding.SourceTrust);
AddStringProperty(properties, "stellaops:policy.reachability", finding.Reachability);
AddDoubleProperty(properties, "stellaops:policy.score", finding.Score);
AddNullableDoubleProperty(properties, "stellaops:policy.unknownConfidence", finding.UnknownConfidence);
AddNullableDoubleProperty(properties, "stellaops:policy.unknownAgeDays", finding.UnknownAgeDays);
if (!finding.Inputs.IsDefaultOrEmpty && finding.Inputs.Length > 0)
{
foreach (var (key, value) in finding.Inputs)
{
AddDoubleProperty(properties, $"stellaops:policy.input.{key}", value);
}
}
if (properties.Count == 0)
{
return null;
}
properties.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Name, right.Name));
return properties;
}
private static void AddStringProperty(ICollection<Property> properties, string name, string? value)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
{
return;
}
properties.Add(new Property
{
Name = name,
Value = value.Trim(),
});
}
private static void AddBooleanProperty(ICollection<Property> properties, string name, bool value)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
properties.Add(new Property
{
Name = name,
Value = value ? "true" : "false",
});
}
private static void AddDoubleProperty(ICollection<Property> properties, string name, double value)
{
if (string.IsNullOrWhiteSpace(name) || double.IsNaN(value) || double.IsInfinity(value))
{
return;
}
properties.Add(new Property
{
Name = name,
Value = FormatDouble(value),
});
}
private static void AddNullableDoubleProperty(ICollection<Property> properties, string name, double? value)
{
if (!value.HasValue)
{
return;
}
AddDoubleProperty(properties, name, value.Value);
}
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 FormatDouble(double value)
=> value.ToString("0.############################", CultureInfo.InvariantCulture);
private static string ComputeSha256(byte[] bytes)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
public sealed record ImageArtifactDescriptor
{
public string ImageDigest { get; init; } = string.Empty;
public string? ImageReference { get; init; }
= null;
public string? Repository { get; init; }
= null;
public string? Tag { get; init; }
= null;
public string? Architecture { get; init; }
= null;
}
public sealed record SbomCompositionRequest
{
public required ImageArtifactDescriptor Image { get; init; }
public required ImmutableArray<LayerComponentFragment> LayerFragments { get; init; }
public DateTimeOffset GeneratedAt { get; init; }
= ScannerTimestamps.UtcNow();
public string? GeneratorName { get; init; }
= null;
public string? GeneratorVersion { get; init; }
= null;
public IReadOnlyDictionary<string, string>? AdditionalProperties { get; init; }
= null;
public ImmutableArray<SbomPolicyFinding> PolicyFindings { get; init; }
= ImmutableArray<SbomPolicyFinding>.Empty;
public static SbomCompositionRequest Create(
ImageArtifactDescriptor image,
IEnumerable<LayerComponentFragment> fragments,
DateTimeOffset generatedAt,
string? generatorName = null,
string? generatorVersion = null,
IReadOnlyDictionary<string, string>? properties = null,
IEnumerable<SbomPolicyFinding>? policyFindings = null)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(fragments);
var normalizedImage = new ImageArtifactDescriptor
{
ImageDigest = ScannerIdentifiers.NormalizeDigest(image.ImageDigest) ?? throw new ArgumentException("Image digest is required.", nameof(image)),
ImageReference = Normalize(image.ImageReference),
Repository = Normalize(image.Repository),
Tag = Normalize(image.Tag),
Architecture = Normalize(image.Architecture),
};
return new SbomCompositionRequest
{
Image = normalizedImage,
LayerFragments = fragments.ToImmutableArray(),
GeneratedAt = ScannerTimestamps.Normalize(generatedAt),
GeneratorName = Normalize(generatorName),
GeneratorVersion = Normalize(generatorVersion),
AdditionalProperties = properties,
PolicyFindings = NormalizePolicyFindings(policyFindings),
};
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static ImmutableArray<SbomPolicyFinding> NormalizePolicyFindings(IEnumerable<SbomPolicyFinding>? policyFindings)
{
if (policyFindings is null)
{
return ImmutableArray<SbomPolicyFinding>.Empty;
}
var builder = ImmutableArray.CreateBuilder<SbomPolicyFinding>();
foreach (var finding in policyFindings)
{
if (finding is null)
{
continue;
}
SbomPolicyFinding normalized;
try
{
normalized = finding.Normalize();
}
catch (ArgumentException)
{
continue;
}
if (string.IsNullOrWhiteSpace(normalized.FindingId) || string.IsNullOrWhiteSpace(normalized.ComponentKey))
{
continue;
}
builder.Add(normalized);
}
if (builder.Count == 0)
{
return ImmutableArray<SbomPolicyFinding>.Empty;
}
return builder
.ToImmutable()
.OrderBy(static finding => finding.FindingId, StringComparer.Ordinal)
.ThenBy(static finding => finding.ComponentKey, StringComparer.Ordinal)
.ThenBy(static finding => finding.VulnerabilityId ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Composition;
public sealed record CycloneDxArtifact
{
public required SbomView View { get; init; }
public required string SerialNumber { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required ImmutableArray<AggregatedComponent> Components { get; init; }
public required byte[] JsonBytes { get; init; }
public required string JsonSha256 { get; init; }
public required string JsonMediaType { get; init; }
public required byte[] ProtobufBytes { get; init; }
public required string ProtobufSha256 { get; init; }
public required string ProtobufMediaType { get; init; }
}
public sealed record SbomCompositionResult
{
public required CycloneDxArtifact Inventory { get; init; }
public CycloneDxArtifact? Usage { get; init; }
public required ComponentGraph Graph { get; init; }
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Emit.Composition;
public sealed record SbomPolicyFinding
{
public required string FindingId { get; init; }
public required string ComponentKey { get; init; }
public string? VulnerabilityId { get; init; }
public string Status { get; init; } = string.Empty;
public double Score { get; init; }
public string ConfigVersion { get; init; } = string.Empty;
public ImmutableArray<KeyValuePair<string, double>> Inputs { get; init; } = ImmutableArray<KeyValuePair<string, double>>.Empty;
public string? QuietedBy { get; init; }
public bool Quiet { get; init; }
public double? UnknownConfidence { get; init; }
public string? ConfidenceBand { get; init; }
public double? UnknownAgeDays { get; init; }
public string? SourceTrust { get; init; }
public string? Reachability { get; init; }
internal SbomPolicyFinding Normalize()
{
ArgumentException.ThrowIfNullOrWhiteSpace(FindingId);
ArgumentException.ThrowIfNullOrWhiteSpace(ComponentKey);
var normalizedInputs = Inputs.IsDefaultOrEmpty
? ImmutableArray<KeyValuePair<string, double>>.Empty
: Inputs
.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key))
.Select(static pair => new KeyValuePair<string, double>(pair.Key.Trim(), pair.Value))
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableArray();
return this with
{
FindingId = FindingId.Trim(),
ComponentKey = ComponentKey.Trim(),
VulnerabilityId = string.IsNullOrWhiteSpace(VulnerabilityId) ? null : VulnerabilityId.Trim(),
Status = string.IsNullOrWhiteSpace(Status) ? string.Empty : Status.Trim(),
ConfigVersion = string.IsNullOrWhiteSpace(ConfigVersion) ? string.Empty : ConfigVersion.Trim(),
QuietedBy = string.IsNullOrWhiteSpace(QuietedBy) ? null : QuietedBy.Trim(),
ConfidenceBand = string.IsNullOrWhiteSpace(ConfidenceBand) ? null : ConfidenceBand.Trim(),
SourceTrust = string.IsNullOrWhiteSpace(SourceTrust) ? null : SourceTrust.Trim(),
Reachability = string.IsNullOrWhiteSpace(Reachability) ? null : Reachability.Trim(),
Inputs = normalizedInputs
};
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Composition;
public static class ScanAnalysisCompositionBuilder
{
public static SbomCompositionRequest FromAnalysis(
ScanAnalysisStore analysis,
ImageArtifactDescriptor image,
DateTimeOffset generatedAt,
string? generatorName = null,
string? generatorVersion = null,
IReadOnlyDictionary<string, string>? properties = null)
{
ArgumentNullException.ThrowIfNull(analysis);
ArgumentNullException.ThrowIfNull(image);
var fragments = analysis.GetLayerFragments();
if (fragments.IsDefaultOrEmpty)
{
throw new InvalidOperationException("No layer fragments recorded in analysis.");
}
return SbomCompositionRequest.Create(
image,
fragments,
generatedAt,
generatorName,
generatorVersion,
properties);
}
public static ComponentGraph BuildComponentGraph(ScanAnalysisStore analysis)
{
ArgumentNullException.ThrowIfNull(analysis);
var fragments = analysis.GetLayerFragments();
if (fragments.IsDefaultOrEmpty)
{
return new ComponentGraph
{
Layers = ImmutableArray<LayerComponentFragment>.Empty,
Components = ImmutableArray<AggregatedComponent>.Empty,
ComponentMap = ImmutableDictionary<string, AggregatedComponent>.Empty,
};
}
return ComponentGraphBuilder.Build(fragments);
}
}

View File

@@ -0,0 +1,239 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Collections.Special;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Index;
public sealed record BomIndexBuildRequest
{
public required string ImageDigest { get; init; }
public required ComponentGraph Graph { get; init; }
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed record BomIndexArtifact
{
public required byte[] Bytes { get; init; }
public required string Sha256 { get; init; }
public required int LayerCount { get; init; }
public required int ComponentCount { get; init; }
public required int EntrypointCount { get; init; }
public string MediaType { get; init; } = "application/vnd.stellaops.bom-index.v1+binary";
}
public sealed class BomIndexBuilder
{
private static readonly byte[] Magic = Encoding.ASCII.GetBytes("BOMIDX1");
public BomIndexArtifact Build(BomIndexBuildRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
throw new ArgumentException("Image digest is required.", nameof(request));
}
var normalizedDigest = request.ImageDigest.Trim();
var graph = request.Graph ?? throw new ArgumentNullException(nameof(request.Graph));
var layers = graph.Layers.Select(layer => layer.LayerDigest).ToImmutableArray();
var components = graph.Components;
var layerIndex = new Dictionary<string, int>(layers.Length, StringComparer.Ordinal);
for (var i = 0; i < layers.Length; i++)
{
layerIndex[layers[i]] = i;
}
var entrypointSet = new SortedSet<string>(StringComparer.Ordinal);
foreach (var component in components)
{
if (!component.Usage.Entrypoints.IsDefaultOrEmpty)
{
foreach (var entry in component.Usage.Entrypoints)
{
if (!string.IsNullOrWhiteSpace(entry))
{
entrypointSet.Add(entry);
}
}
}
}
var entrypoints = entrypointSet.ToImmutableArray();
var entrypointIndex = new Dictionary<string, int>(entrypoints.Length, StringComparer.Ordinal);
for (var i = 0; i < entrypoints.Length; i++)
{
entrypointIndex[entrypoints[i]] = i;
}
using var buffer = new MemoryStream();
using var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true);
WriteHeader(writer, normalizedDigest, request.GeneratedAt, layers.Length, components.Length, entrypoints.Length);
WriteLayerTable(writer, layers);
WriteComponentTable(writer, components);
WriteComponentBitmaps(writer, components, layerIndex);
if (entrypoints.Length > 0)
{
WriteEntrypointTable(writer, entrypoints);
WriteEntrypointBitmaps(writer, components, entrypointIndex);
}
writer.Flush();
var bytes = buffer.ToArray();
var sha256 = ComputeSha256(bytes);
return new BomIndexArtifact
{
Bytes = bytes,
Sha256 = sha256,
LayerCount = layers.Length,
ComponentCount = components.Length,
EntrypointCount = entrypoints.Length,
};
}
private static void WriteHeader(BinaryWriter writer, string imageDigest, DateTimeOffset generatedAt, int layerCount, int componentCount, int entrypointCount)
{
writer.Write(Magic);
writer.Write((ushort)1); // version
var flags = (ushort)0;
if (entrypointCount > 0)
{
flags |= 0x1;
}
writer.Write(flags);
var digestBytes = Encoding.UTF8.GetBytes(imageDigest);
if (digestBytes.Length > ushort.MaxValue)
{
throw new InvalidOperationException("Image digest exceeds maximum length.");
}
writer.Write((ushort)digestBytes.Length);
writer.Write(digestBytes);
var unixMicroseconds = ToUnixMicroseconds(generatedAt);
writer.Write(unixMicroseconds);
writer.Write((uint)layerCount);
writer.Write((uint)componentCount);
writer.Write((uint)entrypointCount);
}
private static void WriteLayerTable(BinaryWriter writer, ImmutableArray<string> layers)
{
foreach (var layer in layers)
{
WriteUtf8String(writer, layer);
}
}
private static void WriteComponentTable(BinaryWriter writer, ImmutableArray<AggregatedComponent> components)
{
foreach (var component in components)
{
var key = component.Identity.Purl ?? component.Identity.Key;
WriteUtf8String(writer, key);
}
}
private static void WriteComponentBitmaps(BinaryWriter writer, ImmutableArray<AggregatedComponent> components, IReadOnlyDictionary<string, int> layerIndex)
{
foreach (var component in components)
{
var indices = component.LayerDigests
.Select(digest => layerIndex.TryGetValue(digest, out var index) ? index : -1)
.Where(index => index >= 0)
.Distinct()
.OrderBy(index => index)
.ToArray();
var bitmap = RoaringBitmap.Create(indices).Optimize();
WriteBitmap(writer, bitmap);
}
}
private static void WriteEntrypointTable(BinaryWriter writer, ImmutableArray<string> entrypoints)
{
foreach (var entry in entrypoints)
{
WriteUtf8String(writer, entry);
}
}
private static void WriteEntrypointBitmaps(BinaryWriter writer, ImmutableArray<AggregatedComponent> components, IReadOnlyDictionary<string, int> entrypointIndex)
{
foreach (var component in components)
{
var indices = component.Usage.Entrypoints
.Where(entrypointIndex.ContainsKey)
.Select(entry => entrypointIndex[entry])
.Distinct()
.OrderBy(index => index)
.ToArray();
if (indices.Length == 0)
{
writer.Write((uint)0);
continue;
}
var bitmap = RoaringBitmap.Create(indices).Optimize();
WriteBitmap(writer, bitmap);
}
}
private static void WriteBitmap(BinaryWriter writer, RoaringBitmap bitmap)
{
using var ms = new MemoryStream();
RoaringBitmap.Serialize(bitmap, ms);
var data = ms.ToArray();
writer.Write((uint)data.Length);
writer.Write(data);
}
private static void WriteUtf8String(BinaryWriter writer, string value)
{
var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
if (bytes.Length > ushort.MaxValue)
{
throw new InvalidOperationException("String value exceeds maximum length supported by BOM index.");
}
writer.Write((ushort)bytes.Length);
writer.Write(bytes);
}
private static long ToUnixMicroseconds(DateTimeOffset timestamp)
{
var normalized = timestamp.ToUniversalTime();
var microseconds = normalized.ToUnixTimeMilliseconds() * 1000L;
microseconds += normalized.Ticks % TimeSpan.TicksPerMillisecond / 10;
return microseconds;
}
private static string ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Serialization;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.Emit.Index;
using StellaOps.Scanner.Storage.Catalog;
namespace StellaOps.Scanner.Emit.Packaging;
public sealed record ScannerArtifactDescriptor
{
public required ArtifactDocumentType Type { get; init; }
public required ArtifactDocumentFormat Format { get; init; }
public required string MediaType { get; init; }
public required ReadOnlyMemory<byte> Content { get; init; }
public required string Sha256 { get; init; }
public SbomView? View { get; init; }
public long Size => Content.Length;
}
public sealed record ScannerArtifactManifestEntry
{
public required string Kind { get; init; }
public required ArtifactDocumentType Type { get; init; }
public required ArtifactDocumentFormat Format { get; init; }
public required string MediaType { get; init; }
public required string Sha256 { get; init; }
public required long Size { get; init; }
public SbomView? View { get; init; }
}
public sealed record ScannerArtifactManifest
{
public required string ImageDigest { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required ImmutableArray<ScannerArtifactManifestEntry> Artifacts { get; init; }
public byte[] ToJsonBytes()
=> JsonSerializer.SerializeToUtf8Bytes(this, ScannerJsonOptions.Default);
}
public sealed record ScannerArtifactPackage
{
public required ImmutableArray<ScannerArtifactDescriptor> Artifacts { get; init; }
public required ScannerArtifactManifest Manifest { get; init; }
}
public sealed class ScannerArtifactPackageBuilder
{
public ScannerArtifactPackage Build(
string imageDigest,
DateTimeOffset generatedAt,
SbomCompositionResult composition,
BomIndexArtifact bomIndex)
{
if (string.IsNullOrWhiteSpace(imageDigest))
{
throw new ArgumentException("Image digest is required.", nameof(imageDigest));
}
var descriptors = new List<ScannerArtifactDescriptor>();
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxJson, composition.Inventory.JsonMediaType, composition.Inventory.JsonBytes, composition.Inventory.JsonSha256, SbomView.Inventory));
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxProtobuf, composition.Inventory.ProtobufMediaType, composition.Inventory.ProtobufBytes, composition.Inventory.ProtobufSha256, SbomView.Inventory));
if (composition.Usage is not null)
{
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxJson, composition.Usage.JsonMediaType, composition.Usage.JsonBytes, composition.Usage.JsonSha256, SbomView.Usage));
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxProtobuf, composition.Usage.ProtobufMediaType, composition.Usage.ProtobufBytes, composition.Usage.ProtobufSha256, SbomView.Usage));
}
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
var manifest = new ScannerArtifactManifest
{
ImageDigest = imageDigest.Trim(),
GeneratedAt = generatedAt,
Artifacts = descriptors
.Select(ToManifestEntry)
.OrderBy(entry => entry.Kind, StringComparer.Ordinal)
.ThenBy(entry => entry.Format)
.ToImmutableArray(),
};
return new ScannerArtifactPackage
{
Artifacts = descriptors.ToImmutableArray(),
Manifest = manifest,
};
}
private static ScannerArtifactDescriptor CreateDescriptor(
ArtifactDocumentType type,
ArtifactDocumentFormat format,
string mediaType,
ReadOnlyMemory<byte> content,
string sha256,
SbomView? view)
{
return new ScannerArtifactDescriptor
{
Type = type,
Format = format,
MediaType = mediaType,
Content = content,
Sha256 = sha256,
View = view,
};
}
private static ScannerArtifactManifestEntry ToManifestEntry(ScannerArtifactDescriptor descriptor)
{
var kind = descriptor.Type switch
{
ArtifactDocumentType.Index => "bom-index",
ArtifactDocumentType.ImageBom when descriptor.View == SbomView.Usage => "sbom-usage",
ArtifactDocumentType.ImageBom => "sbom-inventory",
ArtifactDocumentType.LayerBom => "layer-sbom",
ArtifactDocumentType.Diff => "diff",
ArtifactDocumentType.Attestation => "attestation",
_ => descriptor.Type.ToString().ToLowerInvariant(),
};
return new ScannerArtifactManifestEntry
{
Kind = kind,
Type = descriptor.Type,
Format = descriptor.Format,
MediaType = descriptor.MediaType,
Sha256 = descriptor.Sha256,
Size = descriptor.Size,
View = descriptor.View,
};
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="10.0.1" />
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# Scanner Emit Task Board (Sprint 10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-EMIT-10-601 | DONE (2025-10-22) | Emit Guild | SCANNER-CACHE-10-101 | Compose inventory SBOM (CycloneDX JSON/Protobuf) from layer fragments with deterministic ordering. | Inventory SBOM validated against schema; fixtures confirm deterministic output. |
| SCANNER-EMIT-10-602 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-601 | Compose usage SBOM leveraging EntryTrace to flag actual usage; ensure separate view toggles. | Usage SBOM tests confirm correct subset; API contract documented. |
| SCANNER-EMIT-10-603 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-601 | Generate BOM index sidecar (purl table + roaring bitmap + usedByEntrypoint flag). | Index format validated; query helpers proven; stored artifacts hashed deterministically. |
| SCANNER-EMIT-10-604 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-602 | Package artifacts for export + attestation (naming, compression, manifests). | Export pipeline produces deterministic file paths/hashes; integration test with storage passes. |
| SCANNER-EMIT-10-605 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-603 | Emit BOM-Index sidecar schema/fixtures (`bom-index@1`) and note CRITICAL PATH for Scheduler. | Schema + fixtures in docs/artifacts/bom-index; tests `BOMIndexGoldenIsStable` green. |
| SCANNER-EMIT-10-606 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-605 | Integrate EntryTrace usage flags into BOM-Index; document semantics. | Usage bits present in sidecar; integration tests with EntryTrace fixtures pass. |
| SCANNER-EMIT-17-701 | DONE (2025-10-26) | Emit Guild, Native Analyzer Guild | SCANNER-EMIT-10-602 | Record GNU build-id for ELF components and surface it in inventory/usage SBOM plus diff payloads with deterministic ordering. | Native analyzer emits buildId for every ELF executable/library, SBOM/diff fixtures updated with canonical `buildId` field, regression tests prove stability, docs call out debug-symbol lookup flow. |
| SCANNER-EMIT-10-607 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-604, POLICY-CORE-09-005 | Embed scoring inputs, confidence band, and `quietedBy` provenance into CycloneDX 1.6 and DSSE predicates; verify deterministic serialization. | SBOM/attestation fixtures include score, inputs, configVersion, quiet metadata; golden tests confirm canonical output. |