- Implemented `run-scanner-ci.sh` to build and run tests for the Scanner solution with a warmed NuGet cache. - Created `excititor-vex-traces.json` dashboard for monitoring Excititor VEX observations. - Added Docker Compose configuration for the OTLP span sink in `docker-compose.spansink.yml`. - Configured OpenTelemetry collector in `otel-spansink.yaml` to receive and process traces. - Developed `run-spansink.sh` script to run the OTLP span sink for Excititor traces. - Introduced `FileSystemRiskBundleObjectStore` for storing risk bundle artifacts in the filesystem. - Built `RiskBundleBuilder` for creating risk bundles with associated metadata and providers. - Established `RiskBundleJob` to execute the risk bundle creation and storage process. - Defined models for risk bundle inputs, entries, and manifests in `RiskBundleModels.cs`. - Implemented signing functionality for risk bundle manifests with `HmacRiskBundleManifestSigner`. - Created unit tests for `RiskBundleBuilder`, `RiskBundleJob`, and signing functionality to ensure correctness. - Added filesystem artifact reader tests to validate manifest parsing and artifact listing. - Included test manifests for egress scenarios in the task runner tests. - Developed timeline query service tests to verify tenant and event ID handling.
242 lines
8.5 KiB
C#
242 lines
8.5 KiB
C#
using System.Buffers.Binary;
|
|
using System.Collections.Immutable;
|
|
using System.Formats.Tar;
|
|
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace StellaOps.ExportCenter.RiskBundles;
|
|
|
|
public sealed class RiskBundleBuilder
|
|
{
|
|
private const string ManifestVersion = "1";
|
|
private static readonly UnixFileMode DefaultFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
|
private static readonly DateTimeOffset FixedTimestamp = new(2024, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
public RiskBundleBuildResult Build(RiskBundleBuildRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
if (request.BundleId == Guid.Empty)
|
|
{
|
|
throw new ArgumentException("Bundle identifier must be provided.", nameof(request));
|
|
}
|
|
|
|
if (request.Providers is not { Count: > 0 })
|
|
{
|
|
throw new ArgumentException("At least one provider input is required.", nameof(request));
|
|
}
|
|
|
|
var providers = CollectProviders(request, cancellationToken);
|
|
var manifest = BuildManifest(request.BundleId, providers);
|
|
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
|
var rootHash = ComputeSha256(manifestJson);
|
|
|
|
var bundleStream = CreateBundleStream(
|
|
providers,
|
|
request.ManifestFileName,
|
|
manifestJson,
|
|
request.BundleFileName ?? "risk-bundle.tar.gz");
|
|
|
|
bundleStream.Position = 0;
|
|
|
|
return new RiskBundleBuildResult(manifest, manifestJson, rootHash, bundleStream);
|
|
}
|
|
|
|
private static List<RiskBundleProviderEntry> CollectProviders(RiskBundleBuildRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var entries = new List<RiskBundleProviderEntry>(request.Providers.Count);
|
|
|
|
foreach (var provider in request.Providers.OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (provider is null)
|
|
{
|
|
throw new ArgumentException("Provider list cannot contain null entries.", nameof(request));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(provider.ProviderId))
|
|
{
|
|
throw new ArgumentException("ProviderId cannot be empty.", nameof(provider.ProviderId));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(provider.SourcePath))
|
|
{
|
|
throw new ArgumentException("SourcePath cannot be empty.", nameof(provider.SourcePath));
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(provider.SourcePath);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
if (provider.Optional && request.AllowMissingOptional)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
throw new FileNotFoundException($"Provider source file '{fullPath}' not found.", fullPath);
|
|
}
|
|
|
|
var sha256 = ComputeSha256FromFile(fullPath);
|
|
var size = new FileInfo(fullPath).Length;
|
|
var bundlePath = $"providers/{provider.ProviderId}/snapshot";
|
|
|
|
entries.Add(new RiskBundleProviderEntry(
|
|
provider.ProviderId,
|
|
provider.Source,
|
|
provider.SnapshotDate,
|
|
sha256,
|
|
size,
|
|
provider.Optional,
|
|
bundlePath,
|
|
fullPath,
|
|
SignaturePath: null));
|
|
}
|
|
|
|
if (entries.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No provider artefacts collected. Provide at least one valid provider input.");
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
private static RiskBundleManifest BuildManifest(Guid bundleId, List<RiskBundleProviderEntry> providers)
|
|
{
|
|
var inputsHash = ComputeInputsHash(providers);
|
|
return new RiskBundleManifest(
|
|
ManifestVersion,
|
|
bundleId,
|
|
FixedTimestamp,
|
|
providers.ToImmutableArray(),
|
|
inputsHash);
|
|
}
|
|
|
|
private static string ComputeInputsHash(IEnumerable<RiskBundleProviderEntry> providers)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
var builder = new StringBuilder();
|
|
foreach (var entry in providers.OrderBy(e => e.ProviderId, StringComparer.Ordinal))
|
|
{
|
|
builder.Append(entry.ProviderId)
|
|
.Append('\0')
|
|
.Append(entry.Sha256)
|
|
.Append('\0')
|
|
.Append(entry.Source)
|
|
.Append('\0')
|
|
.Append(entry.Optional ? '1' : '0')
|
|
.Append('\0')
|
|
.Append(entry.SnapshotDate?.ToString("yyyy-MM-dd") ?? string.Empty)
|
|
.Append('\0');
|
|
}
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
|
var hash = sha.ComputeHash(bytes);
|
|
return ToHex(hash);
|
|
}
|
|
|
|
private static string ComputeSha256(string content)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(content);
|
|
using var sha = SHA256.Create();
|
|
var hash = sha.ComputeHash(bytes);
|
|
return ToHex(hash);
|
|
}
|
|
|
|
private static string ComputeSha256FromFile(string filePath)
|
|
{
|
|
using var stream = File.OpenRead(filePath);
|
|
using var sha = SHA256.Create();
|
|
var hash = sha.ComputeHash(stream);
|
|
return ToHex(hash);
|
|
}
|
|
|
|
private static string ToHex(ReadOnlySpan<byte> bytes)
|
|
{
|
|
Span<byte> hex = stackalloc byte[bytes.Length * 2];
|
|
for (var i = 0; i < bytes.Length; i++)
|
|
{
|
|
var b = bytes[i];
|
|
hex[i * 2] = GetHexValue(b / 16);
|
|
hex[i * 2 + 1] = GetHexValue(b % 16);
|
|
}
|
|
return Encoding.ASCII.GetString(hex);
|
|
}
|
|
|
|
private static byte GetHexValue(int i) => (byte)(i < 10 ? i + 48 : i - 10 + 97);
|
|
|
|
private static MemoryStream CreateBundleStream(
|
|
IReadOnlyList<RiskBundleProviderEntry> providers,
|
|
string manifestFileName,
|
|
string manifestJson,
|
|
string bundleFileName)
|
|
{
|
|
var stream = new MemoryStream();
|
|
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
|
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
|
{
|
|
WriteTextEntry(tar, $"manifests/{manifestFileName}", manifestJson, DefaultFileMode);
|
|
|
|
foreach (var provider in providers)
|
|
{
|
|
WriteProviderFile(tar, provider);
|
|
}
|
|
}
|
|
|
|
ApplyDeterministicGzipHeader(stream);
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
private static void WriteProviderFile(TarWriter writer, RiskBundleProviderEntry entry)
|
|
{
|
|
var filePath = entry.SourceFilePath ?? throw new InvalidOperationException("Source file path missing for provider entry.");
|
|
using var dataStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan);
|
|
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.BundlePath)
|
|
{
|
|
Mode = DefaultFileMode,
|
|
ModificationTime = FixedTimestamp,
|
|
DataStream = dataStream
|
|
};
|
|
writer.WriteEntry(tarEntry);
|
|
}
|
|
|
|
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(content);
|
|
using var dataStream = new MemoryStream(bytes);
|
|
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
|
{
|
|
Mode = mode,
|
|
ModificationTime = FixedTimestamp,
|
|
DataStream = dataStream
|
|
};
|
|
writer.WriteEntry(entry);
|
|
}
|
|
|
|
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
|
{
|
|
if (stream.Length < 10)
|
|
{
|
|
throw new InvalidOperationException("GZip header not fully written for risk bundle.");
|
|
}
|
|
|
|
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
|
Span<byte> buffer = stackalloc byte[4];
|
|
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
|
|
|
var originalPosition = stream.Position;
|
|
stream.Position = 4;
|
|
stream.Write(buffer);
|
|
stream.Position = originalPosition;
|
|
}
|
|
}
|