sprints and audit work
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user