work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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; }
}
}