sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -26,6 +26,7 @@ public sealed class AirGapTelemetry
private readonly Queue<(string Tenant, long Sequence)> _evictionQueue = new();
private readonly object _cacheLock = new();
private readonly int _maxTenantEntries;
private readonly int _maxEvictionQueueSize;
private long _sequence;
private readonly ObservableGauge<long> _anchorAgeGauge;
@@ -36,6 +37,8 @@ public sealed class AirGapTelemetry
{
var maxEntries = options.Value.MaxTenantEntries;
_maxTenantEntries = maxEntries > 0 ? maxEntries : 1000;
// Bound eviction queue to 3x tenant entries to prevent unbounded memory growth
_maxEvictionQueueSize = _maxTenantEntries * 3;
_logger = logger;
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
@@ -146,6 +149,7 @@ public sealed class AirGapTelemetry
private void TrimCache()
{
// Evict stale tenant entries when cache is over limit
while (_latestByTenant.Count > _maxTenantEntries && _evictionQueue.Count > 0)
{
var (tenant, sequence) = _evictionQueue.Dequeue();
@@ -154,6 +158,19 @@ public sealed class AirGapTelemetry
_latestByTenant.TryRemove(tenant, out _);
}
}
// Trim eviction queue to prevent unbounded memory growth
// Discard stale entries that no longer match current tenant state
while (_evictionQueue.Count > _maxEvictionQueueSize)
{
var (tenant, sequence) = _evictionQueue.Dequeue();
// Only actually evict if this is still the current entry for the tenant
if (_latestByTenant.TryGetValue(tenant, out var entry) && entry.Sequence == sequence)
{
_latestByTenant.TryRemove(tenant, out _);
}
// Otherwise the queue entry is stale and can be discarded
}
}
private readonly record struct TelemetryEntry(long Age, long Budget, long Sequence);

View File

@@ -209,20 +209,19 @@ public sealed record EvidenceGraphMetadata
/// </summary>
public sealed class EvidenceGraphSerializer
{
// Use default escaping for deterministic output (no UnsafeRelaxedJsonEscaping)
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions PrettySerializerOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>

View File

@@ -4,6 +4,7 @@
// Part of Step 3: Normalization
// =============================================================================
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -225,7 +226,9 @@ public static class JsonNormalizer
char.IsDigit(value[3]) &&
value[4] == '-')
{
return DateTimeOffset.TryParse(value, out _);
// Use InvariantCulture for deterministic parsing
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out _);
}
return false;

View File

@@ -16,11 +16,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class CycloneDxParser : ISbomParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public SbomFormat DetectFormat(string filePath)
@@ -87,7 +86,7 @@ public sealed class CycloneDxParser : ISbomParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Validate bomFormat

View File

@@ -14,11 +14,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class DsseAttestationParser : IAttestationParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public bool IsAttestation(string filePath)
@@ -92,7 +91,7 @@ public sealed class DsseAttestationParser : IAttestationParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Parse DSSE envelope

View File

@@ -11,7 +11,7 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// <summary>
/// Transforms SBOMs into a canonical form for deterministic hashing and comparison.
/// Applies normalization rules per advisory §5 step 3.
/// Applies normalization rules per advisory section 5 step 3.
/// </summary>
public sealed class SbomNormalizer
{

View File

@@ -15,11 +15,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class SpdxParser : ISbomParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public SbomFormat DetectFormat(string filePath)
@@ -84,7 +83,7 @@ public sealed class SpdxParser : ISbomParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Validate spdxVersion

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
@@ -14,7 +15,9 @@ internal static class DssePreAuthenticationEncoding
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
// Use InvariantCulture to ensure ASCII decimal digits per DSSE spec
var header = string.Create(CultureInfo.InvariantCulture,
$"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ");
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];

View File

