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

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