finish off sprint advisories and sprints
This commit is contained in:
@@ -129,7 +129,11 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: VEX ingestion + lattice merge.
|
||||
var (mergedStatements, conflictCount) = await MergeVexStatementsAsync(index, options, ct).ConfigureAwait(false);
|
||||
var (mergedStatements, conflictCount) = await MergeVexStatementsAsync(
|
||||
index,
|
||||
Path.Combine(inputDirectory, "attestations"),
|
||||
options,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// Step 5: Graph emission.
|
||||
var graph = BuildGraph(
|
||||
@@ -247,6 +251,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
|
||||
private static async Task<(Dictionary<string, VexStatement> Statements, int ConflictCount)> MergeVexStatementsAsync(
|
||||
ArtifactIndex index,
|
||||
string attestationsDirectory,
|
||||
ReconciliationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -258,9 +263,12 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
{
|
||||
foreach (var vexRef in entry.VexDocuments)
|
||||
{
|
||||
// Resolve relative path to absolute
|
||||
var absolutePath = Path.Combine(attestationsDirectory, vexRef.FilePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!documentCache.TryGetValue(vexRef.FilePath, out var document))
|
||||
{
|
||||
var loaded = await TryLoadOpenVexDocumentAsync(vexRef.FilePath, ct).ConfigureAwait(false);
|
||||
var loaded = await TryLoadOpenVexDocumentAsync(absolutePath, ct).ConfigureAwait(false);
|
||||
if (loaded is null)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -248,6 +248,7 @@ public sealed record NormalizationOptions
|
||||
SortArrays = true,
|
||||
LowercaseUris = true,
|
||||
StripTimestamps = true,
|
||||
StripVolatileFields = true,
|
||||
NormalizeKeys = true
|
||||
};
|
||||
|
||||
@@ -266,6 +267,13 @@ public sealed record NormalizationOptions
|
||||
/// </summary>
|
||||
public bool StripTimestamps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Strip SBOM-specific volatile fields that vary between generation runs
|
||||
/// (e.g., serialNumber, metadata.tools, creationInfo.creators).
|
||||
/// See docs/contracts/sbom-volatile-fields.json for the authoritative field list.
|
||||
/// </summary>
|
||||
public bool StripVolatileFields { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalize JSON keys to camelCase.
|
||||
/// </summary>
|
||||
|
||||
@@ -233,6 +233,7 @@ public sealed class SbomNormalizer
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CycloneDX metadata.
|
||||
/// Strips volatile fields: timestamp, tools (per docs/contracts/sbom-volatile-fields.json).
|
||||
/// </summary>
|
||||
private JsonNode NormalizeCycloneDxMetadata(JsonNode node)
|
||||
{
|
||||
@@ -245,7 +246,12 @@ public sealed class SbomNormalizer
|
||||
|
||||
var sortedKeys = obj
|
||||
.Select(kv => kv.Key)
|
||||
.Where(key => _options.StripTimestamps ? key != "timestamp" : true)
|
||||
.Where(key =>
|
||||
{
|
||||
if (_options.StripTimestamps && key == "timestamp") return false;
|
||||
if (_options.StripVolatileFields && key is "tools" or "authors") return false;
|
||||
return true;
|
||||
})
|
||||
.OrderBy(k => k, StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in sortedKeys)
|
||||
@@ -386,6 +392,7 @@ public sealed class SbomNormalizer
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes SPDX creation info.
|
||||
/// Strips volatile fields: created, creators, licenseListVersion (per docs/contracts/sbom-volatile-fields.json).
|
||||
/// </summary>
|
||||
private JsonNode NormalizeSpdxCreationInfo(JsonNode node)
|
||||
{
|
||||
@@ -398,7 +405,12 @@ public sealed class SbomNormalizer
|
||||
|
||||
var sortedKeys = obj
|
||||
.Select(kv => kv.Key)
|
||||
.Where(key => _options.StripTimestamps ? key != "created" : true)
|
||||
.Where(key =>
|
||||
{
|
||||
if (_options.StripTimestamps && key == "created") return false;
|
||||
if (_options.StripVolatileFields && key is "creators" or "licenseListVersion") return false;
|
||||
return true;
|
||||
})
|
||||
.OrderBy(k => k, StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in sortedKeys)
|
||||
@@ -442,14 +454,23 @@ public sealed class SbomNormalizer
|
||||
return obj.ToJsonString();
|
||||
}
|
||||
|
||||
private static bool ShouldStripCycloneDxField(string key)
|
||||
private bool ShouldStripCycloneDxField(string key)
|
||||
{
|
||||
// Fields that should be stripped for canonical form
|
||||
return key == "$schema";
|
||||
// Always strip $schema (non-content metadata)
|
||||
if (key == "$schema") return true;
|
||||
|
||||
if (!_options.StripVolatileFields) return false;
|
||||
|
||||
// Volatile fields per docs/contracts/sbom-volatile-fields.json
|
||||
return key is "serialNumber";
|
||||
}
|
||||
|
||||
private static bool ShouldStripSpdxField(string key)
|
||||
private bool ShouldStripSpdxField(string key)
|
||||
{
|
||||
if (!_options.StripVolatileFields) return false;
|
||||
|
||||
// No root-level SPDX fields are stripped; volatile fields live
|
||||
// inside creationInfo and are handled by NormalizeSpdxCreationInfo.
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-011 - Bundle Integration: function_map Artifact Type
|
||||
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
/// <summary>
|
||||
/// Integration constants and helpers for function_map artifacts in StellaBundle.
|
||||
/// Provides standardized artifact type strings, media types, and factory methods
|
||||
/// for building function-map bundle configurations.
|
||||
/// </summary>
|
||||
public static class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact type strings for bundle manifest entries.
|
||||
/// </summary>
|
||||
public static class ArtifactTypes
|
||||
{
|
||||
/// <summary>Function map predicate JSON.</summary>
|
||||
public const string FunctionMap = "function-map";
|
||||
|
||||
/// <summary>DSSE-signed function map statement.</summary>
|
||||
public const string FunctionMapDsse = "function-map.dsse";
|
||||
|
||||
/// <summary>Runtime observations data (NDJSON).</summary>
|
||||
public const string Observations = "observations";
|
||||
|
||||
/// <summary>Verification report JSON.</summary>
|
||||
public const string VerificationReport = "verification-report";
|
||||
|
||||
/// <summary>DSSE-signed verification report.</summary>
|
||||
public const string VerificationReportDsse = "verification-report.dsse";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Media types for function-map artifacts.
|
||||
/// </summary>
|
||||
public static class MediaTypes
|
||||
{
|
||||
/// <summary>Function map predicate media type.</summary>
|
||||
public const string FunctionMap = "application/vnd.stella.function-map+json";
|
||||
|
||||
/// <summary>DSSE-signed function map envelope.</summary>
|
||||
public const string FunctionMapDsse = "application/vnd.dsse+json";
|
||||
|
||||
/// <summary>Runtime observations NDJSON.</summary>
|
||||
public const string Observations = "application/x-ndjson";
|
||||
|
||||
/// <summary>Verification report media type.</summary>
|
||||
public const string VerificationReport = "application/vnd.stella.verification-report+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default relative paths within a bundle.
|
||||
/// </summary>
|
||||
public static class BundlePaths
|
||||
{
|
||||
/// <summary>Directory for function maps.</summary>
|
||||
public const string FunctionMapsDir = "function-maps";
|
||||
|
||||
/// <summary>Directory for observations.</summary>
|
||||
public const string ObservationsDir = "observations";
|
||||
|
||||
/// <summary>Directory for verification reports.</summary>
|
||||
public const string VerificationDir = "verification";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a function map predicate file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the function map JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed function map.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapDsseConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.dsse.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMapDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a runtime observations file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the NDJSON observations file on disk.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file (e.g., "2026-01-22").</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsConfig(string sourcePath, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the verification report JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReport,
|
||||
ContentType = MediaTypes.VerificationReport,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportDsseConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReportDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.dsse.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory function map content.
|
||||
/// </summary>
|
||||
/// <param name="content">Function map predicate JSON bytes.</param>
|
||||
/// <param name="serviceName">Service name for the function map.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapFromContent(byte[] content, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory observations content.
|
||||
/// </summary>
|
||||
/// <param name="content">Observations NDJSON bytes.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsFromContent(byte[] content, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type string represents a function-map related artifact.
|
||||
/// </summary>
|
||||
public static bool IsFunctionMapArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMap
|
||||
or ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.Observations
|
||||
or ArtifactTypes.VerificationReport
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type is a DSSE-signed artifact that should be verified.
|
||||
/// </summary>
|
||||
public static bool IsDsseArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
|
||||
private static string SanitizeName(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
var index = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' || ch == '.')
|
||||
{
|
||||
buffer[index++] = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[index++] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
var cleaned = new string(buffer, 0, index).Trim('-');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? "unknown" : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleExportMode.cs
|
||||
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-04)
|
||||
// Description: Two-tier bundle export mode enum
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how much content is included in an exported evidence bundle.
|
||||
/// </summary>
|
||||
public enum BundleExportMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Include only metadata, predicates, proofs, and SBOMs. No binary blobs.
|
||||
/// Typical size: ~50KB.
|
||||
/// </summary>
|
||||
Light,
|
||||
|
||||
/// <summary>
|
||||
/// Include everything in Light mode plus all binary blobs referenced in predicates.
|
||||
/// Typical size: 50MB+.
|
||||
/// </summary>
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for controlling bundle export behavior.
|
||||
/// </summary>
|
||||
public sealed record BundleBuilderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Export mode (Light = metadata only, Full = metadata + binary blobs).
|
||||
/// </summary>
|
||||
public BundleExportMode Mode { get; init; } = BundleExportMode.Light;
|
||||
|
||||
/// <summary>
|
||||
/// Skip blobs larger than this threshold in Full mode (null = no limit).
|
||||
/// </summary>
|
||||
public long? MaxBlobSizeBytes { get; init; }
|
||||
}
|
||||
@@ -138,6 +138,22 @@ public enum BundleArtifactType
|
||||
[JsonPropertyName("rekor.checkpoint")]
|
||||
RekorCheckpoint,
|
||||
|
||||
/// <summary>Function map predicate (runtime→static linkage).</summary>
|
||||
[JsonPropertyName("function-map")]
|
||||
FunctionMap,
|
||||
|
||||
/// <summary>DSSE-signed function map statement.</summary>
|
||||
[JsonPropertyName("function-map.dsse")]
|
||||
FunctionMapDsse,
|
||||
|
||||
/// <summary>Runtime observations data (NDJSON).</summary>
|
||||
[JsonPropertyName("observations")]
|
||||
Observations,
|
||||
|
||||
/// <summary>Verification report (function map verification result).</summary>
|
||||
[JsonPropertyName("verification-report")]
|
||||
VerificationReport,
|
||||
|
||||
/// <summary>Other/generic artifact.</summary>
|
||||
[JsonPropertyName("other")]
|
||||
Other
|
||||
|
||||
@@ -25,6 +25,12 @@ public sealed record BundleManifest
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export mode indicator: "light" or "full".
|
||||
/// Sprint: SPRINT_20260122_040 (040-04)
|
||||
/// </summary>
|
||||
public string? ExportMode { get; init; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// v2.0.0 Additions - Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -70,6 +70,11 @@ public sealed class BundleValidationOptions
|
||||
/// Whether to validate crypto provider entries if present.
|
||||
/// </summary>
|
||||
public bool ValidateCryptoProviders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate artifact digests (function maps, observations, verification reports).
|
||||
/// </summary>
|
||||
public bool ValidateArtifacts { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -207,6 +207,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
timestampSizeBytes +
|
||||
artifactsSizeBytes;
|
||||
|
||||
var exportMode = request.ExportOptions?.Mode ?? BundleExportMode.Light;
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = _guidProvider.NewGuid().ToString(),
|
||||
@@ -221,6 +222,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||
Timestamps = timestamps.ToImmutableArray(),
|
||||
Artifacts = artifacts.ToImmutableArray(),
|
||||
ExportMode = exportMode.ToString().ToLowerInvariant(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
@@ -564,7 +566,8 @@ public sealed record BundleBuildRequest(
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null,
|
||||
IReadOnlyList<BundleArtifactBuildConfig>? Artifacts = null,
|
||||
bool StrictInlineArtifacts = false,
|
||||
ICollection<string>? WarningSink = null);
|
||||
ICollection<string>? WarningSink = null,
|
||||
BundleBuilderOptions? ExportOptions = null);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
|
||||
@@ -104,6 +104,40 @@ public sealed class BundleValidator : IBundleValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Validate artifact digests (function maps, observations, verification reports)
|
||||
if (_options.ValidateArtifacts && manifest.Artifacts.Length > 0)
|
||||
{
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifact.Path))
|
||||
{
|
||||
continue; // Inline artifact without path
|
||||
}
|
||||
|
||||
if (!PathValidation.IsSafeRelativePath(artifact.Path))
|
||||
{
|
||||
errors.Add(new BundleValidationError("Artifacts",
|
||||
$"Artifact '{artifact.Type}' has unsafe relative path: {artifact.Path}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact.Digest))
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("Artifacts",
|
||||
$"Artifact '{artifact.Type}' at '{artifact.Path}' has no digest"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = PathValidation.SafeCombine(bundlePath, artifact.Path);
|
||||
var result = await VerifyFileDigestAsync(filePath, NormalizeDigest(artifact.Digest), ct).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new BundleValidationError("Artifacts",
|
||||
$"Artifact '{artifact.Type}' at '{artifact.Path}' digest mismatch: expected {artifact.Digest}, got {result.ActualDigest}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle expiration
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < now)
|
||||
{
|
||||
@@ -159,6 +193,14 @@ public sealed class BundleValidator : IBundleValidator
|
||||
return (string.Equals(actualDigest, expectedDigest, StringComparison.OrdinalIgnoreCase), actualDigest);
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
// Strip "sha256:" prefix if present for comparison with raw hex
|
||||
return digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest[7..]
|
||||
: digest;
|
||||
}
|
||||
|
||||
private static string ComputeBundleDigest(BundleManifest manifest)
|
||||
{
|
||||
var withoutDigest = manifest with { BundleDigest = null };
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleExportModeTests.cs
|
||||
// Sprint: SPRINT_20260122_040_Platform_oci_delta_attestation_pipeline (040-04)
|
||||
// Description: Unit tests for two-tier bundle export mode (light/full)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
public sealed class BundleExportModeTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public BundleExportModeTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"bundle-mode-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleExportMode_Enum_HasLightAndFull()
|
||||
{
|
||||
var values = Enum.GetValues<BundleExportMode>();
|
||||
values.Should().Contain(BundleExportMode.Light);
|
||||
values.Should().Contain(BundleExportMode.Full);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleBuilderOptions_DefaultMode_IsLight()
|
||||
{
|
||||
var options = new BundleBuilderOptions();
|
||||
options.Mode.Should().Be(BundleExportMode.Light);
|
||||
options.MaxBlobSizeBytes.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleBuilderOptions_FullMode_CanSetMaxBlobSize()
|
||||
{
|
||||
var options = new BundleBuilderOptions
|
||||
{
|
||||
Mode = BundleExportMode.Full,
|
||||
MaxBlobSizeBytes = 100 * 1024 * 1024 // 100MB
|
||||
};
|
||||
options.Mode.Should().Be(BundleExportMode.Full);
|
||||
options.MaxBlobSizeBytes.Should().Be(100 * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleBuildRequest_ExportOptions_DefaultsToNull()
|
||||
{
|
||||
var request = new BundleBuildRequest(
|
||||
Name: "test",
|
||||
Version: "1.0.0",
|
||||
ExpiresAt: null,
|
||||
Feeds: Array.Empty<FeedBuildConfig>(),
|
||||
Policies: Array.Empty<PolicyBuildConfig>(),
|
||||
CryptoMaterials: Array.Empty<CryptoBuildConfig>(),
|
||||
RuleBundles: Array.Empty<RuleBundleBuildConfig>());
|
||||
|
||||
request.ExportOptions.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleBuildRequest_WithExportOptions_AcceptsFullMode()
|
||||
{
|
||||
var request = new BundleBuildRequest(
|
||||
Name: "test-full",
|
||||
Version: "2.0.0",
|
||||
ExpiresAt: null,
|
||||
Feeds: Array.Empty<FeedBuildConfig>(),
|
||||
Policies: Array.Empty<PolicyBuildConfig>(),
|
||||
CryptoMaterials: Array.Empty<CryptoBuildConfig>(),
|
||||
RuleBundles: Array.Empty<RuleBundleBuildConfig>(),
|
||||
ExportOptions: new BundleBuilderOptions { Mode = BundleExportMode.Full });
|
||||
|
||||
request.ExportOptions.Should().NotBeNull();
|
||||
request.ExportOptions!.Mode.Should().Be(BundleExportMode.Full);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Builder_LightMode_SetsExportModeInManifest()
|
||||
{
|
||||
// Arrange
|
||||
var outputPath = Path.Combine(_testDir, "light-bundle");
|
||||
var builder = new BundleBuilder();
|
||||
var request = new BundleBuildRequest(
|
||||
Name: "light-test",
|
||||
Version: "1.0.0",
|
||||
ExpiresAt: null,
|
||||
Feeds: Array.Empty<FeedBuildConfig>(),
|
||||
Policies: Array.Empty<PolicyBuildConfig>(),
|
||||
CryptoMaterials: Array.Empty<CryptoBuildConfig>(),
|
||||
RuleBundles: Array.Empty<RuleBundleBuildConfig>(),
|
||||
ExportOptions: new BundleBuilderOptions { Mode = BundleExportMode.Light });
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.ExportMode.Should().Be("light");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Builder_FullMode_SetsExportModeInManifest()
|
||||
{
|
||||
// Arrange
|
||||
var outputPath = Path.Combine(_testDir, "full-bundle");
|
||||
var builder = new BundleBuilder();
|
||||
var request = new BundleBuildRequest(
|
||||
Name: "full-test",
|
||||
Version: "1.0.0",
|
||||
ExpiresAt: null,
|
||||
Feeds: Array.Empty<FeedBuildConfig>(),
|
||||
Policies: Array.Empty<PolicyBuildConfig>(),
|
||||
CryptoMaterials: Array.Empty<CryptoBuildConfig>(),
|
||||
RuleBundles: Array.Empty<RuleBundleBuildConfig>(),
|
||||
ExportOptions: new BundleBuilderOptions { Mode = BundleExportMode.Full });
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.ExportMode.Should().Be("full");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Builder_NoExportOptions_DefaultsToLight()
|
||||
{
|
||||
// Arrange
|
||||
var outputPath = Path.Combine(_testDir, "default-bundle");
|
||||
var builder = new BundleBuilder();
|
||||
var request = new BundleBuildRequest(
|
||||
Name: "default-test",
|
||||
Version: "1.0.0",
|
||||
ExpiresAt: null,
|
||||
Feeds: Array.Empty<FeedBuildConfig>(),
|
||||
Policies: Array.Empty<PolicyBuildConfig>(),
|
||||
CryptoMaterials: Array.Empty<CryptoBuildConfig>(),
|
||||
RuleBundles: Array.Empty<RuleBundleBuildConfig>());
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.ExportMode.Should().Be("light");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleManifest_ExportMode_IsNullable()
|
||||
{
|
||||
// Backwards compat: old manifests won't have exportMode
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = "test",
|
||||
Name = "test",
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Feeds = System.Collections.Immutable.ImmutableArray<FeedComponent>.Empty,
|
||||
Policies = System.Collections.Immutable.ImmutableArray<PolicyComponent>.Empty,
|
||||
CryptoMaterials = System.Collections.Immutable.ImmutableArray<CryptoComponent>.Empty
|
||||
};
|
||||
|
||||
manifest.ExportMode.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed class BundleTimestampOfflineVerificationTests : IAsyncLifetime
|
||||
var leafWithKey = leafCert.CopyWithPrivateKey(leafKey);
|
||||
|
||||
var content = new ContentInfo(Encoding.UTF8.GetBytes("timestamp-test"));
|
||||
var signedCms = new SignedCms(content, detached: true);
|
||||
var signedCms = new SignedCms(content, detached: false);
|
||||
var signer = new CmsSigner(leafWithKey)
|
||||
{
|
||||
IncludeOption = X509IncludeOption.WholeChain
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-011 - Bundle Integration: function_map Artifact Type
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.FunctionMap;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Sprint", "039")]
|
||||
public sealed class FunctionMapBundleIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public FunctionMapBundleIntegrationTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"stella-fmbi-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#region Artifact Type Constants Tests
|
||||
|
||||
[Fact(DisplayName = "ArtifactTypes constants have correct values")]
|
||||
public void ArtifactTypes_CorrectValues()
|
||||
{
|
||||
FunctionMapBundleIntegration.ArtifactTypes.FunctionMap.Should().Be("function-map");
|
||||
FunctionMapBundleIntegration.ArtifactTypes.FunctionMapDsse.Should().Be("function-map.dsse");
|
||||
FunctionMapBundleIntegration.ArtifactTypes.Observations.Should().Be("observations");
|
||||
FunctionMapBundleIntegration.ArtifactTypes.VerificationReport.Should().Be("verification-report");
|
||||
FunctionMapBundleIntegration.ArtifactTypes.VerificationReportDsse.Should().Be("verification-report.dsse");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MediaTypes constants have correct values")]
|
||||
public void MediaTypes_CorrectValues()
|
||||
{
|
||||
FunctionMapBundleIntegration.MediaTypes.FunctionMap.Should().Be("application/vnd.stella.function-map+json");
|
||||
FunctionMapBundleIntegration.MediaTypes.FunctionMapDsse.Should().Be("application/vnd.dsse+json");
|
||||
FunctionMapBundleIntegration.MediaTypes.Observations.Should().Be("application/x-ndjson");
|
||||
FunctionMapBundleIntegration.MediaTypes.VerificationReport.Should().Be("application/vnd.stella.verification-report+json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundlePaths constants have correct values")]
|
||||
public void BundlePaths_CorrectValues()
|
||||
{
|
||||
FunctionMapBundleIntegration.BundlePaths.FunctionMapsDir.Should().Be("function-maps");
|
||||
FunctionMapBundleIntegration.BundlePaths.ObservationsDir.Should().Be("observations");
|
||||
FunctionMapBundleIntegration.BundlePaths.VerificationDir.Should().Be("verification");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Factory Method Tests
|
||||
|
||||
[Fact(DisplayName = "CreateFunctionMapConfig produces correct config")]
|
||||
public void CreateFunctionMapConfig_ProducesCorrectConfig()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "fm.json");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateFunctionMapConfig(sourcePath, "myservice");
|
||||
|
||||
config.Type.Should().Be("function-map");
|
||||
config.ContentType.Should().Be("application/vnd.stella.function-map+json");
|
||||
config.SourcePath.Should().Be(sourcePath);
|
||||
config.RelativePath.Should().Be("function-maps/myservice-function-map.json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateFunctionMapDsseConfig produces correct config")]
|
||||
public void CreateFunctionMapDsseConfig_ProducesCorrectConfig()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "fm.dsse.json");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateFunctionMapDsseConfig(sourcePath, "myservice");
|
||||
|
||||
config.Type.Should().Be("function-map.dsse");
|
||||
config.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
config.SourcePath.Should().Be(sourcePath);
|
||||
config.RelativePath.Should().Be("function-maps/myservice-function-map.dsse.json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateObservationsConfig produces correct config")]
|
||||
public void CreateObservationsConfig_ProducesCorrectConfig()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "obs.ndjson");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateObservationsConfig(sourcePath, "2026-01-22");
|
||||
|
||||
config.Type.Should().Be("observations");
|
||||
config.ContentType.Should().Be("application/x-ndjson");
|
||||
config.SourcePath.Should().Be(sourcePath);
|
||||
config.RelativePath.Should().Be("observations/observations-2026-01-22.ndjson");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateVerificationReportConfig produces correct config")]
|
||||
public void CreateVerificationReportConfig_ProducesCorrectConfig()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "report.json");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateVerificationReportConfig(sourcePath);
|
||||
|
||||
config.Type.Should().Be("verification-report");
|
||||
config.ContentType.Should().Be("application/vnd.stella.verification-report+json");
|
||||
config.SourcePath.Should().Be(sourcePath);
|
||||
config.RelativePath.Should().Be("verification/verification-report.json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateVerificationReportDsseConfig produces correct config")]
|
||||
public void CreateVerificationReportDsseConfig_ProducesCorrectConfig()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "report.dsse.json");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateVerificationReportDsseConfig(sourcePath);
|
||||
|
||||
config.Type.Should().Be("verification-report.dsse");
|
||||
config.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
config.SourcePath.Should().Be(sourcePath);
|
||||
config.RelativePath.Should().Be("verification/verification-report.dsse.json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateFunctionMapFromContent produces correct config")]
|
||||
public void CreateFunctionMapFromContent_ProducesCorrectConfig()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("{\"schema\":\"v1\"}");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateFunctionMapFromContent(content, "myservice");
|
||||
|
||||
config.Type.Should().Be("function-map");
|
||||
config.ContentType.Should().Be("application/vnd.stella.function-map+json");
|
||||
config.Content.Should().BeEquivalentTo(content);
|
||||
config.SourcePath.Should().BeNull();
|
||||
config.RelativePath.Should().Be("function-maps/myservice-function-map.json");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateObservationsFromContent produces correct config")]
|
||||
public void CreateObservationsFromContent_ProducesCorrectConfig()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("{\"obs\":1}\n{\"obs\":2}\n");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateObservationsFromContent(content, "2026-01-22");
|
||||
|
||||
config.Type.Should().Be("observations");
|
||||
config.ContentType.Should().Be("application/x-ndjson");
|
||||
config.Content.Should().BeEquivalentTo(content);
|
||||
config.RelativePath.Should().Be("observations/observations-2026-01-22.ndjson");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CreateFunctionMapConfig sanitizes service name")]
|
||||
public void CreateFunctionMapConfig_SanitizesServiceName()
|
||||
{
|
||||
var sourcePath = Path.Combine(_tempRoot, "fm.json");
|
||||
|
||||
var config = FunctionMapBundleIntegration.CreateFunctionMapConfig(sourcePath, "my/service:v1");
|
||||
|
||||
config.RelativePath.Should().Be("function-maps/my-service-v1-function-map.json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Predicate Tests
|
||||
|
||||
[Theory(DisplayName = "IsFunctionMapArtifact returns true for function-map types")]
|
||||
[InlineData("function-map")]
|
||||
[InlineData("function-map.dsse")]
|
||||
[InlineData("observations")]
|
||||
[InlineData("verification-report")]
|
||||
[InlineData("verification-report.dsse")]
|
||||
public void IsFunctionMapArtifact_TrueForKnownTypes(string type)
|
||||
{
|
||||
FunctionMapBundleIntegration.IsFunctionMapArtifact(type).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "IsFunctionMapArtifact returns false for non-function-map types")]
|
||||
[InlineData("sbom")]
|
||||
[InlineData("vex")]
|
||||
[InlineData("rekor.proof")]
|
||||
[InlineData("other")]
|
||||
[InlineData(null)]
|
||||
public void IsFunctionMapArtifact_FalseForOtherTypes(string? type)
|
||||
{
|
||||
FunctionMapBundleIntegration.IsFunctionMapArtifact(type).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "IsDsseArtifact returns true for DSSE types")]
|
||||
[InlineData("function-map.dsse")]
|
||||
[InlineData("verification-report.dsse")]
|
||||
public void IsDsseArtifact_TrueForDsseTypes(string type)
|
||||
{
|
||||
FunctionMapBundleIntegration.IsDsseArtifact(type).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "IsDsseArtifact returns false for non-DSSE types")]
|
||||
[InlineData("function-map")]
|
||||
[InlineData("observations")]
|
||||
[InlineData("verification-report")]
|
||||
[InlineData(null)]
|
||||
public void IsDsseArtifact_FalseForNonDsseTypes(string? type)
|
||||
{
|
||||
FunctionMapBundleIntegration.IsDsseArtifact(type).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BundleBuilder Integration Tests
|
||||
|
||||
[Fact(DisplayName = "BundleBuilder packages function-map artifact")]
|
||||
public async Task BundleBuilder_PackagesFunctionMapArtifact()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = Path.Combine(_tempRoot, "source");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var feedFile = Path.Combine(sourceDir, "feed.json");
|
||||
await File.WriteAllTextAsync(feedFile, "{}");
|
||||
|
||||
var fmFile = Path.Combine(sourceDir, "function-map.json");
|
||||
await File.WriteAllTextAsync(fmFile, "{\"_type\":\"https://stella.ops/predicates/function-map/v1\"}");
|
||||
|
||||
var fmConfig = FunctionMapBundleIntegration.CreateFunctionMapConfig(fmFile, "testservice");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[] { fmConfig });
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var builder = new BundleBuilder();
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Artifacts.Should().ContainSingle();
|
||||
var artifact = manifest.Artifacts[0];
|
||||
artifact.Type.Should().Be("function-map");
|
||||
artifact.Path.Should().Be("function-maps/testservice-function-map.json");
|
||||
artifact.Digest.Should().StartWith("sha256:");
|
||||
artifact.SizeBytes.Should().BeGreaterThan(0);
|
||||
|
||||
var bundledFile = Path.Combine(outputPath, "function-maps", "testservice-function-map.json");
|
||||
File.Exists(bundledFile).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundleBuilder packages observations artifact")]
|
||||
public async Task BundleBuilder_PackagesObservationsArtifact()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = Path.Combine(_tempRoot, "source");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var feedFile = Path.Combine(sourceDir, "feed.json");
|
||||
await File.WriteAllTextAsync(feedFile, "{}");
|
||||
|
||||
var obsFile = Path.Combine(sourceDir, "obs.ndjson");
|
||||
await File.WriteAllTextAsync(obsFile, "{\"symbol\":\"SSL_connect\"}\n{\"symbol\":\"SSL_read\"}\n");
|
||||
|
||||
var obsConfig = FunctionMapBundleIntegration.CreateObservationsConfig(obsFile, "2026-01-22");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[] { obsConfig });
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var builder = new BundleBuilder();
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Artifacts.Should().ContainSingle();
|
||||
var artifact = manifest.Artifacts[0];
|
||||
artifact.Type.Should().Be("observations");
|
||||
artifact.Path.Should().Be("observations/observations-2026-01-22.ndjson");
|
||||
artifact.ContentType.Should().Be("application/x-ndjson");
|
||||
|
||||
var bundledFile = Path.Combine(outputPath, "observations", "observations-2026-01-22.ndjson");
|
||||
File.Exists(bundledFile).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundleBuilder packages multiple function-map artifacts")]
|
||||
public async Task BundleBuilder_PackagesMultipleArtifacts()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = Path.Combine(_tempRoot, "source");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var feedFile = Path.Combine(sourceDir, "feed.json");
|
||||
await File.WriteAllTextAsync(feedFile, "{}");
|
||||
|
||||
var fmFile = Path.Combine(sourceDir, "function-map.json");
|
||||
await File.WriteAllTextAsync(fmFile, "{\"predicate\":{}}");
|
||||
|
||||
var obsFile = Path.Combine(sourceDir, "obs.ndjson");
|
||||
await File.WriteAllTextAsync(obsFile, "{\"symbol\":\"SSL_connect\"}\n");
|
||||
|
||||
var reportFile = Path.Combine(sourceDir, "report.json");
|
||||
await File.WriteAllTextAsync(reportFile, "{\"verified\":true}");
|
||||
|
||||
var artifacts = new[]
|
||||
{
|
||||
FunctionMapBundleIntegration.CreateFunctionMapConfig(fmFile, "myservice"),
|
||||
FunctionMapBundleIntegration.CreateObservationsConfig(obsFile, "2026-01-22"),
|
||||
FunctionMapBundleIntegration.CreateVerificationReportConfig(reportFile)
|
||||
};
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
Array.Empty<CryptoBuildConfig>(),
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: artifacts);
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var builder = new BundleBuilder();
|
||||
|
||||
// Act
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Assert
|
||||
manifest.Artifacts.Should().HaveCount(3);
|
||||
manifest.Artifacts.Select(a => a.Type).Should().Contain("function-map");
|
||||
manifest.Artifacts.Select(a => a.Type).Should().Contain("observations");
|
||||
manifest.Artifacts.Select(a => a.Type).Should().Contain("verification-report");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BundleValidator Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Validator passes when artifact digests match")]
|
||||
public async Task Validator_PassesWhenArtifactDigestsMatch()
|
||||
{
|
||||
// Arrange - build a bundle with function-map artifact
|
||||
var sourceDir = Path.Combine(_tempRoot, "source");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var feedFile = Path.Combine(sourceDir, "feed.json");
|
||||
await File.WriteAllTextAsync(feedFile, "{}");
|
||||
|
||||
var fmFile = Path.Combine(sourceDir, "function-map.json");
|
||||
var fmContent = "{\"_type\":\"function-map\"}";
|
||||
await File.WriteAllTextAsync(fmFile, fmContent);
|
||||
|
||||
var fmConfig = FunctionMapBundleIntegration.CreateFunctionMapConfig(fmFile, "testservice");
|
||||
var cryptoFile = Path.Combine(sourceDir, "root.pem");
|
||||
await File.WriteAllTextAsync(cryptoFile, "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
new[] { new CryptoBuildConfig("crypto-1", "root", cryptoFile, "crypto/root.pem", CryptoComponentType.TrustRoot, null) },
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[] { fmConfig });
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var builder = new BundleBuilder();
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
var validator = new BundleValidator();
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(manifest, outputPath);
|
||||
|
||||
// Assert
|
||||
result.Errors.Where(e => e.Component == "Artifacts").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Validator fails when artifact digest mismatches")]
|
||||
public async Task Validator_FailsWhenArtifactDigestMismatches()
|
||||
{
|
||||
// Arrange - build a bundle, then tamper with the artifact
|
||||
var sourceDir = Path.Combine(_tempRoot, "source");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var feedFile = Path.Combine(sourceDir, "feed.json");
|
||||
await File.WriteAllTextAsync(feedFile, "{}");
|
||||
|
||||
var fmFile = Path.Combine(sourceDir, "function-map.json");
|
||||
await File.WriteAllTextAsync(fmFile, "{\"_type\":\"function-map\"}");
|
||||
|
||||
var fmConfig = FunctionMapBundleIntegration.CreateFunctionMapConfig(fmFile, "testservice");
|
||||
var cryptoFile = Path.Combine(sourceDir, "root.pem");
|
||||
await File.WriteAllTextAsync(cryptoFile, "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----");
|
||||
|
||||
var request = new BundleBuildRequest(
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
null,
|
||||
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
|
||||
Array.Empty<PolicyBuildConfig>(),
|
||||
new[] { new CryptoBuildConfig("crypto-1", "root", cryptoFile, "crypto/root.pem", CryptoComponentType.TrustRoot, null) },
|
||||
Array.Empty<RuleBundleBuildConfig>(),
|
||||
Artifacts: new[] { fmConfig });
|
||||
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
var builder = new BundleBuilder();
|
||||
var manifest = await builder.BuildAsync(request, outputPath);
|
||||
|
||||
// Tamper with the function-map file
|
||||
var bundledFile = Path.Combine(outputPath, "function-maps", "testservice-function-map.json");
|
||||
await File.WriteAllTextAsync(bundledFile, "{\"tampered\":true}");
|
||||
|
||||
var validator = new BundleValidator();
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(manifest, outputPath);
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().Contain(e =>
|
||||
e.Component == "Artifacts" && e.Message.Contains("digest mismatch"));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Validator warns when artifact has no digest")]
|
||||
public async Task Validator_WarnsWhenArtifactHasNoDigest()
|
||||
{
|
||||
// Arrange - create a manifest with an artifact that has no digest
|
||||
var outputPath = Path.Combine(_tempRoot, "bundle");
|
||||
Directory.CreateDirectory(Path.Combine(outputPath, "function-maps"));
|
||||
|
||||
var fmPath = Path.Combine(outputPath, "function-maps", "test-function-map.json");
|
||||
await File.WriteAllTextAsync(fmPath, "{}");
|
||||
|
||||
var feedDir = Path.Combine(outputPath, "feeds");
|
||||
Directory.CreateDirectory(feedDir);
|
||||
var feedPath = Path.Combine(feedDir, "nvd.json");
|
||||
await File.WriteAllTextAsync(feedPath, "{}");
|
||||
|
||||
var cryptoDir = Path.Combine(outputPath, "crypto");
|
||||
Directory.CreateDirectory(cryptoDir);
|
||||
var cryptoPath = Path.Combine(cryptoDir, "root.pem");
|
||||
await File.WriteAllTextAsync(cryptoPath, "cert");
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = "test",
|
||||
Name = "test",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Feeds = ImmutableArray.Create(new FeedComponent(
|
||||
"feed-1", "nvd", "v1", "feeds/nvd.json",
|
||||
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes("{}")).Select(b => b.ToString("x2")).Aggregate((a, b) => a + b),
|
||||
2, DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)),
|
||||
Policies = ImmutableArray<PolicyComponent>.Empty,
|
||||
CryptoMaterials = ImmutableArray.Create(new CryptoComponent(
|
||||
"crypto-1", "root", "crypto/root.pem",
|
||||
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes("cert")).Select(b => b.ToString("x2")).Aggregate((a, b) => a + b),
|
||||
4, CryptoComponentType.TrustRoot, null)),
|
||||
Artifacts = ImmutableArray.Create(new BundleArtifact(
|
||||
"function-maps/test-function-map.json",
|
||||
"function-map",
|
||||
"application/vnd.stella.function-map+json",
|
||||
null, // No digest
|
||||
2))
|
||||
};
|
||||
|
||||
var validator = new BundleValidator();
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(manifest, outputPath);
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().Contain(w =>
|
||||
w.Component == "Artifacts" && w.Message.Contains("no digest"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BundleArtifactType Enum Tests
|
||||
|
||||
[Fact(DisplayName = "BundleArtifactType has FunctionMap value")]
|
||||
public void BundleArtifactType_HasFunctionMap()
|
||||
{
|
||||
BundleArtifactType.FunctionMap.Should().BeDefined();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundleArtifactType has FunctionMapDsse value")]
|
||||
public void BundleArtifactType_HasFunctionMapDsse()
|
||||
{
|
||||
BundleArtifactType.FunctionMapDsse.Should().BeDefined();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundleArtifactType has Observations value")]
|
||||
public void BundleArtifactType_HasObservations()
|
||||
{
|
||||
BundleArtifactType.Observations.Should().BeDefined();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "BundleArtifactType has VerificationReport value")]
|
||||
public void BundleArtifactType_HasVerificationReport()
|
||||
{
|
||||
BundleArtifactType.VerificationReport.Should().BeDefined();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -28,8 +28,8 @@ public sealed class EvidenceReconcilerVexTests
|
||||
var researcherEnvelope = BuildDsseEnvelope(researcherVex, digest);
|
||||
|
||||
var attestations = Path.Combine(input, "attestations");
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "vendor.dsse.json"), vendorEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "researcher.dsse.json"), researcherEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "vendor.intoto.json"), vendorEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "researcher.intoto.json"), researcherEnvelope);
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
var options = new ReconciliationOptions
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomNormalizerVolatileFieldsTests.cs
|
||||
// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication
|
||||
// Task: 041-01 - Expand volatile field stripping in SbomNormalizer
|
||||
// Description: Verifies volatile fields are stripped for deterministic canonical hashes
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
|
||||
|
||||
public sealed class SbomNormalizerVolatileFieldsTests
|
||||
{
|
||||
private readonly SbomNormalizer _normalizer = new(new NormalizationOptions
|
||||
{
|
||||
SortArrays = true,
|
||||
LowercaseUris = true,
|
||||
StripTimestamps = true,
|
||||
StripVolatileFields = true,
|
||||
NormalizeKeys = false
|
||||
});
|
||||
|
||||
private readonly SbomNormalizer _normalizerNoStrip = new(new NormalizationOptions
|
||||
{
|
||||
SortArrays = true,
|
||||
LowercaseUris = true,
|
||||
StripTimestamps = true,
|
||||
StripVolatileFields = false,
|
||||
NormalizeKeys = false
|
||||
});
|
||||
|
||||
#region CycloneDX volatile field stripping
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_SerialNumber_Stripped_Produces_Same_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{"type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{"type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_MetadataTools_Stripped_Produces_Same_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tools": [{"vendor": "anchore", "name": "syft", "version": "1.0.0"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "2.0.0"}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tools": [{"vendor": "anchore", "name": "syft", "version": "2.5.0"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "2.0.0"}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_MetadataTimestamp_Stripped_Produces_Same_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"component": {"type": "application", "name": "myapp", "version": "1.0.0"}
|
||||
},
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-23T12:34:56Z",
|
||||
"component": {"type": "application", "name": "myapp", "version": "1.0.0"}
|
||||
},
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_MetadataAuthors_Stripped_Produces_Same_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"authors": [{"name": "Alice"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "1.0.0"}
|
||||
},
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"authors": [{"name": "Bob"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "1.0.0"}
|
||||
},
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_ContentChange_Produces_Different_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{"type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{"type": "library", "name": "lodash", "version": "4.17.22", "purl": "pkg:npm/lodash@4.17.22"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.NotEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_StripVolatileFields_Disabled_Preserves_SerialNumber()
|
||||
{
|
||||
var sbom = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"version": 1,
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _normalizerNoStrip.Normalize(sbom, SbomFormat.CycloneDx);
|
||||
|
||||
Assert.Contains("serialNumber", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SPDX volatile field stripping
|
||||
|
||||
[Fact]
|
||||
public void Spdx_CreationInfoCreators_Stripped_Produces_Same_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "myapp",
|
||||
"creationInfo": {
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"creators": ["Tool: syft-1.0.0"],
|
||||
"licenseListVersion": "3.19"
|
||||
},
|
||||
"packages": [
|
||||
{"SPDXID": "SPDXRef-Package-lodash", "name": "lodash", "versionInfo": "4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "myapp",
|
||||
"creationInfo": {
|
||||
"created": "2026-01-23T12:00:00Z",
|
||||
"creators": ["Tool: syft-2.5.0", "Organization: ACME"],
|
||||
"licenseListVersion": "3.22"
|
||||
},
|
||||
"packages": [
|
||||
{"SPDXID": "SPDXRef-Package-lodash", "name": "lodash", "versionInfo": "4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.Spdx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.Spdx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx_ContentChange_Produces_Different_Hash()
|
||||
{
|
||||
var sbomA = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "myapp",
|
||||
"creationInfo": {
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"creators": ["Tool: syft-1.0.0"]
|
||||
},
|
||||
"packages": [
|
||||
{"SPDXID": "SPDXRef-Package-lodash", "name": "lodash", "versionInfo": "4.17.21"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "myapp",
|
||||
"creationInfo": {
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"creators": ["Tool: syft-1.0.0"]
|
||||
},
|
||||
"packages": [
|
||||
{"SPDXID": "SPDXRef-Package-lodash", "name": "lodash", "versionInfo": "4.17.22"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.Spdx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.Spdx));
|
||||
|
||||
Assert.NotEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spdx_StripVolatileFields_Disabled_Preserves_Creators()
|
||||
{
|
||||
var sbom = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "myapp",
|
||||
"creationInfo": {
|
||||
"creators": ["Tool: syft-1.0.0"],
|
||||
"licenseListVersion": "3.19"
|
||||
},
|
||||
"packages": []
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _normalizerNoStrip.Normalize(sbom, SbomFormat.Spdx);
|
||||
|
||||
Assert.Contains("creators", result);
|
||||
Assert.Contains("licenseListVersion", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combined volatile field tests (determinism guard)
|
||||
|
||||
[Fact]
|
||||
public void CycloneDx_AllVolatileFields_Different_Same_Hash()
|
||||
{
|
||||
// Simulates two scans of the same image with completely different volatile metadata
|
||||
var sbomA = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:11111111-1111-1111-1111-111111111111",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"tools": [{"vendor": "anchore", "name": "syft", "version": "0.90.0"}],
|
||||
"authors": [{"name": "CI Bot 1"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "3.0.0"}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "react", "version": "18.2.0", "purl": "pkg:npm/react@18.2.0"},
|
||||
{"type": "library", "name": "typescript", "version": "5.3.0", "purl": "pkg:npm/typescript@5.3.0"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var sbomB = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:99999999-9999-9999-9999-999999999999",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-23T23:59:59Z",
|
||||
"tools": [{"vendor": "anchore", "name": "syft", "version": "1.5.0"}],
|
||||
"authors": [{"name": "CI Bot 2", "email": "bot@example.com"}],
|
||||
"component": {"type": "application", "name": "myapp", "version": "3.0.0"}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "typescript", "version": "5.3.0", "purl": "pkg:npm/typescript@5.3.0"},
|
||||
{"type": "library", "name": "react", "version": "18.2.0", "purl": "pkg:npm/react@18.2.0"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var hashA = ComputeHash(_normalizer.Normalize(sbomA, SbomFormat.CycloneDx));
|
||||
var hashB = ComputeHash(_normalizer.Normalize(sbomB, SbomFormat.CycloneDx));
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Twice_Identical_Bytes()
|
||||
{
|
||||
// Non-determinism guard: run canonicalizer twice, assert identical bytes
|
||||
var sbom = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": "urn:uuid:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-23T12:00:00Z",
|
||||
"tools": [{"vendor": "anchore", "name": "syft", "version": "1.0.0"}]
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "b-lib", "version": "2.0.0", "purl": "pkg:npm/b-lib@2.0.0"},
|
||||
{"type": "library", "name": "a-lib", "version": "1.0.0", "purl": "pkg:npm/a-lib@1.0.0"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var pass1 = _normalizer.Normalize(sbom, SbomFormat.CycloneDx);
|
||||
var pass2 = _normalizer.Normalize(sbom, SbomFormat.CycloneDx);
|
||||
|
||||
Assert.Equal(pass1, pass2);
|
||||
Assert.Equal(Encoding.UTF8.GetBytes(pass1), Encoding.UTF8.GetBytes(pass2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static string ComputeHash(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user