@@ -128,7 +128,14 @@ public sealed class RuleBundleValidator
var digestErrors = new List<string>();
foreach (var file in manifest.Files)
{
var filePath = Path.Combine(request.BundleDirectory, file.Name);
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(file.Name))
{
digestErrors.Add($"unsafe-path:{file.Name}");
continue;
}
var filePath = PathValidation.SafeCombine(request.BundleDirectory, file.Name);
if (!File.Exists(filePath))
{
digestErrors.Add($"file-missing:{file.Name}");
@@ -345,3 +352,81 @@ internal sealed class RuleBundleFileEntry
public string Digest { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}
/// <summary>
/// Utility methods for path validation and security.
/// </summary>
internal static class PathValidation
{
/// <summary>
/// Validates that a relative path does not escape the bundle root.
/// </summary>
public static bool IsSafeRelativePath(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return false;
}
// Check for absolute paths
if (Path.IsPathRooted(relativePath))
{
return false;
}
// Check for path traversal sequences
var normalized = relativePath.Replace('\\', '/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
var depth = 0;
foreach (var segment in segments)
{
if (segment == "..")
{
depth--;
if (depth < 0)
{
return false;
}
}
else if (segment != ".")
{
depth++;
}
}
// Also check for null bytes
if (relativePath.Contains('\0'))
{
return false;
}
return true;
}
/// <summary>
/// Combines a root path with a relative path, validating that the result does not escape the root.
/// </summary>
public static string SafeCombine(string rootPath, string relativePath)
{
if (!IsSafeRelativePath(relativePath))
{
throw new ArgumentException(
$"Invalid relative path: path traversal or absolute path detected in '{relativePath}'",
nameof(relativePath));
}
var combined = Path.GetFullPath(Path.Combine(rootPath, relativePath));
var normalizedRoot = Path.GetFullPath(rootPath);
// Ensure the combined path starts with the root path
if (!combined.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Path '{relativePath}' escapes root directory",
nameof(relativePath));
}
return combined;
}
}

View File

@@ -8,6 +8,8 @@ public sealed class TimeTelemetry
{
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
private const int MaxEntries = 1024;
// Bound eviction queue to 3x max entries to prevent unbounded memory growth
private const int MaxEvictionQueueSize = MaxEntries * 3;
private readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<string> _evictionQueue = new();
@@ -71,10 +73,20 @@ public sealed class TimeTelemetry
private void TrimCache()
{
// Evict tenant entries when cache is over limit
while (_latest.Count > MaxEntries && _evictionQueue.TryDequeue(out var candidate))
{
_latest.TryRemove(candidate, out _);
}
// Trim eviction queue to prevent unbounded memory growth
// Discard stale entries that may no longer be in the cache
while (_evictionQueue.Count > MaxEvictionQueueSize && _evictionQueue.TryDequeue(out var stale))
{
// If the tenant is still in cache, try to remove it
// (this helps when we have many updates to the same tenant)
_latest.TryRemove(stale, out _);
}
}
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);

View File

@@ -195,7 +195,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -250,7 +258,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -305,7 +321,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -349,9 +373,52 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
private static async Task ExtractBundleAsync(string bundlePath, string targetDir, CancellationToken ct)
{
var normalizedTargetDir = Path.GetFullPath(targetDir);
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzipStream, targetDir, overwriteFiles: true, ct);
await using var tarReader = new TarReader(gzipStream, leaveOpen: false);
while (await tarReader.GetNextEntryAsync(copyData: true, ct) is { } entry)
{
if (string.IsNullOrEmpty(entry.Name))
{
continue;
}
// Validate entry path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.Name))
{
throw new InvalidOperationException($"Unsafe tar entry path detected: {entry.Name}");
}
var destinationPath = Path.GetFullPath(Path.Combine(normalizedTargetDir, entry.Name));
// Verify the path is within the target directory
if (!destinationPath.StartsWith(normalizedTargetDir, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Tar entry path escapes target directory: {entry.Name}");
}
// Create directory if needed
var entryDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(entryDir))
{
Directory.CreateDirectory(entryDir);
}
// Extract based on entry type
if (entry.EntryType == TarEntryType.Directory)
{
Directory.CreateDirectory(destinationPath);
}
else if (entry.EntryType == TarEntryType.RegularFile ||
entry.EntryType == TarEntryType.V7RegularFile)
{
await entry.ExtractToFileAsync(destinationPath, overwrite: true, ct);
}
// Skip symbolic links and other special entry types for security
}
}
private sealed class ModuleImportResult

View File

@@ -5,6 +5,7 @@
// Description: Signs snapshot manifests using DSSE format for integrity verification.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -196,8 +197,9 @@ public sealed class SnapshotManifestSigner : ISnapshotManifestSigner
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefixBytes = Encoding.UTF8.GetBytes(PreAuthenticationEncodingPrefix);
var typeLenStr = typeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
// Use InvariantCulture to ensure ASCII decimal digits per DSSE spec
var typeLenStr = typeBytes.Length.ToString(CultureInfo.InvariantCulture);
var payloadLenStr = payload.Length.ToString(CultureInfo.InvariantCulture);
var totalLen = prefixBytes.Length + 1 +
typeLenStr.Length + 1 +

