work
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, deterministic writer for Concelier air-gap bundles. Intended as the
|
||||
/// first increment for CONCELIER-AIRGAP-56-001; produces a stable NDJSON file
|
||||
/// from link-not-merge cache items without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class AirgapBundleBuilder
|
||||
{
|
||||
private const string BundleFileName = "concelier-airgap.ndjson";
|
||||
private const string ManifestFileName = "bundle.manifest.json";
|
||||
private const string EntryTraceFileName = "bundle.entry-trace.json";
|
||||
|
||||
public async Task<AirgapBundleResult> BuildAsync(
|
||||
IEnumerable<string> cacheItems,
|
||||
string outputDirectory,
|
||||
DateTimeOffset? createdUtc = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (cacheItems is null) throw new ArgumentNullException(nameof(cacheItems));
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory)) throw new ArgumentException("Output directory is required", nameof(outputDirectory));
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var ordered = cacheItems
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var bundlePath = Path.Combine(outputDirectory, BundleFileName);
|
||||
await WriteNdjsonAsync(bundlePath, ordered, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleSha = ComputeSha256FromPath(bundlePath);
|
||||
|
||||
var entries = ordered
|
||||
.Select((value, index) => new AirgapBundleEntry
|
||||
{
|
||||
LineNumber = index + 1,
|
||||
Sha256 = ComputeSha256(value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var manifestCreated = createdUtc ?? DateTimeOffset.UnixEpoch;
|
||||
var manifest = new AirgapBundleManifest
|
||||
{
|
||||
Items = ordered,
|
||||
Entries = entries,
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedUtc = manifestCreated,
|
||||
Count = ordered.Length
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(outputDirectory, ManifestFileName);
|
||||
await WriteManifest(manifestPath, manifest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entryTracePath = Path.Combine(outputDirectory, EntryTraceFileName);
|
||||
await WriteEntryTrace(entryTracePath, entries, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AirgapBundleResult(bundlePath, manifestPath, entryTracePath, bundleSha, ordered.Length);
|
||||
}
|
||||
|
||||
private static async Task WriteNdjsonAsync(string bundlePath, IReadOnlyList<string> orderedItems, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
foreach (var item in orderedItems)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteLineAsync(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteManifest(string manifestPath, AirgapBundleManifest manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteEntryTrace(string entryTracePath, IReadOnlyList<AirgapBundleEntry> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(entryTracePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromPath(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleResult(string BundlePath, string ManifestPath, string EntryTracePath, string Sha256, int ItemCount);
|
||||
|
||||
public sealed record AirgapBundleManifest
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public string[] Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public AirgapBundleEntry[] Entries { get; init; } = Array.Empty<AirgapBundleEntry>();
|
||||
|
||||
[JsonPropertyName("bundleSha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdUtc")]
|
||||
public DateTimeOffset CreatedUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleEntry
|
||||
{
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int LineNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
public sealed class AirgapBundleValidator
|
||||
{
|
||||
public async Task<AirgapBundleValidationResult> ValidateAsync(
|
||||
string bundlePath,
|
||||
string manifestPath,
|
||||
string? entryTracePath = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
errors.Add($"Bundle file missing: {bundlePath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add($"Manifest file missing: {manifestPath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
AirgapBundleManifest? manifest = null;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Manifest parse error: {ex.Message}");
|
||||
}
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
var bundleSha = ComputeSha256FromFile(bundlePath);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!string.Equals(bundleSha, manifest.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add("Bundle hash mismatch");
|
||||
}
|
||||
|
||||
if (manifest.Count != lines.Length)
|
||||
{
|
||||
errors.Add($"Manifest count {manifest.Count} != bundle lines {lines.Length}");
|
||||
}
|
||||
|
||||
var ordered = lines.ToArray();
|
||||
if (!manifest.Items.SequenceEqual(ordered))
|
||||
{
|
||||
errors.Add("Manifest items differ from bundle payload");
|
||||
}
|
||||
|
||||
// If entry trace exists (either provided or embedded in manifest), verify per-line hashes.
|
||||
AirgapBundleEntry[] entries = manifest.Entries ?? Array.Empty<AirgapBundleEntry>();
|
||||
if (!string.IsNullOrWhiteSpace(entryTracePath) && File.Exists(entryTracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var traceJson = await File.ReadAllTextAsync(entryTracePath!, cancellationToken).ConfigureAwait(false);
|
||||
var traceEntries = JsonSerializer.Deserialize<AirgapBundleEntry[]>(traceJson);
|
||||
if (traceEntries is not null)
|
||||
{
|
||||
entries = traceEntries;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Entry trace parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
if (entries.Length != lines.Length)
|
||||
{
|
||||
errors.Add($"Entry trace length {entries.Length} != bundle lines {lines.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var expectedHash = ComputeSha256(lines[i]);
|
||||
if (!string.Equals(entries[i].Sha256, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Entry trace hash mismatch at line {i + 1}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AirgapBundleValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromFile(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleValidationResult(bool IsValid, IReadOnlyList<string> Errors);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record VerifyAttestationRequest(
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? PipelineVersion);
|
||||
|
||||
public readonly record struct EvidencePathResolutionResult(
|
||||
bool IsValid,
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? Error,
|
||||
string? ErrorDetails)
|
||||
{
|
||||
public static EvidencePathResolutionResult Valid(string bundlePath, string manifestPath, string? transparencyPath) =>
|
||||
new(true, bundlePath, manifestPath, transparencyPath, null, null);
|
||||
|
||||
public static EvidencePathResolutionResult Invalid(string error, string? details = null) =>
|
||||
new(false, null, null, null, error, details);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record EvidenceBatchRequest(
|
||||
IReadOnlyCollection<EvidenceBatchItemRequest> Items,
|
||||
int? ObservationLimit,
|
||||
int? LinksetLimit);
|
||||
|
||||
public sealed record EvidenceBatchItemRequest(
|
||||
string? ComponentId,
|
||||
IReadOnlyCollection<string>? Purls,
|
||||
IReadOnlyCollection<string>? Aliases);
|
||||
|
||||
public sealed record EvidenceBatchItemResponse(
|
||||
string ComponentId,
|
||||
IReadOnlyCollection<AdvisoryObservation> Observations,
|
||||
IReadOnlyCollection<AdvisoryLinkset> Linksets,
|
||||
bool HasMore,
|
||||
DateTimeOffset RetrievedAt);
|
||||
|
||||
public sealed record EvidenceBatchResponse(
|
||||
IReadOnlyCollection<EvidenceBatchItemResponse> Items);
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record EvidenceSnapshotResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("manifestHash")] string ManifestHash,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
|
||||
public sealed record AttestationStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("claims")] AttestationClaims Claims,
|
||||
[property: JsonPropertyName("bundlePath")] string BundlePath,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record IncidentUpsertRequest(
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("cooldownMinutes")] int? CooldownMinutes);
|
||||
|
||||
public sealed record IncidentStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("activatedAt")] string ActivatedAt,
|
||||
[property: JsonPropertyName("cooldownUntil")] string CooldownUntil,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion,
|
||||
[property: JsonPropertyName("active")] bool Active);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal static class IncidentFileStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static string GetIncidentFilePath(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceOptions);
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
ArgumentNullException.ThrowIfNull(advisoryKey);
|
||||
|
||||
var root = evidenceOptions.RootAbsolute ?? evidenceOptions.Root ?? string.Empty;
|
||||
return Path.Combine(root, tenant.Trim(), advisoryKey.Trim(), "incident.json");
|
||||
}
|
||||
|
||||
public static async Task WriteAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
string reason,
|
||||
int cooldownMinutes,
|
||||
string? pipelineVersion,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var activatedAt = now.ToUniversalTime();
|
||||
var cooldownUntil = activatedAt.AddMinutes(cooldownMinutes);
|
||||
|
||||
var payload = new IncidentFile
|
||||
{
|
||||
AdvisoryKey = advisoryKey.Trim(),
|
||||
Tenant = tenant.Trim(),
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
|
||||
ActivatedAt = activatedAt,
|
||||
CooldownUntil = cooldownUntil,
|
||||
PipelineVersion = pipelineVersion,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<IncidentStatusResponse?> ReadAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var payload = await JsonSerializer.DeserializeAsync<IncidentFile>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var active = payload.CooldownUntil > now.ToUniversalTime();
|
||||
return new IncidentStatusResponse(
|
||||
payload.AdvisoryKey,
|
||||
payload.Tenant,
|
||||
payload.Reason,
|
||||
payload.ActivatedAt.ToUniversalTime().ToString("O"),
|
||||
payload.CooldownUntil.ToUniversalTime().ToString("O"),
|
||||
payload.PipelineVersion,
|
||||
active);
|
||||
}
|
||||
|
||||
public static Task DeleteAsync(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record IncidentFile
|
||||
{
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = "unspecified";
|
||||
public DateTimeOffset ActivatedAt { get; init; }
|
||||
public DateTimeOffset CooldownUntil { get; init; }
|
||||
public string? PipelineVersion { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user