part #2
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class AdvisorySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts advisories from all configured feeds.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<AdvisoryContent>();
|
||||
var errors = new List<string>();
|
||||
var totalRecords = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var feeds = await _dataSource.GetAvailableFeedsAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort feeds for deterministic output.
|
||||
var sortedFeeds = feeds.OrderBy(f => f.FeedId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var feed in sortedFeeds)
|
||||
{
|
||||
if (request.FeedIds is { Count: > 0 } && !request.FeedIds.Contains(feed.FeedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var feedResult = await ExtractFeedAsync(feed.FeedId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (feedResult.Success && feedResult.Content is not null)
|
||||
{
|
||||
contents.Add(feedResult.Content);
|
||||
totalRecords += feedResult.RecordCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(feedResult.Error))
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {feedResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Advisories = contents,
|
||||
TotalRecordCount = totalRecords,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Advisories = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class AdvisorySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts advisories from a specific feed.
|
||||
/// </summary>
|
||||
public async Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(feedId);
|
||||
|
||||
try
|
||||
{
|
||||
var advisories = await _dataSource.GetAdvisoriesAsync(
|
||||
feedId,
|
||||
request.Since,
|
||||
request.MaxRecords,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (advisories.Count == 0)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Serialize advisories to NDJSON format for deterministic output.
|
||||
var contentBuilder = new StringBuilder();
|
||||
foreach (var advisory in advisories.OrderBy(a => a.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory, _jsonOptions);
|
||||
contentBuilder.AppendLine(json);
|
||||
}
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(contentBuilder.ToString());
|
||||
var fileName = $"{feedId}-{snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}.ndjson";
|
||||
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = advisories.Count,
|
||||
Content = new AdvisoryContent
|
||||
{
|
||||
FeedId = feedId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
RecordCount = advisories.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
@@ -16,9 +14,9 @@ namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
/// <summary>
|
||||
/// Extracts advisory data from Concelier database for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
public sealed partial class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -38,233 +36,4 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts advisories from all configured feeds.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<AdvisoryContent>();
|
||||
var errors = new List<string>();
|
||||
var totalRecords = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var feeds = await _dataSource.GetAvailableFeedsAsync(cancellationToken);
|
||||
|
||||
// Sort feeds for deterministic output
|
||||
var sortedFeeds = feeds.OrderBy(f => f.FeedId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var feed in sortedFeeds)
|
||||
{
|
||||
// Skip if specific feeds are requested and this isn't one of them
|
||||
if (request.FeedIds is { Count: > 0 } && !request.FeedIds.Contains(feed.FeedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var feedResult = await ExtractFeedAsync(feed.FeedId, request, cancellationToken);
|
||||
if (feedResult.Success && feedResult.Content is not null)
|
||||
{
|
||||
contents.Add(feedResult.Content);
|
||||
totalRecords += feedResult.RecordCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(feedResult.Error))
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {feedResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Advisories = contents,
|
||||
TotalRecordCount = totalRecords,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Advisories = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts advisories from a specific feed.
|
||||
/// </summary>
|
||||
public async Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(feedId);
|
||||
|
||||
try
|
||||
{
|
||||
var advisories = await _dataSource.GetAdvisoriesAsync(
|
||||
feedId,
|
||||
request.Since,
|
||||
request.MaxRecords,
|
||||
cancellationToken);
|
||||
|
||||
if (advisories.Count == 0)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Serialize advisories to NDJSON format for deterministic output
|
||||
var contentBuilder = new StringBuilder();
|
||||
foreach (var advisory in advisories.OrderBy(a => a.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory, JsonOptions);
|
||||
contentBuilder.AppendLine(json);
|
||||
}
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(contentBuilder.ToString());
|
||||
// Use invariant culture for deterministic filename formatting
|
||||
var fileName = $"{feedId}-{snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}.ndjson";
|
||||
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = advisories.Count,
|
||||
Content = new AdvisoryContent
|
||||
{
|
||||
FeedId = feedId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
RecordCount = advisories.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IAdvisorySnapshotExtractor
|
||||
{
|
||||
Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory data access.
|
||||
/// This should be implemented by Concelier to provide advisory data.
|
||||
/// </summary>
|
||||
public interface IAdvisoryDataSource
|
||||
{
|
||||
Task<IReadOnlyList<FeedInfo>> GetAvailableFeedsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdvisoryRecord>> GetAdvisoriesAsync(
|
||||
string feedId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxRecords = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available feed.
|
||||
/// </summary>
|
||||
public sealed record FeedInfo(string FeedId, string Name, string? Ecosystem);
|
||||
|
||||
/// <summary>
|
||||
/// A single advisory record.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FeedId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
public IReadOnlyList<string>? AffectedPackages { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? RawData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting advisories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific feed IDs to extract. Empty means all feeds.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? FeedIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract advisories modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records per feed.
|
||||
/// </summary>
|
||||
public int? MaxRecords { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting advisories from all feeds.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<AdvisoryContent> Advisories { get; init; } = [];
|
||||
public int TotalRecordCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single feed.
|
||||
/// </summary>
|
||||
public sealed record FeedExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
public AdvisoryContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available feed.
|
||||
/// </summary>
|
||||
public sealed record FeedInfo(string FeedId, string Name, string? Ecosystem);
|
||||
|
||||
/// <summary>
|
||||
/// A single advisory record.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FeedId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
public IReadOnlyList<string>? AffectedPackages { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? RawData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting advisories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific feed IDs to extract. Empty means all feeds.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? FeedIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract advisories modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records per feed.
|
||||
/// </summary>
|
||||
public int? MaxRecords { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting advisories from all feeds.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<AdvisoryContent> Advisories { get; init; } = [];
|
||||
public int TotalRecordCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single feed.
|
||||
/// </summary>
|
||||
public sealed record FeedExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
public AdvisoryContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory data access.
|
||||
/// This should be implemented by Concelier to provide advisory data.
|
||||
/// </summary>
|
||||
public interface IAdvisoryDataSource
|
||||
{
|
||||
Task<IReadOnlyList<FeedInfo>> GetAvailableFeedsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdvisoryRecord>> GetAdvisoriesAsync(
|
||||
string feedId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxRecords = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IAdvisorySnapshotExtractor
|
||||
{
|
||||
Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy data access.
|
||||
/// This should be implemented by the Policy module to provide policy data.
|
||||
/// </summary>
|
||||
public interface IPolicyDataSource
|
||||
{
|
||||
Task<IReadOnlyList<PolicyInfo>> GetAvailablePoliciesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyInfo?> GetPolicyInfoAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetPolicyContentAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IPolicySnapshotExtractor
|
||||
{
|
||||
Task<PolicyExtractionResult> ExtractAllAsync(
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX data access.
|
||||
/// This should be implemented by Excititor to provide VEX data.
|
||||
/// </summary>
|
||||
public interface IVexDataSource
|
||||
{
|
||||
Task<IReadOnlyList<VexSourceInfo>> GetAvailableSourcesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<VexStatement>> GetStatementsAsync(
|
||||
string sourceId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxStatements = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IVexSnapshotExtractor
|
||||
{
|
||||
Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private sealed record OpaBundleManifest
|
||||
{
|
||||
public required string Revision { get; init; }
|
||||
public required string[] Roots { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static async Task<byte[]> PackageRegoBundleAsync(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false); // Operations below are synchronous
|
||||
|
||||
using var outputStream = new MemoryStream();
|
||||
using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
|
||||
|
||||
// Write a simple tar with the rego file
|
||||
// Note: This is a minimal implementation; a full implementation would use System.Formats.Tar
|
||||
var header = CreateTarHeader($"{policyInfo.PolicyId}/policy.rego", policyContent.Length);
|
||||
gzipStream.Write(header);
|
||||
gzipStream.Write(policyContent);
|
||||
|
||||
// Pad to 512-byte boundary
|
||||
var padding = 512 - (policyContent.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Add manifest.json
|
||||
var manifest = new OpaBundleManifest
|
||||
{
|
||||
Revision = policyInfo.Version,
|
||||
Roots = [policyInfo.PolicyId]
|
||||
};
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, _jsonOptions);
|
||||
|
||||
var manifestHeader = CreateTarHeader(".manifest", manifestBytes.Length);
|
||||
gzipStream.Write(manifestHeader);
|
||||
gzipStream.Write(manifestBytes);
|
||||
|
||||
padding = 512 - (manifestBytes.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Write tar end-of-archive marker (two 512-byte zero blocks)
|
||||
gzipStream.Write(new byte[1024]);
|
||||
|
||||
gzipStream.Close();
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private static async Task<PolicyContent> BuildPolicyContentAsync(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] contentBytes;
|
||||
string fileName;
|
||||
|
||||
switch (policyInfo.Type)
|
||||
{
|
||||
case "OpaRego":
|
||||
contentBytes = await PackageRegoBundleAsync(policyInfo, policyContent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.tar.gz";
|
||||
break;
|
||||
|
||||
case "LatticeRules":
|
||||
case "UnknownBudgets":
|
||||
case "ScoringWeights":
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
default:
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.bin";
|
||||
break;
|
||||
}
|
||||
|
||||
return new PolicyContent
|
||||
{
|
||||
PolicyId = policyInfo.PolicyId,
|
||||
Name = policyInfo.Name,
|
||||
Version = policyInfo.Version,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
Type = policyInfo.Type
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts a specific policy.
|
||||
/// </summary>
|
||||
public async Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
try
|
||||
{
|
||||
var policyInfo = await _dataSource.GetPolicyInfoAsync(policyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyInfo is null)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy not found"
|
||||
};
|
||||
}
|
||||
|
||||
var policyContent = await _dataSource.GetPolicyContentAsync(policyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyContent is null || policyContent.Length == 0)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy content is empty"
|
||||
};
|
||||
}
|
||||
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
Content = await BuildPolicyContentAsync(policyInfo, policyContent, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private const long DeterministicMtime = 1704067200;
|
||||
|
||||
private static byte[] CreateTarHeader(string fileName, long fileSize)
|
||||
{
|
||||
var header = new byte[512];
|
||||
var nameBytes = Encoding.ASCII.GetBytes(fileName);
|
||||
Array.Copy(nameBytes, header, Math.Min(nameBytes.Length, 100));
|
||||
|
||||
// Mode (100-107) - 0644
|
||||
Encoding.ASCII.GetBytes("0000644").CopyTo(header, 100);
|
||||
|
||||
// Owner/group UID/GID (108-123) - zeros
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 108);
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 116);
|
||||
|
||||
// File size in octal (124-135)
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(fileSize, 8).PadLeft(11, '0')).CopyTo(header, 124);
|
||||
|
||||
// Modification time (136-147) - use deterministic mtime for reproducible output
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(DeterministicMtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
|
||||
// Checksum placeholder (148-155) - spaces
|
||||
for (var i = 148; i < 156; i++)
|
||||
{
|
||||
header[i] = 0x20;
|
||||
}
|
||||
|
||||
// Type flag (156) - regular file
|
||||
header[156] = (byte)'0';
|
||||
|
||||
// USTAR magic (257-264)
|
||||
Encoding.ASCII.GetBytes("ustar\0").CopyTo(header, 257);
|
||||
Encoding.ASCII.GetBytes("00").CopyTo(header, 263);
|
||||
|
||||
// Calculate and set checksum
|
||||
var checksum = 0;
|
||||
foreach (var b in header)
|
||||
{
|
||||
checksum += b;
|
||||
}
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ").CopyTo(header, 148);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,15 @@
|
||||
// Task: SEAL-008 - Implement policy bundle extractor
|
||||
// Description: Extracts policy bundle data for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts policy bundles from the Policy registry for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
public sealed partial class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private const long DeterministicMtime = 1704067200;
|
||||
|
||||
private readonly IPolicyDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -57,7 +41,8 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
|
||||
try
|
||||
{
|
||||
var policies = await _dataSource.GetAvailablePoliciesAsync(cancellationToken);
|
||||
var policies = await _dataSource.GetAvailablePoliciesAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort policies for deterministic output
|
||||
var sortedPolicies = policies.OrderBy(p => p.PolicyId, StringComparer.Ordinal).ToList();
|
||||
@@ -72,7 +57,8 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
|
||||
try
|
||||
{
|
||||
var policyResult = await ExtractPolicyAsync(policy.PolicyId, request, cancellationToken);
|
||||
var policyResult = await ExtractPolicyAsync(policy.PolicyId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyResult.Success && policyResult.Content is not null)
|
||||
{
|
||||
contents.Add(policyResult.Content);
|
||||
@@ -105,271 +91,4 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a specific policy.
|
||||
/// </summary>
|
||||
public async Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
try
|
||||
{
|
||||
var policyInfo = await _dataSource.GetPolicyInfoAsync(policyId, cancellationToken);
|
||||
if (policyInfo is null)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy not found"
|
||||
};
|
||||
}
|
||||
|
||||
var policyContent = await _dataSource.GetPolicyContentAsync(policyId, cancellationToken);
|
||||
if (policyContent is null || policyContent.Length == 0)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy content is empty"
|
||||
};
|
||||
}
|
||||
|
||||
// Package policy based on type
|
||||
byte[] contentBytes;
|
||||
string fileName;
|
||||
|
||||
switch (policyInfo.Type)
|
||||
{
|
||||
case "OpaRego":
|
||||
// Package Rego files as a tar.gz bundle
|
||||
contentBytes = await PackageRegoBundle(policyInfo, policyContent, cancellationToken);
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.tar.gz";
|
||||
break;
|
||||
|
||||
case "LatticeRules":
|
||||
// LatticeRules are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
case "UnknownBudgets":
|
||||
// Unknown budgets are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
case "ScoringWeights":
|
||||
// Scoring weights are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown types are passed through as-is
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.bin";
|
||||
break;
|
||||
}
|
||||
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
Content = new PolicyContent
|
||||
{
|
||||
PolicyId = policyInfo.PolicyId,
|
||||
Name = policyInfo.Name,
|
||||
Version = policyInfo.Version,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
Type = policyInfo.Type
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> PackageRegoBundle(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask; // Operations below are synchronous
|
||||
|
||||
using var outputStream = new MemoryStream();
|
||||
using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
|
||||
|
||||
// Write a simple tar with the rego file
|
||||
// Note: This is a minimal implementation; a full implementation would use System.Formats.Tar
|
||||
var header = CreateTarHeader($"{policyInfo.PolicyId}/policy.rego", policyContent.Length);
|
||||
gzipStream.Write(header);
|
||||
gzipStream.Write(policyContent);
|
||||
|
||||
// Pad to 512-byte boundary
|
||||
var padding = 512 - (policyContent.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Add manifest.json
|
||||
var manifest = new OpaBundleManifest
|
||||
{
|
||||
Revision = policyInfo.Version,
|
||||
Roots = [policyInfo.PolicyId]
|
||||
};
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
|
||||
var manifestHeader = CreateTarHeader(".manifest", manifestBytes.Length);
|
||||
gzipStream.Write(manifestHeader);
|
||||
gzipStream.Write(manifestBytes);
|
||||
|
||||
padding = 512 - (manifestBytes.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Write tar end-of-archive marker (two 512-byte zero blocks)
|
||||
gzipStream.Write(new byte[1024]);
|
||||
|
||||
gzipStream.Close();
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateTarHeader(string fileName, long fileSize)
|
||||
{
|
||||
var header = new byte[512];
|
||||
var nameBytes = Encoding.ASCII.GetBytes(fileName);
|
||||
Array.Copy(nameBytes, header, Math.Min(nameBytes.Length, 100));
|
||||
|
||||
// Mode (100-107) - 0644
|
||||
Encoding.ASCII.GetBytes("0000644").CopyTo(header, 100);
|
||||
|
||||
// Owner/group UID/GID (108-123) - zeros
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 108);
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 116);
|
||||
|
||||
// File size in octal (124-135)
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(fileSize, 8).PadLeft(11, '0')).CopyTo(header, 124);
|
||||
|
||||
// Modification time (136-147) - use deterministic mtime for reproducible output
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(DeterministicMtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
|
||||
// Checksum placeholder (148-155) - spaces
|
||||
for (var i = 148; i < 156; i++)
|
||||
{
|
||||
header[i] = 0x20;
|
||||
}
|
||||
|
||||
// Type flag (156) - regular file
|
||||
header[156] = (byte)'0';
|
||||
|
||||
// USTAR magic (257-264)
|
||||
Encoding.ASCII.GetBytes("ustar\0").CopyTo(header, 257);
|
||||
Encoding.ASCII.GetBytes("00").CopyTo(header, 263);
|
||||
|
||||
// Calculate and set checksum
|
||||
var checksum = 0;
|
||||
foreach (var b in header)
|
||||
{
|
||||
checksum += b;
|
||||
}
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ").CopyTo(header, 148);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private sealed record OpaBundleManifest
|
||||
{
|
||||
public required string Revision { get; init; }
|
||||
public required string[] Roots { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IPolicySnapshotExtractor
|
||||
{
|
||||
Task<PolicyExtractionResult> ExtractAllAsync(
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy data access.
|
||||
/// This should be implemented by the Policy module to provide policy data.
|
||||
/// </summary>
|
||||
public interface IPolicyDataSource
|
||||
{
|
||||
Task<IReadOnlyList<PolicyInfo>> GetAvailablePoliciesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyInfo?> GetPolicyInfoAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetPolicyContentAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about a policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific policy types to extract. Empty means all types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Types { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<PolicyContent> Policies { get; init; } = [];
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single policy.
|
||||
/// </summary>
|
||||
public sealed record PolicySingleExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public PolicyContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific policy types to extract. Empty means all types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Types { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<PolicyContent> Policies { get; init; } = [];
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single policy.
|
||||
/// </summary>
|
||||
public sealed record PolicySingleExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public PolicyContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class VexSnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from all configured sources.
|
||||
/// </summary>
|
||||
public async Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<VexContent>();
|
||||
var errors = new List<string>();
|
||||
var totalStatements = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var sources = await _dataSource.GetAvailableSourcesAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort sources for deterministic output.
|
||||
var sortedSources = sources.OrderBy(s => s.SourceId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var source in sortedSources)
|
||||
{
|
||||
if (request.SourceIds is { Count: > 0 } && !request.SourceIds.Contains(source.SourceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceResult = await ExtractSourceAsync(source.SourceId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (sourceResult.Success && sourceResult.Content is not null)
|
||||
{
|
||||
contents.Add(sourceResult.Content);
|
||||
totalStatements += sourceResult.StatementCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(sourceResult.Error))
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {sourceResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
VexStatements = contents,
|
||||
TotalStatementCount = totalStatements,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
VexStatements = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class VexSnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from a specific source.
|
||||
/// </summary>
|
||||
public async Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
try
|
||||
{
|
||||
var statements = await _dataSource.GetStatementsAsync(
|
||||
sourceId,
|
||||
request.Since,
|
||||
request.MaxStatements,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var timestampStr = snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
// Serialize statements to OpenVEX format.
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{timestampStr}",
|
||||
Author = sourceId,
|
||||
Timestamp = snapshotAt,
|
||||
Version = 1,
|
||||
Statements = statements.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
|
||||
var contentBytes = JsonSerializer.SerializeToUtf8Bytes(document, _jsonOptions);
|
||||
var fileName = $"{sourceId}-{timestampStr}.json";
|
||||
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = statements.Count,
|
||||
Content = new VexContent
|
||||
{
|
||||
SourceId = sourceId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
StatementCount = statements.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,6 @@
|
||||
// Task: SEAL-007 - Implement VEX snapshot extractor
|
||||
// Description: Extracts VEX statement data from Excititor for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
@@ -17,9 +12,9 @@ namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
/// Extracts VEX (Vulnerability Exploitability eXchange) statements from Excititor
|
||||
/// database for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
public sealed partial class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -39,258 +34,4 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from all configured sources.
|
||||
/// </summary>
|
||||
public async Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<VexContent>();
|
||||
var errors = new List<string>();
|
||||
var totalStatements = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var sources = await _dataSource.GetAvailableSourcesAsync(cancellationToken);
|
||||
|
||||
// Sort sources for deterministic output
|
||||
var sortedSources = sources.OrderBy(s => s.SourceId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var source in sortedSources)
|
||||
{
|
||||
// Skip if specific sources are requested and this isn't one of them
|
||||
if (request.SourceIds is { Count: > 0 } && !request.SourceIds.Contains(source.SourceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceResult = await ExtractSourceAsync(source.SourceId, request, cancellationToken);
|
||||
if (sourceResult.Success && sourceResult.Content is not null)
|
||||
{
|
||||
contents.Add(sourceResult.Content);
|
||||
totalStatements += sourceResult.StatementCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(sourceResult.Error))
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {sourceResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
VexStatements = contents,
|
||||
TotalStatementCount = totalStatements,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
VexStatements = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from a specific source.
|
||||
/// </summary>
|
||||
public async Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
try
|
||||
{
|
||||
var statements = await _dataSource.GetStatementsAsync(
|
||||
sourceId,
|
||||
request.Since,
|
||||
request.MaxStatements,
|
||||
cancellationToken);
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var timestampStr = snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
// Serialize statements to OpenVEX format
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{timestampStr}",
|
||||
Author = sourceId,
|
||||
Timestamp = snapshotAt,
|
||||
Version = 1,
|
||||
Statements = statements.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
|
||||
var contentBytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions);
|
||||
var fileName = $"{sourceId}-{timestampStr}.json";
|
||||
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = statements.Count,
|
||||
Content = new VexContent
|
||||
{
|
||||
SourceId = sourceId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
StatementCount = statements.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IVexSnapshotExtractor
|
||||
{
|
||||
Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX data access.
|
||||
/// This should be implemented by Excititor to provide VEX data.
|
||||
/// </summary>
|
||||
public interface IVexDataSource
|
||||
{
|
||||
Task<IReadOnlyList<VexSourceInfo>> GetAvailableSourcesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<VexStatement>> GetStatementsAsync(
|
||||
string sourceId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxStatements = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceInfo(string SourceId, string Name, string? Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement following OpenVEX format.
|
||||
/// </summary>
|
||||
public sealed record VexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ImpactStatement { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<VexProduct>? Products { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A product reference in a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexProduct
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX document format.
|
||||
/// </summary>
|
||||
public sealed record OpenVexDocument
|
||||
{
|
||||
public required string Context { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required string Author { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required IReadOnlyList<VexStatement> Statements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting VEX statements.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific source IDs to extract. Empty means all sources.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract statements modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements per source.
|
||||
/// </summary>
|
||||
public int? MaxStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting VEX statements from all sources.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<VexContent> VexStatements { get; init; } = [];
|
||||
public int TotalStatementCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public VexContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceInfo(string SourceId, string Name, string? Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement following OpenVEX format.
|
||||
/// </summary>
|
||||
public sealed record VexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ImpactStatement { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<VexProduct>? Products { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A product reference in a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexProduct
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX document format.
|
||||
/// </summary>
|
||||
public sealed record OpenVexDocument
|
||||
{
|
||||
public required string Context { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required string Author { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required IReadOnlyList<VexStatement> Statements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting VEX statements.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific source IDs to extract. Empty means all sources.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract statements modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements per source.
|
||||
/// </summary>
|
||||
public int? MaxStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting VEX statements from all sources.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<VexContent> VexStatements { get; init; } = [];
|
||||
public int TotalStatementCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public VexContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,6 @@
|
||||
// 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>
|
||||
@@ -13,7 +9,7 @@ namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
/// Provides standardized artifact type strings, media types, and factory methods
|
||||
/// for building function-map bundle configurations.
|
||||
/// </summary>
|
||||
public static class FunctionMapBundleIntegration
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact type strings for bundle manifest entries.
|
||||
@@ -69,149 +65,6 @@ public static class FunctionMapBundleIntegration
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for an advisory feed in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotEntry
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public DateTimeOffset SnapshotAt { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in a bundle (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact(
|
||||
/// <summary>Relative path within the bundle.</summary>
|
||||
string? Path,
|
||||
/// <summary>Artifact type: sbom, vex, dsse, rekor-proof, oci-referrers, etc.</summary>
|
||||
string Type,
|
||||
/// <summary>Content type (MIME).</summary>
|
||||
string? ContentType,
|
||||
/// <summary>SHA-256 digest of the artifact.</summary>
|
||||
string? Digest,
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long? SizeBytes);
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle artifact type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BundleArtifactType
|
||||
{
|
||||
/// <summary>SBOM document.</summary>
|
||||
[JsonPropertyName("sbom")]
|
||||
Sbom,
|
||||
|
||||
/// <summary>DSSE-signed SBOM statement.</summary>
|
||||
[JsonPropertyName("sbom.dsse")]
|
||||
SbomDsse,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
[JsonPropertyName("vex")]
|
||||
Vex,
|
||||
|
||||
/// <summary>DSSE-signed VEX statement.</summary>
|
||||
[JsonPropertyName("vex.dsse")]
|
||||
VexDsse,
|
||||
|
||||
/// <summary>Rekor inclusion proof.</summary>
|
||||
[JsonPropertyName("rekor.proof")]
|
||||
RekorProof,
|
||||
|
||||
/// <summary>OCI referrers index.</summary>
|
||||
[JsonPropertyName("oci.referrers")]
|
||||
OciReferrers,
|
||||
|
||||
/// <summary>Policy snapshot.</summary>
|
||||
[JsonPropertyName("policy")]
|
||||
Policy,
|
||||
|
||||
/// <summary>Feed snapshot.</summary>
|
||||
[JsonPropertyName("feed")]
|
||||
Feed,
|
||||
|
||||
/// <summary>Rekor checkpoint.</summary>
|
||||
[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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle artifact entry.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifactV2
|
||||
{
|
||||
/// <summary>Path within bundle.</summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Artifact type.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public BundleArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>Content digest (sha256).</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleFormatV2.cs
|
||||
// Sprint: SPRINT_20260118_018_AirGap_router_integration
|
||||
// Task: TASK-018-001 - Complete Air-Gap Bundle Format
|
||||
// Description: Air-gap bundle format v2.0.0 matching advisory specification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap bundle manifest v2.0.0 per advisory specification.
|
||||
/// </summary>
|
||||
public sealed record BundleManifestV2
|
||||
{
|
||||
/// <summary>Schema version.</summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "2.0.0";
|
||||
|
||||
/// <summary>Canonical manifest hash (sha256 over canonical JSON).</summary>
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; init; }
|
||||
|
||||
/// <summary>Subject digests for the bundle target.</summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>Timestamp entries for offline verification.</summary>
|
||||
[JsonPropertyName("timestamps")]
|
||||
public ImmutableArray<TimestampEntry> Timestamps { get; init; } = [];
|
||||
|
||||
/// <summary>Rekor proof entries for offline verification.</summary>
|
||||
[JsonPropertyName("rekorProofs")]
|
||||
public ImmutableArray<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
|
||||
/// <summary>Bundle information.</summary>
|
||||
[JsonPropertyName("bundle")]
|
||||
public required BundleInfoV2 Bundle { get; init; }
|
||||
|
||||
/// <summary>Verification configuration.</summary>
|
||||
[JsonPropertyName("verify")]
|
||||
public BundleVerifySectionV2? Verify { get; init; }
|
||||
|
||||
/// <summary>Bundle metadata.</summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public BundleMetadata? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle information.
|
||||
/// </summary>
|
||||
public sealed record BundleInfoV2
|
||||
{
|
||||
/// <summary>Primary image reference.</summary>
|
||||
[JsonPropertyName("image")]
|
||||
public required string Image { get; init; }
|
||||
|
||||
/// <summary>Image digest.</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Bundle artifacts.</summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public required ImmutableArray<BundleArtifactV2> Artifacts { get; init; }
|
||||
|
||||
/// <summary>OCI referrer manifest.</summary>
|
||||
[JsonPropertyName("referrers")]
|
||||
public OciReferrerIndex? Referrers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle artifact entry.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifactV2
|
||||
{
|
||||
/// <summary>Path within bundle.</summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Artifact type.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public BundleArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>Content digest (sha256).</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Media type.</summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle artifact type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BundleArtifactType
|
||||
{
|
||||
/// <summary>SBOM document.</summary>
|
||||
[JsonPropertyName("sbom")]
|
||||
Sbom,
|
||||
|
||||
/// <summary>DSSE-signed SBOM statement.</summary>
|
||||
[JsonPropertyName("sbom.dsse")]
|
||||
SbomDsse,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
[JsonPropertyName("vex")]
|
||||
Vex,
|
||||
|
||||
/// <summary>DSSE-signed VEX statement.</summary>
|
||||
[JsonPropertyName("vex.dsse")]
|
||||
VexDsse,
|
||||
|
||||
/// <summary>Rekor inclusion proof.</summary>
|
||||
[JsonPropertyName("rekor.proof")]
|
||||
RekorProof,
|
||||
|
||||
/// <summary>OCI referrers index.</summary>
|
||||
[JsonPropertyName("oci.referrers")]
|
||||
OciReferrers,
|
||||
|
||||
/// <summary>Policy snapshot.</summary>
|
||||
[JsonPropertyName("policy")]
|
||||
Policy,
|
||||
|
||||
/// <summary>Feed snapshot.</summary>
|
||||
[JsonPropertyName("feed")]
|
||||
Feed,
|
||||
|
||||
/// <summary>Rekor checkpoint.</summary>
|
||||
[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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification section.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifySectionV2
|
||||
{
|
||||
/// <summary>Trusted signing keys.</summary>
|
||||
[JsonPropertyName("keys")]
|
||||
public ImmutableArray<string> Keys { get; init; } = [];
|
||||
|
||||
/// <summary>Verification expectations.</summary>
|
||||
[JsonPropertyName("expectations")]
|
||||
public VerifyExpectations? Expectations { get; init; }
|
||||
|
||||
/// <summary>Certificate roots for verification.</summary>
|
||||
[JsonPropertyName("certificateRoots")]
|
||||
public ImmutableArray<string> CertificateRoots { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations.
|
||||
/// </summary>
|
||||
public sealed record VerifyExpectations
|
||||
{
|
||||
/// <summary>Expected payload types.</summary>
|
||||
[JsonPropertyName("payloadTypes")]
|
||||
public ImmutableArray<string> PayloadTypes { get; init; } = [];
|
||||
|
||||
/// <summary>Whether Rekor inclusion is required.</summary>
|
||||
[JsonPropertyName("rekorRequired")]
|
||||
public bool RekorRequired { get; init; }
|
||||
|
||||
/// <summary>Expected issuers.</summary>
|
||||
[JsonPropertyName("issuers")]
|
||||
public ImmutableArray<string> Issuers { get; init; } = [];
|
||||
|
||||
/// <summary>Minimum signature count.</summary>
|
||||
[JsonPropertyName("minSignatures")]
|
||||
public int MinSignatures { get; init; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrer index.
|
||||
/// </summary>
|
||||
public sealed record OciReferrerIndex
|
||||
{
|
||||
/// <summary>Referrer descriptors.</summary>
|
||||
[JsonPropertyName("manifests")]
|
||||
public ImmutableArray<OciReferrerDescriptor> Manifests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrer descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciReferrerDescriptor
|
||||
{
|
||||
/// <summary>Media type.</summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>Digest.</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Artifact type.</summary>
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>Size.</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Annotations.</summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata
|
||||
{
|
||||
/// <summary>When bundle was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Bundle creator.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Bundle description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Source environment.</summary>
|
||||
[JsonPropertyName("sourceEnvironment")]
|
||||
public string? SourceEnvironment { get; init; }
|
||||
|
||||
/// <summary>Target environment.</summary>
|
||||
[JsonPropertyName("targetEnvironment")]
|
||||
public string? TargetEnvironment { get; init; }
|
||||
|
||||
/// <summary>Additional labels.</summary>
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle information.
|
||||
/// </summary>
|
||||
public sealed record BundleInfoV2
|
||||
{
|
||||
/// <summary>Primary image reference.</summary>
|
||||
[JsonPropertyName("image")]
|
||||
public required string Image { get; init; }
|
||||
|
||||
/// <summary>Image digest.</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Bundle artifacts.</summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public required ImmutableArray<BundleArtifactV2> Artifacts { get; init; }
|
||||
|
||||
/// <summary>OCI referrer manifest.</summary>
|
||||
[JsonPropertyName("referrers")]
|
||||
public OciReferrerIndex? Referrers { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
@@ -71,200 +71,3 @@ public sealed record BundleManifest
|
||||
/// </summary>
|
||||
public ImmutableArray<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in a bundle (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact(
|
||||
/// <summary>Relative path within the bundle.</summary>
|
||||
string? Path,
|
||||
/// <summary>Artifact type: sbom, vex, dsse, rekor-proof, oci-referrers, etc.</summary>
|
||||
string Type,
|
||||
/// <summary>Content type (MIME).</summary>
|
||||
string? ContentType,
|
||||
/// <summary>SHA-256 digest of the artifact.</summary>
|
||||
string? Digest,
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long? SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Verification section for bundle validation (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleVerifySection
|
||||
{
|
||||
/// <summary>
|
||||
/// Trusted signing keys for verification.
|
||||
/// Formats: kms://..., file://..., sigstore://...
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Keys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations.
|
||||
/// </summary>
|
||||
public BundleVerifyExpectations? Expectations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: path to trust root certificate.
|
||||
/// </summary>
|
||||
public string? TrustRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Rekor checkpoint for offline proof verification.
|
||||
/// </summary>
|
||||
public string? RekorCheckpointPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyExpectations
|
||||
{
|
||||
/// <summary>
|
||||
/// Expected payload types in DSSE envelopes.
|
||||
/// Example: ["application/vnd.cyclonedx+json;version=1.6", "application/vnd.openvex+json"]
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PayloadTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor proof is required for verification.
|
||||
/// </summary>
|
||||
public bool RekorRequired { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of signatures required.
|
||||
/// </summary>
|
||||
public int MinSignatures { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Required artifact types that must be present.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RequiredArtifacts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether all artifacts must pass checksum verification.
|
||||
/// </summary>
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record FeedComponent(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format);
|
||||
|
||||
public enum FeedFormat
|
||||
{
|
||||
StellaOpsNative,
|
||||
TrivyDb,
|
||||
GrypeDb,
|
||||
OsvJson
|
||||
}
|
||||
|
||||
public sealed record PolicyComponent(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
PolicyType Type);
|
||||
|
||||
public enum PolicyType
|
||||
{
|
||||
OpaRego,
|
||||
LatticeRules,
|
||||
UnknownBudgets,
|
||||
ScoringWeights,
|
||||
/// <summary>
|
||||
/// Local RBAC policy file for Authority offline fallback.
|
||||
/// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback Task: RBAC-010
|
||||
/// </summary>
|
||||
LocalRbac
|
||||
}
|
||||
|
||||
public sealed record CryptoComponent(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
public enum CryptoComponentType
|
||||
{
|
||||
TrustRoot,
|
||||
IntermediateCa,
|
||||
TimestampRoot,
|
||||
SigningKey,
|
||||
FulcioRoot
|
||||
}
|
||||
|
||||
public sealed record CatalogComponent(
|
||||
string CatalogId,
|
||||
string Ecosystem,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt);
|
||||
|
||||
public sealed record RekorSnapshot(
|
||||
string TreeId,
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
DateTimeOffset SnapshotAt);
|
||||
|
||||
public sealed record CryptoProviderComponent(
|
||||
string ProviderId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
ImmutableArray<string> SupportedAlgorithms);
|
||||
|
||||
/// <summary>
|
||||
/// Component for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="RelativePath">Relative path to the bundle directory.</param>
|
||||
/// <param name="Digest">Combined digest of all files in the bundle.</param>
|
||||
/// <param name="SizeBytes">Total size of the bundle in bytes.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
/// <param name="Files">List of files in the bundle.</param>
|
||||
public sealed record RuleBundleComponent(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt,
|
||||
ImmutableArray<RuleBundleFileComponent> Files);
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="Name">Filename (e.g., "secrets.ruleset.manifest.json").</param>
|
||||
/// <param name="Digest">SHA256 digest of the file.</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
public sealed record RuleBundleFileComponent(
|
||||
string Name,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap bundle manifest v2.0.0 per advisory specification.
|
||||
/// </summary>
|
||||
public sealed record BundleManifestV2
|
||||
{
|
||||
/// <summary>Schema version.</summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "2.0.0";
|
||||
|
||||
/// <summary>Canonical manifest hash (sha256 over canonical JSON).</summary>
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; init; }
|
||||
|
||||
/// <summary>Subject digests for the bundle target.</summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>Timestamp entries for offline verification.</summary>
|
||||
[JsonPropertyName("timestamps")]
|
||||
public ImmutableArray<TimestampEntry> Timestamps { get; init; } = [];
|
||||
|
||||
/// <summary>Rekor proof entries for offline verification.</summary>
|
||||
[JsonPropertyName("rekorProofs")]
|
||||
public ImmutableArray<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
|
||||
/// <summary>Bundle information.</summary>
|
||||
[JsonPropertyName("bundle")]
|
||||
public required BundleInfoV2 Bundle { get; init; }
|
||||
|
||||
/// <summary>Verification configuration.</summary>
|
||||
[JsonPropertyName("verify")]
|
||||
public BundleVerifySectionV2? Verify { get; init; }
|
||||
|
||||
/// <summary>Bundle metadata.</summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public BundleMetadata? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle metadata.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata
|
||||
{
|
||||
/// <summary>When bundle was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Bundle creator.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Bundle description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Source environment.</summary>
|
||||
[JsonPropertyName("sourceEnvironment")]
|
||||
public string? SourceEnvironment { get; init; }
|
||||
|
||||
/// <summary>Target environment.</summary>
|
||||
[JsonPropertyName("targetEnvironment")]
|
||||
public string? TargetEnvironment { get; init; }
|
||||
|
||||
/// <summary>Additional labels.</summary>
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyExpectations
|
||||
{
|
||||
/// <summary>
|
||||
/// Expected payload types in DSSE envelopes.
|
||||
/// Example: ["application/vnd.cyclonedx+json;version=1.6", "application/vnd.openvex+json"]
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PayloadTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor proof is required for verification.
|
||||
/// </summary>
|
||||
public bool RekorRequired { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of signatures required.
|
||||
/// </summary>
|
||||
public int MinSignatures { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Required artifact types that must be present.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RequiredArtifacts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether all artifacts must pass checksum verification.
|
||||
/// </summary>
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Verification section for bundle validation (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleVerifySection
|
||||
{
|
||||
/// <summary>
|
||||
/// Trusted signing keys for verification.
|
||||
/// Formats: kms://..., file://..., sigstore://...
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Keys { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations.
|
||||
/// </summary>
|
||||
public BundleVerifyExpectations? Expectations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: path to trust root certificate.
|
||||
/// </summary>
|
||||
public string? TrustRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Rekor checkpoint for offline proof verification.
|
||||
/// </summary>
|
||||
public string? RekorCheckpointPath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification section.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifySectionV2
|
||||
{
|
||||
/// <summary>Trusted signing keys.</summary>
|
||||
[JsonPropertyName("keys")]
|
||||
public ImmutableArray<string> Keys { get; init; } = [];
|
||||
|
||||
/// <summary>Verification expectations.</summary>
|
||||
[JsonPropertyName("expectations")]
|
||||
public VerifyExpectations? Expectations { get; init; }
|
||||
|
||||
/// <summary>Certificate roots for verification.</summary>
|
||||
[JsonPropertyName("certificateRoots")]
|
||||
public ImmutableArray<string> CertificateRoots { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record CatalogComponent(
|
||||
string CatalogId,
|
||||
string Ecosystem,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record CryptoComponent(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public enum CryptoComponentType
|
||||
{
|
||||
TrustRoot,
|
||||
IntermediateCa,
|
||||
TimestampRoot,
|
||||
SigningKey,
|
||||
FulcioRoot
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record CryptoProviderComponent(
|
||||
string ProviderId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
ImmutableArray<string> SupportedAlgorithms);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record FeedComponent(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public enum FeedFormat
|
||||
{
|
||||
StellaOpsNative,
|
||||
TrivyDb,
|
||||
GrypeDb,
|
||||
OsvJson
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
// Task: SEAL-001 - Define KnowledgeSnapshotManifest schema
|
||||
// Description: Manifest model for sealed knowledge snapshots.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,139 +27,3 @@ public sealed class KnowledgeSnapshotManifest
|
||||
public List<RuleBundleSnapshotEntry> RuleBundles { get; init; } = [];
|
||||
public TimeAnchorEntry? TimeAnchor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for an advisory feed in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotEntry
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public DateTimeOffset SnapshotAt { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for VEX statements in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class VexSnapshotEntry
|
||||
{
|
||||
public required string SourceId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public DateTimeOffset SnapshotAt { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a policy in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class PolicySnapshotEntry
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public string Type { get; init; } = "OpaRego";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a trust root in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class TrustRootSnapshotEntry
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public string Algorithm { get; init; } = "ES256";
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a rule bundle in the snapshot.
|
||||
/// Used for detection rule bundles (secrets, malware, etc.).
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSnapshotEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the bundle directory in the snapshot.
|
||||
/// </summary>
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of files in the bundle with their digests.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFile> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the file.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor entry in the manifest.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorEntry
|
||||
{
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrer descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciReferrerDescriptor
|
||||
{
|
||||
/// <summary>Media type.</summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>Digest.</summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Artifact type.</summary>
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>Size.</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Annotations.</summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrer index.
|
||||
/// </summary>
|
||||
public sealed record OciReferrerIndex
|
||||
{
|
||||
/// <summary>Referrer descriptors.</summary>
|
||||
[JsonPropertyName("manifests")]
|
||||
public ImmutableArray<OciReferrerDescriptor> Manifests { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record PolicyComponent(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
PolicyType Type);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a policy in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class PolicySnapshotEntry
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public string Type { get; init; } = "OpaRego";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public enum PolicyType
|
||||
{
|
||||
OpaRego,
|
||||
LatticeRules,
|
||||
UnknownBudgets,
|
||||
ScoringWeights,
|
||||
/// <summary>
|
||||
/// Local RBAC policy file for Authority offline fallback.
|
||||
/// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback Task: RBAC-010
|
||||
/// </summary>
|
||||
LocalRbac
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
public sealed record RekorSnapshot(
|
||||
string TreeId,
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
DateTimeOffset SnapshotAt);
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Component for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="RelativePath">Relative path to the bundle directory.</param>
|
||||
/// <param name="Digest">Combined digest of all files in the bundle.</param>
|
||||
/// <param name="SizeBytes">Total size of the bundle in bytes.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
/// <param name="Files">List of files in the bundle.</param>
|
||||
public sealed record RuleBundleComponent(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt,
|
||||
ImmutableArray<RuleBundleFileComponent> Files);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the file.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="Name">Filename (e.g., "secrets.ruleset.manifest.json").</param>
|
||||
/// <param name="Digest">SHA256 digest of the file.</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
public sealed record RuleBundleFileComponent(
|
||||
string Name,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a rule bundle in the snapshot.
|
||||
/// Used for detection rule bundles (secrets, malware, etc.).
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSnapshotEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the bundle directory in the snapshot.
|
||||
/// </summary>
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of files in the bundle with their digests.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFile> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor entry in the manifest.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorEntry
|
||||
{
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a trust root in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class TrustRootSnapshotEntry
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public string Algorithm { get; init; } = "ES256";
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Verification expectations.
|
||||
/// </summary>
|
||||
public sealed record VerifyExpectations
|
||||
{
|
||||
/// <summary>Expected payload types.</summary>
|
||||
[JsonPropertyName("payloadTypes")]
|
||||
public ImmutableArray<string> PayloadTypes { get; init; } = [];
|
||||
|
||||
/// <summary>Whether Rekor inclusion is required.</summary>
|
||||
[JsonPropertyName("rekorRequired")]
|
||||
public bool RekorRequired { get; init; }
|
||||
|
||||
/// <summary>Expected issuers.</summary>
|
||||
[JsonPropertyName("issuers")]
|
||||
public ImmutableArray<string> Issuers { get; init; } = [];
|
||||
|
||||
/// <summary>Minimum signature count.</summary>
|
||||
[JsonPropertyName("minSignatures")]
|
||||
public int MinSignatures { get; init; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for VEX statements in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class VexSnapshotEntry
|
||||
{
|
||||
public required string SourceId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public DateTimeOffset SnapshotAt { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace StellaOps.AirGap.Bundle.Serialization;
|
||||
/// </summary>
|
||||
public static class BundleManifestSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -28,14 +28,14 @@ public static class BundleManifestSerializer
|
||||
|
||||
public static string Serialize(BundleManifest manifest)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, _jsonOptions);
|
||||
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
|
||||
return Encoding.UTF8.GetString(canonicalBytes);
|
||||
}
|
||||
|
||||
public static BundleManifest Deserialize(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<BundleManifest>(json, JsonOptions)
|
||||
return JsonSerializer.Deserialize<BundleManifest>(json, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize bundle manifest");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed record AdvisoryContent
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public DateTimeOffset? SnapshotAt { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed record BundleArtifactBuildConfig
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? SourcePath { get; init; }
|
||||
public byte[]? Content { get; init; }
|
||||
public string? RelativePath { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed record BundleBuildRequest(
|
||||
string Name,
|
||||
string Version,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles,
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null,
|
||||
IReadOnlyList<BundleArtifactBuildConfig>? Artifacts = null,
|
||||
bool StrictInlineArtifacts = false,
|
||||
ICollection<string>? WarningSink = null,
|
||||
BundleBuilderOptions? ExportOptions = null);
|
||||
@@ -0,0 +1,88 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<(BundleArtifact Artifact, long SizeBytes)> AddArtifactAsync(
|
||||
BundleArtifactBuildConfig config,
|
||||
string outputPath,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.Type))
|
||||
{
|
||||
throw new ArgumentException("Artifact type is required.", nameof(config));
|
||||
}
|
||||
|
||||
var hasSourcePath = !string.IsNullOrWhiteSpace(config.SourcePath);
|
||||
var hasContent = config.Content is { Length: > 0 };
|
||||
if (!hasSourcePath && !hasContent)
|
||||
{
|
||||
throw new ArgumentException("Artifact content or source path is required.", nameof(config));
|
||||
}
|
||||
|
||||
string? relativePath = string.IsNullOrWhiteSpace(config.RelativePath) ? null : config.RelativePath;
|
||||
if (!string.IsNullOrWhiteSpace(relativePath) && !PathValidation.IsSafeRelativePath(relativePath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relative path: {relativePath}", nameof(config));
|
||||
}
|
||||
|
||||
string digest;
|
||||
long sizeBytes;
|
||||
|
||||
if (hasSourcePath)
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(config.SourcePath!);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException("Artifact source file not found.", sourcePath);
|
||||
}
|
||||
|
||||
var info = new FileInfo(sourcePath);
|
||||
sizeBytes = info.Length;
|
||||
digest = await ComputeSha256DigestAsync(sourcePath, ct).ConfigureAwait(false);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = config.Content ?? Array.Empty<byte>();
|
||||
sizeBytes = content.Length;
|
||||
digest = ComputeSha256Digest(content);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var artifact = new BundleArtifact(relativePath, config.Type, config.ContentType, digest, sizeBytes);
|
||||
return (artifact, sizeBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static string? ApplyInlineSizeGuard(
|
||||
string? relativePath,
|
||||
BundleArtifactBuildConfig config,
|
||||
string digest,
|
||||
long sizeBytes,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
if (!BundleSizeValidator.RequiresExternalization(sizeBytes))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var warning = BundleSizeValidator.GetInlineSizeWarning(sizeBytes)
|
||||
?? "Inline artifact size exceeds the maximum allowed size.";
|
||||
|
||||
if (strictInlineArtifacts)
|
||||
{
|
||||
throw new InvalidOperationException(warning);
|
||||
}
|
||||
|
||||
warningSink?.Add(warning);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(config.FileName)
|
||||
? BuildInlineFallbackName(config.Type, digest)
|
||||
: EnsureSafeFileName(config.FileName);
|
||||
|
||||
var fallbackPath = $"artifacts/{fileName}";
|
||||
if (!PathValidation.IsSafeRelativePath(fallbackPath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact fallback path: {fallbackPath}", nameof(config));
|
||||
}
|
||||
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
private static string BuildInlineFallbackName(string type, string digest)
|
||||
{
|
||||
var normalizedType = SanitizeFileSegment(type);
|
||||
var digestValue = digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest[7..]
|
||||
: digest;
|
||||
var shortDigest = digestValue.Length > 12 ? digestValue[..12] : digestValue;
|
||||
return $"{normalizedType}-{shortDigest}.blob";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<string> ComputeSha256DigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static string SanitizeFileSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "artifact";
|
||||
}
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
var index = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_')
|
||||
{
|
||||
buffer[index++] = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[index++] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
var cleaned = new string(buffer, 0, index).Trim('-');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? "artifact" : cleaned;
|
||||
}
|
||||
|
||||
private static string EnsureSafeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
throw new ArgumentException("Artifact file name is required.");
|
||||
}
|
||||
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
|
||||
fileName.Contains('/') ||
|
||||
fileName.Contains('\\'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact file name: {fileName}");
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<ImmutableArray<BundleArtifact>> BuildArtifactsAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var artifacts = new List<BundleArtifact>();
|
||||
var artifactConfigs = request.Artifacts ?? Array.Empty<BundleArtifactBuildConfig>();
|
||||
foreach (var artifactConfig in artifactConfigs)
|
||||
{
|
||||
var (artifact, _) = await AddArtifactAsync(
|
||||
artifactConfig,
|
||||
outputPath,
|
||||
request.StrictInlineArtifacts,
|
||||
request.WarningSink,
|
||||
ct).ConfigureAwait(false);
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
|
||||
return artifacts.ToImmutableArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
public async Task<BundleManifest> BuildAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var feeds = await BuildFeedComponentsAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
var policies = await BuildPolicyComponentsAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
var cryptoMaterials = await BuildCryptoComponentsAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
var ruleBundles = await BuildRuleBundlesAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
var (timestamps, timestampSizeBytes) = await BuildTimestampsAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
var artifacts = await BuildArtifactsAsync(request, outputPath, ct).ConfigureAwait(false);
|
||||
|
||||
var artifactsSizeBytes = artifacts.Sum(a => a.SizeBytes ?? 0);
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes) +
|
||||
ruleBundles.Sum(r => r.SizeBytes) +
|
||||
timestampSizeBytes +
|
||||
artifactsSizeBytes;
|
||||
|
||||
var exportMode = request.ExportOptions?.Mode ?? BundleExportMode.Light;
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = _guidProvider.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Feeds = feeds,
|
||||
Policies = policies,
|
||||
CryptoMaterials = cryptoMaterials,
|
||||
RuleBundles = ruleBundles,
|
||||
Timestamps = timestamps,
|
||||
Artifacts = artifacts,
|
||||
ExportMode = exportMode.ToString().ToLowerInvariant(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
return BundleManifestSerializer.WithDigest(manifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private async Task<ImmutableArray<FeedComponent>> BuildFeedComponentsAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var feeds = new List<FeedComponent>();
|
||||
foreach (var feedConfig in request.Feeds)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, feedConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(feedConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
feeds.Add(new FeedComponent(
|
||||
feedConfig.FeedId,
|
||||
feedConfig.Name,
|
||||
feedConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
feedConfig.SnapshotAt,
|
||||
feedConfig.Format));
|
||||
}
|
||||
|
||||
return feeds.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<PolicyComponent>> BuildPolicyComponentsAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policies = new List<PolicyComponent>();
|
||||
foreach (var policyConfig in request.Policies)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, policyConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(policyConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
policies.Add(new PolicyComponent(
|
||||
policyConfig.PolicyId,
|
||||
policyConfig.Name,
|
||||
policyConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
policyConfig.Type));
|
||||
}
|
||||
|
||||
return policies.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<CryptoComponent>> BuildCryptoComponentsAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cryptoMaterials = new List<CryptoComponent>();
|
||||
foreach (var cryptoConfig in request.CryptoMaterials)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, cryptoConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(cryptoConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
cryptoMaterials.Add(new CryptoComponent(
|
||||
cryptoConfig.ComponentId,
|
||||
cryptoConfig.Name,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
cryptoConfig.Type,
|
||||
cryptoConfig.ExpiresAt));
|
||||
}
|
||||
|
||||
return cryptoMaterials.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<CopiedComponent> CopyComponentAsync(
|
||||
BundleComponentSource source,
|
||||
string outputPath,
|
||||
string targetPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(source.SourcePath))
|
||||
await using (var output = File.Create(targetPath))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var digestStream = File.OpenRead(targetPath);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedComponent(source.RelativePath, digest, info.Length);
|
||||
}
|
||||
|
||||
private sealed record CopiedComponent(string RelativePath, string Digest, long SizeBytes);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<ImmutableArray<RuleBundleComponent>> BuildRuleBundlesAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ruleBundles = new List<RuleBundleComponent>();
|
||||
foreach (var ruleBundleConfig in request.RuleBundles)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetDir = PathValidation.SafeCombine(outputPath, ruleBundleConfig.RelativePath);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
var files = new List<RuleBundleFileComponent>();
|
||||
long bundleTotalSize = 0;
|
||||
var digestBuilder = new StringBuilder();
|
||||
|
||||
// Copy all files from source directory
|
||||
if (Directory.Exists(ruleBundleConfig.SourceDirectory))
|
||||
{
|
||||
foreach (var sourceFile in Directory.GetFiles(ruleBundleConfig.SourceDirectory)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal))
|
||||
{
|
||||
var fileName = Path.GetFileName(sourceFile);
|
||||
var targetFile = Path.Combine(targetDir, fileName);
|
||||
|
||||
await using (var input = File.OpenRead(sourceFile))
|
||||
await using (var output = File.Create(targetFile))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var digestStream = File.OpenRead(targetFile);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var fileDigest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var fileInfo = new FileInfo(targetFile);
|
||||
files.Add(new RuleBundleFileComponent(fileName, fileDigest, fileInfo.Length));
|
||||
bundleTotalSize += fileInfo.Length;
|
||||
digestBuilder.Append(fileDigest);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute combined digest from all file digests
|
||||
var combinedDigest = Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(digestBuilder.ToString()))).ToLowerInvariant();
|
||||
|
||||
ruleBundles.Add(new RuleBundleComponent(
|
||||
ruleBundleConfig.BundleId,
|
||||
ruleBundleConfig.BundleType,
|
||||
ruleBundleConfig.Version,
|
||||
ruleBundleConfig.RelativePath,
|
||||
combinedDigest,
|
||||
bundleTotalSize,
|
||||
ruleBundleConfig.RuleCount,
|
||||
ruleBundleConfig.SignerKeyId,
|
||||
ruleBundleConfig.SignedAt,
|
||||
files.ToImmutableArray()));
|
||||
}
|
||||
|
||||
return ruleBundles.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<CopiedTimestampComponent> CopyTimestampFileAsync(
|
||||
string sourcePath,
|
||||
string relativePath,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(sourcePath))
|
||||
await using (var output = File.Create(targetPath))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedTimestampComponent(relativePath, info.Length);
|
||||
}
|
||||
|
||||
private sealed record CopiedTimestampComponent(string RelativePath, long SizeBytes);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private static async Task<(ImmutableArray<string> Paths, long SizeBytes)> WriteRevocationBlobsAsync(
|
||||
string baseDir,
|
||||
string extension,
|
||||
string prefix,
|
||||
IReadOnlyList<TsaRevocationBlob> blobs,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (blobs.Count == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
var paths = new List<string>(blobs.Count);
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var blob in blobs
|
||||
.OrderBy(b => b.CertificateIndex)
|
||||
.ThenBy(b => ComputeShortHash(b.Data), StringComparer.Ordinal))
|
||||
{
|
||||
var hash = ComputeShortHash(blob.Data);
|
||||
var fileName = $"{prefix}-{blob.CertificateIndex:D2}-{hash}.{extension}";
|
||||
var relativePath = $"{baseDir}/{fileName}";
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, blob.Data, ct).ConfigureAwait(false);
|
||||
|
||||
totalSize += blob.Data.Length;
|
||||
paths.Add(relativePath);
|
||||
}
|
||||
|
||||
return (paths.ToImmutableArray(), totalSize);
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private async Task<(Rfc3161TimestampEntry Entry, long SizeBytes)> BuildRfc3161TimestampAsync(
|
||||
Rfc3161TimestampBuildConfig config,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (config.TimeStampToken is not { Length: > 0 })
|
||||
{
|
||||
throw new ArgumentException("RFC3161 timestamp token is required.", nameof(config));
|
||||
}
|
||||
|
||||
var tokenHash = SHA256.HashData(config.TimeStampToken);
|
||||
var tokenPrefix = Convert.ToHexString(tokenHash).ToLowerInvariant()[..12];
|
||||
|
||||
var chainResult = await _tsaChainBundler.BundleAsync(
|
||||
config.TimeStampToken,
|
||||
outputPath,
|
||||
tokenPrefix,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var ocspBlobs = await _ocspFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (ocspPaths, ocspSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/ocsp",
|
||||
"der",
|
||||
tokenPrefix,
|
||||
ocspBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var crlBlobs = await _crlFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (crlPaths, crlSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/crl",
|
||||
"crl",
|
||||
tokenPrefix,
|
||||
crlBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var entry = new Rfc3161TimestampEntry
|
||||
{
|
||||
TsaChainPaths = chainResult.ChainPaths,
|
||||
OcspBlobs = ocspPaths,
|
||||
CrlBlobs = crlPaths,
|
||||
TstBase64 = Convert.ToBase64String(config.TimeStampToken)
|
||||
};
|
||||
|
||||
return (entry, chainResult.TotalSizeBytes + ocspSizeBytes + crlSizeBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class BundleBuilder
|
||||
{
|
||||
private async Task<(ImmutableArray<TimestampEntry> Entries, long SizeBytes)> BuildTimestampsAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var timestamps = new List<TimestampEntry>();
|
||||
long timestampSizeBytes = 0;
|
||||
var timestampConfigs = request.Timestamps ?? Array.Empty<TimestampBuildConfig>();
|
||||
|
||||
foreach (var timestampConfig in timestampConfigs)
|
||||
{
|
||||
switch (timestampConfig)
|
||||
{
|
||||
case Rfc3161TimestampBuildConfig rfc3161:
|
||||
var (rfcEntry, rfcSizeBytes) = await BuildRfc3161TimestampAsync(
|
||||
rfc3161,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(rfcEntry);
|
||||
timestampSizeBytes += rfcSizeBytes;
|
||||
break;
|
||||
case EidasQtsTimestampBuildConfig eidas:
|
||||
var qtsComponent = await CopyTimestampFileAsync(
|
||||
eidas.SourcePath,
|
||||
eidas.RelativePath,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(new EidasQtsTimestampEntry
|
||||
{
|
||||
QtsMetaPath = qtsComponent.RelativePath
|
||||
});
|
||||
timestampSizeBytes += qtsComponent.SizeBytes;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported timestamp build config type '{timestampConfig.GetType().Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return (timestamps.ToImmutableArray(), timestampSizeBytes);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Validation;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed class BundleBuilder : IBundleBuilder
|
||||
public sealed partial class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
@@ -38,603 +32,4 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
_ocspFetcher = ocspFetcher ?? new OcspResponseFetcher();
|
||||
_crlFetcher = crlFetcher ?? new CrlFetcher();
|
||||
}
|
||||
|
||||
public async Task<BundleManifest> BuildAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var feeds = new List<FeedComponent>();
|
||||
var policies = new List<PolicyComponent>();
|
||||
var cryptoMaterials = new List<CryptoComponent>();
|
||||
|
||||
foreach (var feedConfig in request.Feeds)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, feedConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(feedConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
feeds.Add(new FeedComponent(
|
||||
feedConfig.FeedId,
|
||||
feedConfig.Name,
|
||||
feedConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
feedConfig.SnapshotAt,
|
||||
feedConfig.Format));
|
||||
}
|
||||
|
||||
foreach (var policyConfig in request.Policies)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, policyConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(policyConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
policies.Add(new PolicyComponent(
|
||||
policyConfig.PolicyId,
|
||||
policyConfig.Name,
|
||||
policyConfig.Version,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
policyConfig.Type));
|
||||
}
|
||||
|
||||
foreach (var cryptoConfig in request.CryptoMaterials)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, cryptoConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(cryptoConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
cryptoMaterials.Add(new CryptoComponent(
|
||||
cryptoConfig.ComponentId,
|
||||
cryptoConfig.Name,
|
||||
component.RelativePath,
|
||||
component.Digest,
|
||||
component.SizeBytes,
|
||||
cryptoConfig.Type,
|
||||
cryptoConfig.ExpiresAt));
|
||||
}
|
||||
|
||||
var ruleBundles = new List<RuleBundleComponent>();
|
||||
foreach (var ruleBundleConfig in request.RuleBundles)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetDir = PathValidation.SafeCombine(outputPath, ruleBundleConfig.RelativePath);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
var files = new List<RuleBundleFileComponent>();
|
||||
long bundleTotalSize = 0;
|
||||
var digestBuilder = new System.Text.StringBuilder();
|
||||
|
||||
// Copy all files from source directory
|
||||
if (Directory.Exists(ruleBundleConfig.SourceDirectory))
|
||||
{
|
||||
foreach (var sourceFile in Directory.GetFiles(ruleBundleConfig.SourceDirectory)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal))
|
||||
{
|
||||
var fileName = Path.GetFileName(sourceFile);
|
||||
var targetFile = Path.Combine(targetDir, fileName);
|
||||
|
||||
await using (var input = File.OpenRead(sourceFile))
|
||||
await using (var output = File.Create(targetFile))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var digestStream = File.OpenRead(targetFile);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var fileDigest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var fileInfo = new FileInfo(targetFile);
|
||||
files.Add(new RuleBundleFileComponent(fileName, fileDigest, fileInfo.Length));
|
||||
bundleTotalSize += fileInfo.Length;
|
||||
digestBuilder.Append(fileDigest);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute combined digest from all file digests
|
||||
var combinedDigest = Convert.ToHexString(
|
||||
SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestBuilder.ToString()))).ToLowerInvariant();
|
||||
|
||||
ruleBundles.Add(new RuleBundleComponent(
|
||||
ruleBundleConfig.BundleId,
|
||||
ruleBundleConfig.BundleType,
|
||||
ruleBundleConfig.Version,
|
||||
ruleBundleConfig.RelativePath,
|
||||
combinedDigest,
|
||||
bundleTotalSize,
|
||||
ruleBundleConfig.RuleCount,
|
||||
ruleBundleConfig.SignerKeyId,
|
||||
ruleBundleConfig.SignedAt,
|
||||
files.ToImmutableArray()));
|
||||
}
|
||||
|
||||
var timestamps = new List<TimestampEntry>();
|
||||
long timestampSizeBytes = 0;
|
||||
var timestampConfigs = request.Timestamps ?? Array.Empty<TimestampBuildConfig>();
|
||||
foreach (var timestampConfig in timestampConfigs)
|
||||
{
|
||||
switch (timestampConfig)
|
||||
{
|
||||
case Rfc3161TimestampBuildConfig rfc3161:
|
||||
var (rfcEntry, rfcSizeBytes) = await BuildRfc3161TimestampAsync(
|
||||
rfc3161,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(rfcEntry);
|
||||
timestampSizeBytes += rfcSizeBytes;
|
||||
break;
|
||||
case EidasQtsTimestampBuildConfig eidas:
|
||||
var qtsComponent = await CopyTimestampFileAsync(
|
||||
eidas.SourcePath,
|
||||
eidas.RelativePath,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
timestamps.Add(new EidasQtsTimestampEntry
|
||||
{
|
||||
QtsMetaPath = qtsComponent.RelativePath
|
||||
});
|
||||
timestampSizeBytes += qtsComponent.SizeBytes;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported timestamp build config type '{timestampConfig.GetType().Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var artifacts = new List<BundleArtifact>();
|
||||
long artifactsSizeBytes = 0;
|
||||
var artifactConfigs = request.Artifacts ?? Array.Empty<BundleArtifactBuildConfig>();
|
||||
foreach (var artifactConfig in artifactConfigs)
|
||||
{
|
||||
var (artifact, sizeBytes) = await AddArtifactAsync(
|
||||
artifactConfig,
|
||||
outputPath,
|
||||
request.StrictInlineArtifacts,
|
||||
request.WarningSink,
|
||||
ct).ConfigureAwait(false);
|
||||
artifacts.Add(artifact);
|
||||
artifactsSizeBytes += sizeBytes;
|
||||
}
|
||||
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes) +
|
||||
ruleBundles.Sum(r => r.SizeBytes) +
|
||||
timestampSizeBytes +
|
||||
artifactsSizeBytes;
|
||||
|
||||
var exportMode = request.ExportOptions?.Mode ?? BundleExportMode.Light;
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = _guidProvider.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Feeds = feeds.ToImmutableArray(),
|
||||
Policies = policies.ToImmutableArray(),
|
||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||
Timestamps = timestamps.ToImmutableArray(),
|
||||
Artifacts = artifacts.ToImmutableArray(),
|
||||
ExportMode = exportMode.ToString().ToLowerInvariant(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
return BundleManifestSerializer.WithDigest(manifest);
|
||||
}
|
||||
|
||||
private static async Task<(BundleArtifact Artifact, long SizeBytes)> AddArtifactAsync(
|
||||
BundleArtifactBuildConfig config,
|
||||
string outputPath,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.Type))
|
||||
{
|
||||
throw new ArgumentException("Artifact type is required.", nameof(config));
|
||||
}
|
||||
|
||||
var hasSourcePath = !string.IsNullOrWhiteSpace(config.SourcePath);
|
||||
var hasContent = config.Content is { Length: > 0 };
|
||||
if (!hasSourcePath && !hasContent)
|
||||
{
|
||||
throw new ArgumentException("Artifact content or source path is required.", nameof(config));
|
||||
}
|
||||
|
||||
string? relativePath = string.IsNullOrWhiteSpace(config.RelativePath) ? null : config.RelativePath;
|
||||
if (!string.IsNullOrWhiteSpace(relativePath) && !PathValidation.IsSafeRelativePath(relativePath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relative path: {relativePath}", nameof(config));
|
||||
}
|
||||
|
||||
string digest;
|
||||
long sizeBytes;
|
||||
|
||||
if (hasSourcePath)
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(config.SourcePath!);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException("Artifact source file not found.", sourcePath);
|
||||
}
|
||||
|
||||
var info = new FileInfo(sourcePath);
|
||||
sizeBytes = info.Length;
|
||||
digest = await ComputeSha256DigestAsync(sourcePath, ct).ConfigureAwait(false);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = config.Content ?? Array.Empty<byte>();
|
||||
sizeBytes = content.Length;
|
||||
digest = ComputeSha256Digest(content);
|
||||
relativePath = ApplyInlineSizeGuard(
|
||||
relativePath,
|
||||
config,
|
||||
digest,
|
||||
sizeBytes,
|
||||
strictInlineArtifacts,
|
||||
warningSink);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var artifact = new BundleArtifact(relativePath, config.Type, config.ContentType, digest, sizeBytes);
|
||||
return (artifact, sizeBytes);
|
||||
}
|
||||
|
||||
private static string? ApplyInlineSizeGuard(
|
||||
string? relativePath,
|
||||
BundleArtifactBuildConfig config,
|
||||
string digest,
|
||||
long sizeBytes,
|
||||
bool strictInlineArtifacts,
|
||||
ICollection<string>? warningSink)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
if (!BundleSizeValidator.RequiresExternalization(sizeBytes))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var warning = BundleSizeValidator.GetInlineSizeWarning(sizeBytes)
|
||||
?? "Inline artifact size exceeds the maximum allowed size.";
|
||||
|
||||
if (strictInlineArtifacts)
|
||||
{
|
||||
throw new InvalidOperationException(warning);
|
||||
}
|
||||
|
||||
warningSink?.Add(warning);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(config.FileName)
|
||||
? BuildInlineFallbackName(config.Type, digest)
|
||||
: EnsureSafeFileName(config.FileName);
|
||||
|
||||
var fallbackPath = $"artifacts/{fileName}";
|
||||
if (!PathValidation.IsSafeRelativePath(fallbackPath))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact fallback path: {fallbackPath}", nameof(config));
|
||||
}
|
||||
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
private static string BuildInlineFallbackName(string type, string digest)
|
||||
{
|
||||
var normalizedType = SanitizeFileSegment(type);
|
||||
var digestValue = digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest[7..]
|
||||
: digest;
|
||||
var shortDigest = digestValue.Length > 12 ? digestValue[..12] : digestValue;
|
||||
return $"{normalizedType}-{shortDigest}.blob";
|
||||
}
|
||||
|
||||
private static string SanitizeFileSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "artifact";
|
||||
}
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
var index = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_')
|
||||
{
|
||||
buffer[index++] = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[index++] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
var cleaned = new string(buffer, 0, index).Trim('-');
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? "artifact" : cleaned;
|
||||
}
|
||||
|
||||
private static string EnsureSafeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
throw new ArgumentException("Artifact file name is required.");
|
||||
}
|
||||
|
||||
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
|
||||
fileName.Contains('/') ||
|
||||
fileName.Contains('\\'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid artifact file name: {fileName}");
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256DigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static async Task<CopiedComponent> CopyComponentAsync(
|
||||
BundleComponentSource source,
|
||||
string outputPath,
|
||||
string targetPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(source.SourcePath))
|
||||
await using (var output = File.Create(targetPath))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var digestStream = File.OpenRead(targetPath);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedComponent(source.RelativePath, digest, info.Length);
|
||||
}
|
||||
|
||||
private async Task<(Rfc3161TimestampEntry Entry, long SizeBytes)> BuildRfc3161TimestampAsync(
|
||||
Rfc3161TimestampBuildConfig config,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (config.TimeStampToken is not { Length: > 0 })
|
||||
{
|
||||
throw new ArgumentException("RFC3161 timestamp token is required.", nameof(config));
|
||||
}
|
||||
|
||||
var tokenHash = SHA256.HashData(config.TimeStampToken);
|
||||
var tokenPrefix = Convert.ToHexString(tokenHash).ToLowerInvariant()[..12];
|
||||
|
||||
var chainResult = await _tsaChainBundler.BundleAsync(
|
||||
config.TimeStampToken,
|
||||
outputPath,
|
||||
tokenPrefix,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var ocspBlobs = await _ocspFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (ocspPaths, ocspSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/ocsp",
|
||||
"der",
|
||||
tokenPrefix,
|
||||
ocspBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var crlBlobs = await _crlFetcher.FetchAsync(chainResult.Certificates, ct).ConfigureAwait(false);
|
||||
var (crlPaths, crlSizeBytes) = await WriteRevocationBlobsAsync(
|
||||
"tsa/crl",
|
||||
"crl",
|
||||
tokenPrefix,
|
||||
crlBlobs,
|
||||
outputPath,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var entry = new Rfc3161TimestampEntry
|
||||
{
|
||||
TsaChainPaths = chainResult.ChainPaths,
|
||||
OcspBlobs = ocspPaths,
|
||||
CrlBlobs = crlPaths,
|
||||
TstBase64 = Convert.ToBase64String(config.TimeStampToken)
|
||||
};
|
||||
|
||||
return (entry, chainResult.TotalSizeBytes + ocspSizeBytes + crlSizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(ImmutableArray<string> Paths, long SizeBytes)> WriteRevocationBlobsAsync(
|
||||
string baseDir,
|
||||
string extension,
|
||||
string prefix,
|
||||
IReadOnlyList<TsaRevocationBlob> blobs,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (blobs.Count == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
var paths = new List<string>(blobs.Count);
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var blob in blobs
|
||||
.OrderBy(b => b.CertificateIndex)
|
||||
.ThenBy(b => ComputeShortHash(b.Data), StringComparer.Ordinal))
|
||||
{
|
||||
var hash = ComputeShortHash(blob.Data);
|
||||
var fileName = $"{prefix}-{blob.CertificateIndex:D2}-{hash}.{extension}";
|
||||
var relativePath = $"{baseDir}/{fileName}";
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
await File.WriteAllBytesAsync(targetPath, blob.Data, ct).ConfigureAwait(false);
|
||||
|
||||
totalSize += blob.Data.Length;
|
||||
paths.Add(relativePath);
|
||||
}
|
||||
|
||||
return (paths.ToImmutableArray(), totalSize);
|
||||
}
|
||||
|
||||
private static string ComputeShortHash(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static async Task<CopiedTimestampComponent> CopyTimestampFileAsync(
|
||||
string sourcePath,
|
||||
string relativePath,
|
||||
string outputPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(sourcePath))
|
||||
await using (var output = File.Create(targetPath))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var info = new FileInfo(targetPath);
|
||||
return new CopiedTimestampComponent(relativePath, info.Length);
|
||||
}
|
||||
|
||||
private sealed record CopiedComponent(string RelativePath, string Digest, long SizeBytes);
|
||||
private sealed record CopiedTimestampComponent(string RelativePath, long SizeBytes);
|
||||
}
|
||||
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
Task<BundleManifest> BuildAsync(BundleBuildRequest request, string outputPath, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record BundleBuildRequest(
|
||||
string Name,
|
||||
string Version,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles,
|
||||
IReadOnlyList<TimestampBuildConfig>? Timestamps = null,
|
||||
IReadOnlyList<BundleArtifactBuildConfig>? Artifacts = null,
|
||||
bool StrictInlineArtifacts = false,
|
||||
ICollection<string>? WarningSink = null,
|
||||
BundleBuilderOptions? ExportOptions = null);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
public sealed record FeedBuildConfig(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record PolicyBuildConfig(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
PolicyType Type)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record CryptoBuildConfig(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public abstract record TimestampBuildConfig;
|
||||
|
||||
public sealed record Rfc3161TimestampBuildConfig(byte[] TimeStampToken)
|
||||
: TimestampBuildConfig;
|
||||
|
||||
public sealed record EidasQtsTimestampBuildConfig(string SourcePath, string RelativePath)
|
||||
: TimestampBuildConfig;
|
||||
|
||||
public sealed record BundleArtifactBuildConfig
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? SourcePath { get; init; }
|
||||
public byte[]? Content { get; init; }
|
||||
public string? RelativePath { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for building a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="SourceDirectory">Source directory containing the rule bundle files.</param>
|
||||
/// <param name="RelativePath">Relative path in the output bundle.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
public sealed record RuleBundleBuildConfig(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string SourceDirectory,
|
||||
string RelativePath,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring bundle validation behavior.
|
||||
/// </summary>
|
||||
public sealed class BundleValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age in days for feed snapshots before they are flagged as stale.
|
||||
/// Default is 7 days.
|
||||
/// </summary>
|
||||
public int MaxFeedAgeDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail validation on stale feeds or just warn.
|
||||
/// </summary>
|
||||
public bool FailOnStaleFeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate policy digests.
|
||||
/// </summary>
|
||||
public bool ValidatePolicies { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate crypto material digests.
|
||||
/// </summary>
|
||||
public bool ValidateCryptoMaterials { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate catalog digests if present.
|
||||
/// </summary>
|
||||
public bool ValidateCatalogs { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate Rekor snapshot entries if present.
|
||||
/// </summary>
|
||||
public bool ValidateRekorSnapshots { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed record FeedBuildConfig(
|
||||
string FeedId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
DateTimeOffset SnapshotAt,
|
||||
FeedFormat Format)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record PolicyBuildConfig(
|
||||
string PolicyId,
|
||||
string Name,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
PolicyType Type)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
public sealed record CryptoBuildConfig(
|
||||
string ComponentId,
|
||||
string Name,
|
||||
string SourcePath,
|
||||
string RelativePath,
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
@@ -0,0 +1,94 @@
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class ConcelierAdvisoryImportTarget
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ModuleImportResultData> ImportAdvisoriesAsync(
|
||||
AdvisoryImportData data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
if (data.Content.Length == 0)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Failed = 1,
|
||||
Error = "Empty advisory content"
|
||||
};
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse NDJSON content - each line is a complete AdvisoryRawDocument.
|
||||
var contentString = Encoding.UTF8.GetString(data.Content);
|
||||
var lines = contentString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<AdvisoryRawDocument>(line.Trim(), _jsonOptions);
|
||||
if (document is null)
|
||||
{
|
||||
failed++;
|
||||
errors.Add("Failed to parse advisory line");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tenantedDocument = document with { Tenant = _tenant };
|
||||
var result = await _repository.UpsertAsync(tenantedDocument, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Inserted)
|
||||
{
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"JSON parse error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"Advisory import error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed + 1,
|
||||
Error = $"Import failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed,
|
||||
Error = errors.Count > 0 ? string.Join("; ", errors.Take(5)) : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ConcelierAdvisoryImportTarget.cs
|
||||
// Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import)
|
||||
// Tasks: SEAL-015 - Apply snapshot advisory content to Concelier database
|
||||
// Description: Adapter implementing IAdvisoryImportTarget for Concelier module.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Determinism;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
@@ -21,9 +7,9 @@ namespace StellaOps.AirGap.Bundle.Services;
|
||||
/// Implements IAdvisoryImportTarget by adapting to Concelier's IAdvisoryRawRepository.
|
||||
/// Parses NDJSON advisory content and upserts records to the advisory database.
|
||||
/// </summary>
|
||||
public sealed class ConcelierAdvisoryImportTarget : IAdvisoryImportTarget
|
||||
public sealed partial class ConcelierAdvisoryImportTarget : IAdvisoryImportTarget
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -40,231 +26,4 @@ public sealed class ConcelierAdvisoryImportTarget : IAdvisoryImportTarget
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ModuleImportResultData> ImportAdvisoriesAsync(
|
||||
AdvisoryImportData data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
if (data.Content.Length == 0)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Failed = 1,
|
||||
Error = "Empty advisory content"
|
||||
};
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse NDJSON content - each line is a complete AdvisoryRawDocument
|
||||
var contentString = Encoding.UTF8.GetString(data.Content);
|
||||
var lines = contentString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<AdvisoryRawDocument>(line.Trim(), JsonOptions);
|
||||
if (document is null)
|
||||
{
|
||||
failed++;
|
||||
errors.Add("Failed to parse advisory line");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure tenant is set correctly
|
||||
var tenantedDocument = document with { Tenant = _tenant };
|
||||
|
||||
var result = await _repository.UpsertAsync(tenantedDocument, cancellationToken);
|
||||
|
||||
if (result.Inserted)
|
||||
{
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"JSON parse error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"Advisory import error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed + 1,
|
||||
Error = $"Import failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed,
|
||||
Error = errors.Count > 0 ? string.Join("; ", errors.Take(5)) : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight in-memory implementation of IAdvisoryRawRepository for air-gap scenarios.
|
||||
/// Used when direct database access is unavailable.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
{
|
||||
private readonly Dictionary<string, AdvisoryRawRecord> _records = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryAdvisoryRawRepository(
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawUpsertResult> UpsertAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
var contentHash = ComputeHash(document);
|
||||
var key = $"{document.Tenant}:{contentHash}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_records.TryGetValue(key, out var existing))
|
||||
{
|
||||
return Task.FromResult(new AdvisoryRawUpsertResult(Inserted: false, Record: existing));
|
||||
}
|
||||
|
||||
var record = new AdvisoryRawRecord(
|
||||
Id: _guidProvider.NewGuid().ToString(),
|
||||
Document: document,
|
||||
IngestedAt: now,
|
||||
CreatedAt: now);
|
||||
|
||||
_records[key] = record;
|
||||
return Task.FromResult(new AdvisoryRawUpsertResult(Inserted: true, Record: record));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var record = _records.Values.FirstOrDefault(r => r.Document.Tenant == tenant && r.Id == id);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var query = _records.Values.Where(r => r.Document.Tenant == options.Tenant);
|
||||
|
||||
if (!options.Vendors.IsEmpty)
|
||||
{
|
||||
query = query.Where(r => options.Vendors.Contains(r.Document.Source.Vendor));
|
||||
}
|
||||
|
||||
if (options.Since.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.IngestedAt >= options.Since.Value);
|
||||
}
|
||||
|
||||
var records = query.Take(options.Limit).ToList();
|
||||
return Task.FromResult(new AdvisoryRawQueryResult(
|
||||
Records: records,
|
||||
NextCursor: records.Count == options.Limit && records.Count > 0 ? records[^1].Id : null,
|
||||
HasMore: records.Count == options.Limit));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> searchValues,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var query = _records.Values.Where(r => r.Document.Tenant == tenant);
|
||||
|
||||
if (searchValues.Count > 0)
|
||||
{
|
||||
query = query.Where(r =>
|
||||
searchValues.Contains(r.Document.AdvisoryKey) ||
|
||||
r.Document.Identifiers.Aliases.Any(a => searchValues.Contains(a)));
|
||||
}
|
||||
|
||||
if (sourceVendors.Count > 0)
|
||||
{
|
||||
query = query.Where(r => sourceVendors.Contains(r.Document.Source.Vendor));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryRawRecord>>(query.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
|
||||
string tenant,
|
||||
DateTimeOffset since,
|
||||
DateTimeOffset until,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var query = _records.Values
|
||||
.Where(r => r.Document.Tenant == tenant && r.IngestedAt >= since && r.IngestedAt <= until);
|
||||
|
||||
if (sourceVendors.Count > 0)
|
||||
{
|
||||
query = query.Where(r => sourceVendors.Contains(r.Document.Source.Vendor));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryRawRecord>>(query.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _records.Count;
|
||||
|
||||
public IEnumerable<AdvisoryRawRecord> GetAllRecords()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _records.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(AdvisoryRawDocument document)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(document);
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(bytes)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class CrlFetcher
|
||||
{
|
||||
private static IReadOnlyList<Uri> ExtractCrlUris(X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.31");
|
||||
if (ext is null)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var reader = new AsnReader(ext.RawData, AsnEncodingRules.DER);
|
||||
var bytes = reader.ReadOctetString();
|
||||
var dpReader = new AsnReader(bytes, AsnEncodingRules.DER);
|
||||
var sequence = dpReader.ReadSequence();
|
||||
|
||||
var uris = new List<Uri>();
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var distributionPoint = sequence.ReadSequence();
|
||||
if (!distributionPoint.HasData)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = distributionPoint.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
var dpName = distributionPoint.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (dpName.HasData)
|
||||
{
|
||||
var nameTag = dpName.PeekTag();
|
||||
if (nameTag.TagClass == TagClass.ContextSpecific && nameTag.TagValue == 0)
|
||||
{
|
||||
var fullName = dpName.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (fullName.HasData)
|
||||
{
|
||||
var names = fullName.ReadSequence();
|
||||
while (names.HasData)
|
||||
{
|
||||
var nameTagValue = names.PeekTag();
|
||||
if (nameTagValue.TagClass == TagClass.ContextSpecific &&
|
||||
nameTagValue.TagValue == 6)
|
||||
{
|
||||
var uriValue = names.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
if (Uri.TryCreate(uriValue, UriKind.Absolute, out var uri))
|
||||
{
|
||||
uris.Add(uri);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
names.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (distributionPoint.HasData)
|
||||
{
|
||||
distributionPoint.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class CrlFetcher
|
||||
{
|
||||
public static CrlFetcher CreateNetworked(HttpClient? client = null)
|
||||
{
|
||||
client ??= _defaultClient;
|
||||
return new CrlFetcher(async (uri, ct) =>
|
||||
{
|
||||
using var response = await client.GetAsync(uri, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<byte[]?> FetchCachedAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
var key = uri.ToString();
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var data = await _fetcher!(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
_cache[key] = data;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
|
||||
using System.Formats.Asn1;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface ICrlFetcher
|
||||
public sealed partial class CrlFetcher : ICrlFetcher
|
||||
{
|
||||
Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class CrlFetcher : ICrlFetcher
|
||||
{
|
||||
private static readonly HttpClient DefaultClient = new();
|
||||
private static readonly HttpClient _defaultClient = new();
|
||||
private readonly Func<Uri, CancellationToken, Task<byte[]?>>? _fetcher;
|
||||
private readonly Dictionary<string, byte[]> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -23,21 +13,6 @@ public sealed class CrlFetcher : ICrlFetcher
|
||||
_fetcher = fetcher;
|
||||
}
|
||||
|
||||
public static CrlFetcher CreateNetworked(HttpClient? client = null)
|
||||
{
|
||||
client ??= DefaultClient;
|
||||
return new CrlFetcher(async (uri, ct) =>
|
||||
{
|
||||
using var response = await client.GetAsync(uri, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default)
|
||||
@@ -65,97 +40,4 @@ public sealed class CrlFetcher : ICrlFetcher
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> FetchCachedAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
var key = uri.ToString();
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var data = await _fetcher!(uri, ct).ConfigureAwait(false);
|
||||
if (data is { Length: > 0 })
|
||||
{
|
||||
_cache[key] = data;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Uri> ExtractCrlUris(X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = certificate.Extensions.Cast<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.31");
|
||||
if (ext is null)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var reader = new AsnReader(ext.RawData, AsnEncodingRules.DER);
|
||||
var bytes = reader.ReadOctetString();
|
||||
var dpReader = new AsnReader(bytes, AsnEncodingRules.DER);
|
||||
var sequence = dpReader.ReadSequence();
|
||||
|
||||
var uris = new List<Uri>();
|
||||
while (sequence.HasData)
|
||||
{
|
||||
var distributionPoint = sequence.ReadSequence();
|
||||
if (!distributionPoint.HasData)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = distributionPoint.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
var dpName = distributionPoint.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (dpName.HasData)
|
||||
{
|
||||
var nameTag = dpName.PeekTag();
|
||||
if (nameTag.TagClass == TagClass.ContextSpecific && nameTag.TagValue == 0)
|
||||
{
|
||||
var fullName = dpName.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
if (fullName.HasData)
|
||||
{
|
||||
var names = fullName.ReadSequence();
|
||||
while (names.HasData)
|
||||
{
|
||||
var nameTagValue = names.PeekTag();
|
||||
if (nameTagValue.TagClass == TagClass.ContextSpecific &&
|
||||
nameTagValue.TagValue == 6)
|
||||
{
|
||||
var uriValue = names.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
if (Uri.TryCreate(uriValue, UriKind.Absolute, out var uri))
|
||||
{
|
||||
uris.Add(uri);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
names.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (distributionPoint.HasData)
|
||||
{
|
||||
distributionPoint.ReadEncodedValue();
|
||||
}
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class ExcititorVexImportTarget
|
||||
{
|
||||
private static VexRawDocument BuildDocument(VexStatementDto statement, string line, VexImportData data)
|
||||
{
|
||||
var contentBytes = Encoding.UTF8.GetBytes(line);
|
||||
var digest = ComputeDigest(contentBytes);
|
||||
|
||||
return new VexRawDocument(
|
||||
ProviderId: data.SourceId,
|
||||
Format: DetectFormat(statement),
|
||||
SourceUri: statement.SourceUri ?? new Uri($"urn:stellaops:airgap:vex:{digest}"),
|
||||
RetrievedAt: data.SnapshotAt,
|
||||
Digest: digest,
|
||||
Content: contentBytes,
|
||||
Metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("importSource", "airgap-snapshot")
|
||||
.Add("snapshotAt", data.SnapshotAt.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class ExcititorVexImportTarget
|
||||
{
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static VexDocumentFormat DetectFormat(VexStatementDto statement)
|
||||
{
|
||||
// Detect format from statement structure
|
||||
if (!string.IsNullOrEmpty(statement.Context))
|
||||
{
|
||||
if (statement.Context.Contains("openvex", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.OpenVex;
|
||||
if (statement.Context.Contains("csaf", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.Csaf;
|
||||
if (statement.Context.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.CycloneDx;
|
||||
}
|
||||
|
||||
// Default to OpenVEX
|
||||
return VexDocumentFormat.OpenVex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed partial class ExcititorVexImportTarget
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ModuleImportResultData> ImportVexStatementsAsync(
|
||||
VexImportData data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
if (data.Content.Length == 0)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Failed = 1,
|
||||
Error = "Empty VEX content"
|
||||
};
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse NDJSON content - each line is a VEX statement
|
||||
var contentString = Encoding.UTF8.GetString(data.Content);
|
||||
var lines = contentString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var statement = JsonSerializer.Deserialize<VexStatementDto>(line.Trim(), _jsonOptions);
|
||||
if (statement is null)
|
||||
{
|
||||
failed++;
|
||||
errors.Add("Failed to parse VEX statement line");
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = BuildDocument(statement, line.Trim(), data);
|
||||
|
||||
await _sink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
created++;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"JSON parse error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"VEX import error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed + 1,
|
||||
Error = $"Import failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed,
|
||||
Error = errors.Count > 0 ? string.Join("; ", errors.Take(5)) : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcititorVexImportTarget.cs
|
||||
// Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import)
|
||||
// Tasks: SEAL-016 - Apply snapshot VEX content to Excititor database
|
||||
// Description: Adapter implementing IVexImportTarget for Excititor module.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
@@ -22,9 +8,9 @@ namespace StellaOps.AirGap.Bundle.Services;
|
||||
/// Implements IVexImportTarget by adapting to Excititor's IVexRawDocumentSink.
|
||||
/// Parses NDJSON VEX statement content and stores records to the VEX database.
|
||||
/// </summary>
|
||||
public sealed class ExcititorVexImportTarget : IVexImportTarget
|
||||
public sealed partial class ExcititorVexImportTarget : IVexImportTarget
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -40,227 +26,4 @@ public sealed class ExcititorVexImportTarget : IVexImportTarget
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ModuleImportResultData> ImportVexStatementsAsync(
|
||||
VexImportData data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
if (data.Content.Length == 0)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Failed = 1,
|
||||
Error = "Empty VEX content"
|
||||
};
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse NDJSON content - each line is a VEX statement
|
||||
var contentString = Encoding.UTF8.GetString(data.Content);
|
||||
var lines = contentString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var statement = JsonSerializer.Deserialize<VexStatementDto>(line.Trim(), JsonOptions);
|
||||
if (statement is null)
|
||||
{
|
||||
failed++;
|
||||
errors.Add("Failed to parse VEX statement line");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to VexRawDocument
|
||||
var contentBytes = Encoding.UTF8.GetBytes(line.Trim());
|
||||
var digest = ComputeDigest(contentBytes);
|
||||
|
||||
var document = new VexRawDocument(
|
||||
ProviderId: data.SourceId,
|
||||
Format: DetectFormat(statement),
|
||||
SourceUri: statement.SourceUri ?? new Uri($"urn:stellaops:airgap:vex:{digest}"),
|
||||
RetrievedAt: data.SnapshotAt,
|
||||
Digest: digest,
|
||||
Content: contentBytes,
|
||||
Metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("importSource", "airgap-snapshot")
|
||||
.Add("snapshotAt", data.SnapshotAt.ToString("O", CultureInfo.InvariantCulture)));
|
||||
|
||||
await _sink.StoreAsync(document, cancellationToken);
|
||||
created++;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"JSON parse error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"VEX import error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed + 1,
|
||||
Error = $"Import failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
return new ModuleImportResultData
|
||||
{
|
||||
Created = created,
|
||||
Updated = updated,
|
||||
Failed = failed,
|
||||
Error = errors.Count > 0 ? string.Join("; ", errors.Take(5)) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static VexDocumentFormat DetectFormat(VexStatementDto statement)
|
||||
{
|
||||
// Detect format from statement structure
|
||||
if (!string.IsNullOrEmpty(statement.Context))
|
||||
{
|
||||
if (statement.Context.Contains("openvex", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.OpenVex;
|
||||
if (statement.Context.Contains("csaf", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.Csaf;
|
||||
if (statement.Context.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
return VexDocumentFormat.CycloneDx;
|
||||
}
|
||||
|
||||
// Default to OpenVEX
|
||||
return VexDocumentFormat.OpenVex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight in-memory implementation of IVexRawDocumentSink for air-gap scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryVexRawDocumentSink : IVexRawDocumentSink, IVexRawStore
|
||||
{
|
||||
private readonly Dictionary<string, VexRawRecord> _records = new();
|
||||
private readonly string _tenant;
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryVexRawDocumentSink(
|
||||
string tenant = "default",
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_tenant = tenant;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_records.ContainsKey(document.Digest))
|
||||
{
|
||||
_records[document.Digest] = new VexRawRecord(
|
||||
Digest: document.Digest,
|
||||
Tenant: _tenant,
|
||||
ProviderId: document.ProviderId,
|
||||
Format: document.Format,
|
||||
SourceUri: document.SourceUri,
|
||||
RetrievedAt: document.RetrievedAt,
|
||||
Metadata: document.Metadata,
|
||||
Content: document.Content,
|
||||
InlineContent: true,
|
||||
RecordedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<VexRawRecord?> FindByDigestAsync(string digest, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_records.TryGetValue(digest, out var record);
|
||||
return ValueTask.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<VexRawDocumentPage> QueryAsync(VexRawQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _records.Values
|
||||
.Where(r => r.Tenant == query.Tenant)
|
||||
.Where(r => query.ProviderIds.Count == 0 || query.ProviderIds.Contains(r.ProviderId))
|
||||
.Where(r => query.Digests.Count == 0 || query.Digests.Contains(r.Digest))
|
||||
.Where(r => query.Formats.Count == 0 || query.Formats.Contains(r.Format))
|
||||
.Where(r => !query.Since.HasValue || r.RetrievedAt >= query.Since.Value)
|
||||
.Where(r => !query.Until.HasValue || r.RetrievedAt <= query.Until.Value)
|
||||
.Take(query.Limit)
|
||||
.Select(r => new VexRawDocumentSummary(
|
||||
r.Digest,
|
||||
r.ProviderId,
|
||||
r.Format,
|
||||
r.SourceUri,
|
||||
r.RetrievedAt,
|
||||
r.InlineContent,
|
||||
r.Metadata))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult(new VexRawDocumentPage(
|
||||
items,
|
||||
NextCursor: items.Count == query.Limit && items.Count > 0
|
||||
? new VexRawCursor(items[^1].RetrievedAt, items[^1].Digest)
|
||||
: null,
|
||||
HasMore: items.Count == query.Limit));
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _records.Count;
|
||||
|
||||
public IEnumerable<VexRawRecord> GetAllRecords()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _records.Values.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserializing VEX statements from NDJSON.
|
||||
/// </summary>
|
||||
internal sealed record VexStatementDto
|
||||
{
|
||||
public string? Context { get; init; }
|
||||
public string? Id { get; init; }
|
||||
public string? Vulnerability { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? Impact { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public Uri? SourceUri { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public ImmutableArray<string> Products { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Target interface for importing advisories (SEAL-015).
|
||||
/// Implemented by Concelier module.
|
||||
/// </summary>
|
||||
public interface IAdvisoryImportTarget
|
||||
{
|
||||
Task<ModuleImportResultData> ImportAdvisoriesAsync(
|
||||
AdvisoryImportData data,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
Task<Models.BundleManifest> BuildAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface ICrlFetcher
|
||||
{
|
||||
Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides unique identifiers. Inject to enable deterministic testing.
|
||||
/// </summary>
|
||||
public interface IGuidProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new unique identifier.
|
||||
/// </summary>
|
||||
Guid NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for knowledge snapshot importing.
|
||||
/// </summary>
|
||||
public interface IKnowledgeSnapshotImporter
|
||||
{
|
||||
Task<SnapshotImportResult> ImportAsync(
|
||||
SnapshotImportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public interface IOcspResponseFetcher
|
||||
{
|
||||
Task<IReadOnlyList<TsaRevocationBlob>> FetchAsync(
|
||||
IReadOnlyList<X509Certificate2> certificateChain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Target interface for importing policies (SEAL-017).
|
||||
/// Implemented by Policy module.
|
||||
/// </summary>
|
||||
public interface IPolicyImportTarget
|
||||
{
|
||||
Task<ModuleImportResultData> ImportPolicyAsync(
|
||||
PolicyImportData data,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for importing policy packs from air-gap snapshots.
|
||||
/// </summary>
|
||||
public interface IPolicyPackImportStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an imported policy pack by content digest.
|
||||
/// </summary>
|
||||
Task<ImportedPolicyPack?> FindByDigestAsync(string tenantId, string digest, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Saves an imported policy pack.
|
||||
/// </summary>
|
||||
Task SaveAsync(ImportedPolicyPack pack, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all imported policy packs for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ImportedPolicyPack>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot bundle reading.
|
||||
/// </summary>
|
||||
public interface ISnapshotBundleReader
|
||||
{
|
||||
Task<SnapshotBundleReadResult> ReadAsync(
|
||||
SnapshotBundleReadRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot bundle writing.
|
||||
/// </summary>
|
||||
public interface ISnapshotBundleWriter
|
||||
{
|
||||
Task<SnapshotBundleResult> WriteAsync(
|
||||
SnapshotBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for manifest signing operations.
|
||||
/// </summary>
|
||||
public interface ISnapshotManifestSigner
|
||||
{
|
||||
Task<ManifestSignatureResult> SignAsync(
|
||||
ManifestSigningRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ManifestVerificationResult> VerifyAsync(
|
||||
ManifestVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for time anchor operations.
|
||||
/// </summary>
|
||||
public interface ITimeAnchorService
|
||||
{
|
||||
Task<TimeAnchorResult> CreateAnchorAsync(
|
||||
TimeAnchorRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorValidationResult> ValidateAnchorAsync(
|
||||
TimeAnchorContent anchor,
|
||||
TimeAnchorValidationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user