View File

@@ -178,39 +178,15 @@ public sealed class TimeAnchorService : ITimeAnchorService
CancellationToken cancellationToken)
{
// Roughtime is a cryptographic time synchronization protocol
// This is a placeholder implementation - full implementation would use a Roughtime client
// Full implementation requires a Roughtime client library
var serverUrl = request.Source?["roughtime:".Length..] ?? "roughtime.cloudflare.com:2003";
// For now, fallback to local with indication of intended source
var anchorTime = _timeProvider.GetUtcNow();
var anchorData = new RoughtimeAnchorData
{
Timestamp = anchorTime,
Server = serverUrl,
Midpoint = anchorTime.ToUnixTimeSeconds(),
Radius = 1000000, // 1 second radius in microseconds
Nonce = _guidProvider.NewGuid().ToString("N"),
MerkleRoot = request.MerkleRoot
};
var anchorJson = JsonSerializer.Serialize(anchorData, JsonOptions);
var anchorBytes = Encoding.UTF8.GetBytes(anchorJson);
var tokenDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(anchorBytes)).ToLowerInvariant()}";
await Task.CompletedTask;
return new TimeAnchorResult
{
Success = true,
Content = new TimeAnchorContent
{
AnchorTime = anchorTime,
Source = $"roughtime:{serverUrl}",
TokenDigest = tokenDigest
},
TokenBytes = anchorBytes,
Warning = "Roughtime client not implemented; using simulated response"
};
// Per no-silent-stubs rule: unimplemented paths must fail explicitly
return TimeAnchorResult.Failed(
$"Roughtime time anchor source '{serverUrl}' is not implemented. " +
"Use 'local' source or implement Roughtime client integration.");
}
private async Task<TimeAnchorResult> CreateRfc3161AnchorAsync(
@@ -218,37 +194,15 @@ public sealed class TimeAnchorService : ITimeAnchorService
CancellationToken cancellationToken)
{
// RFC 3161 is the Internet X.509 PKI Time-Stamp Protocol (TSP)
// This is a placeholder implementation - full implementation would use a TSA client
// Full implementation requires a TSA client library
var tsaUrl = request.Source?["rfc3161:".Length..] ?? "http://timestamp.digicert.com";
var anchorTime = _timeProvider.GetUtcNow();
var anchorData = new Rfc3161AnchorData
{
Timestamp = anchorTime,
TsaUrl = tsaUrl,
SerialNumber = _guidProvider.NewGuid().ToString("N"),
PolicyOid = "2.16.840.1.114412.2.1", // DigiCert timestamp policy
MerkleRoot = request.MerkleRoot
};
var anchorJson = JsonSerializer.Serialize(anchorData, JsonOptions);
var anchorBytes = Encoding.UTF8.GetBytes(anchorJson);
var tokenDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(anchorBytes)).ToLowerInvariant()}";
await Task.CompletedTask;
return new TimeAnchorResult
{
Success = true,
Content = new TimeAnchorContent
{
AnchorTime = anchorTime,
Source = $"rfc3161:{tsaUrl}",
TokenDigest = tokenDigest
},
TokenBytes = anchorBytes,
Warning = "RFC 3161 TSA client not implemented; using simulated response"
};
// Per no-silent-stubs rule: unimplemented paths must fail explicitly
return TimeAnchorResult.Failed(
$"RFC 3161 time anchor source '{tsaUrl}' is not implemented. " +
"Use 'local' source or implement RFC 3161 TSA client integration.");
}
private sealed record LocalAnchorData

View File

@@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.AirGap.Sync.Transport;
@@ -42,7 +43,8 @@ public static class AirGapSyncServiceCollectionExtensions
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var stateStore = sp.GetRequiredService<IHlcStateStore>();
return new HybridLogicalClock.HybridLogicalClock(timeProvider, nodeId, stateStore);
var logger = sp.GetRequiredService<ILogger<HybridLogicalClock.HybridLogicalClock>>();
return new HybridLogicalClock.HybridLogicalClock(timeProvider, nodeId, stateStore, logger);
});
// Register deterministic GUID provider