feat(audit): Apply TreatWarningsAsErrors=true to 160+ production csproj files
Sprint: SPRINT_20251229_049_BE_csproj_audit_maint_tests Tasks: AUDIT-0001 through AUDIT-0147 APPLY tasks (approved decisions 1-9) Changes: - Set TreatWarningsAsErrors=true for all production .NET projects - Fixed nullable warnings in Scanner.EntryTrace, Scanner.Evidence, Scheduler.Worker, Concelier connectors, and other modules - Injected TimeProvider/IGuidProvider for deterministic time/ID generation - Added path traversal validation in AirGap.Bundle - Fixed NULL handling in various cursor classes - Third-party GostCryptography retains TreatWarningsAsErrors=false (preserves original) - Test projects excluded per user decision (rejected decision 10) Note: All 17 ACSC connector tests pass after snapshot fixture sync
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Controller</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Time</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Extracts advisory data from Concelier for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
@@ -23,10 +24,17 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
};
|
||||
|
||||
private readonly IAdvisoryDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AdvisorySnapshotExtractor(IAdvisoryDataSource dataSource)
|
||||
: this(dataSource, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisorySnapshotExtractor(IAdvisoryDataSource dataSource, TimeProvider timeProvider)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -46,7 +54,10 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
{
|
||||
var feeds = await _dataSource.GetAvailableFeedsAsync(cancellationToken);
|
||||
|
||||
foreach (var feed in feeds)
|
||||
// Sort feeds for deterministic output
|
||||
var sortedFeeds = feeds.OrderBy(f => f.FeedId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var feed in sortedFeeds)
|
||||
{
|
||||
// Skip if specific feeds are requested and this isn't one of them
|
||||
if (request.FeedIds is { Count: > 0 } && !request.FeedIds.Contains(feed.FeedId))
|
||||
@@ -119,6 +130,8 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Serialize advisories to NDJSON format for deterministic output
|
||||
var contentBuilder = new StringBuilder();
|
||||
foreach (var advisory in advisories.OrderBy(a => a.Id, StringComparer.Ordinal))
|
||||
@@ -128,7 +141,8 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
}
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(contentBuilder.ToString());
|
||||
var fileName = $"{feedId}-{DateTime.UtcNow:yyyyMMddHHmmss}.ndjson";
|
||||
// Use invariant culture for deterministic filename formatting
|
||||
var fileName = $"{feedId}-{snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}.ndjson";
|
||||
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
@@ -139,7 +153,7 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
FeedId = feedId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = DateTimeOffset.UtcNow,
|
||||
SnapshotAt = snapshotAt,
|
||||
RecordCount = advisories.Count
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,11 +23,23 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private const long DeterministicMtime = 1704067200;
|
||||
|
||||
private readonly IPolicyDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicySnapshotExtractor(IPolicyDataSource dataSource)
|
||||
: this(dataSource, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicySnapshotExtractor(IPolicyDataSource dataSource, TimeProvider timeProvider)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -46,7 +58,10 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
{
|
||||
var policies = await _dataSource.GetAvailablePoliciesAsync(cancellationToken);
|
||||
|
||||
foreach (var policy in policies)
|
||||
// Sort policies for deterministic output
|
||||
var sortedPolicies = policies.OrderBy(p => p.PolicyId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var policy in sortedPolicies)
|
||||
{
|
||||
// Skip if specific types are requested and this isn't one of them
|
||||
if (request.Types is { Count: > 0 } && !request.Types.Contains(policy.Type))
|
||||
@@ -247,9 +262,8 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
// File size in octal (124-135)
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(fileSize, 8).PadLeft(11, '0')).CopyTo(header, 124);
|
||||
|
||||
// Modification time (136-147)
|
||||
var mtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(mtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
// Modification time (136-147) - use deterministic mtime for reproducible output
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(DeterministicMtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
|
||||
// Checksum placeholder (148-155) - spaces
|
||||
for (var i = 148; i < 156; i++)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Extracts VEX statement data from Excititor for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
@@ -24,10 +25,17 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
};
|
||||
|
||||
private readonly IVexDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VexSnapshotExtractor(IVexDataSource dataSource)
|
||||
: this(dataSource, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public VexSnapshotExtractor(IVexDataSource dataSource, TimeProvider timeProvider)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,7 +55,10 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
{
|
||||
var sources = await _dataSource.GetAvailableSourcesAsync(cancellationToken);
|
||||
|
||||
foreach (var source in sources)
|
||||
// Sort sources for deterministic output
|
||||
var sortedSources = sources.OrderBy(s => s.SourceId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var source in sortedSources)
|
||||
{
|
||||
// Skip if specific sources are requested and this isn't one of them
|
||||
if (request.SourceIds is { Count: > 0 } && !request.SourceIds.Contains(source.SourceId))
|
||||
@@ -120,19 +131,22 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var timestampStr = snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
// Serialize statements to OpenVEX format
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{timestampStr}",
|
||||
Author = sourceId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = snapshotAt,
|
||||
Version = 1,
|
||||
Statements = statements.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
|
||||
var contentBytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions);
|
||||
var fileName = $"{sourceId}-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var fileName = $"{sourceId}-{timestampStr}.json";
|
||||
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
@@ -143,7 +157,7 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
SourceId = sourceId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = DateTimeOffset.UtcNow,
|
||||
SnapshotAt = snapshotAt,
|
||||
StatementCount = statements.Count
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Abstractions.cs
|
||||
// Description: Abstractions for deterministic/testable time and ID generation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides unique identifiers. Inject to enable deterministic testing.
|
||||
/// </summary>
|
||||
public interface IGuidProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new unique identifier.
|
||||
/// </summary>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default GUID provider using system random GUIDs.
|
||||
/// </summary>
|
||||
public sealed class SystemGuidProvider : IGuidProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance of the system GUID provider.
|
||||
/// </summary>
|
||||
public static SystemGuidProvider Instance { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring bundle validation behavior.
|
||||
/// </summary>
|
||||
public sealed class BundleValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age in days for feed snapshots before they are flagged as stale.
|
||||
/// Default is 7 days.
|
||||
/// </summary>
|
||||
public int MaxFeedAgeDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail validation on stale feeds or just warn.
|
||||
/// </summary>
|
||||
public bool FailOnStaleFeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate policy digests.
|
||||
/// </summary>
|
||||
public bool ValidatePolicies { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate crypto material digests.
|
||||
/// </summary>
|
||||
public bool ValidateCryptoMaterials { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate catalog digests if present.
|
||||
/// </summary>
|
||||
public bool ValidateCatalogs { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate Rekor snapshot entries if present.
|
||||
/// </summary>
|
||||
public bool ValidateRekorSnapshots { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate crypto provider entries if present.
|
||||
/// </summary>
|
||||
public bool ValidateCryptoProviders { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility methods for path validation and security.
|
||||
/// </summary>
|
||||
public static class PathValidation
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a relative path does not escape the bundle root.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">The relative path to validate.</param>
|
||||
/// <returns>True if the path is safe; false if it contains traversal sequences or is absolute.</returns>
|
||||
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 the raw string for null bytes or other dangerous chars
|
||||
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>
|
||||
/// <param name="rootPath">The root directory path.</param>
|
||||
/// <param name="relativePath">The relative path to combine.</param>
|
||||
/// <returns>The combined path.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the relative path would escape the root.</exception>
|
||||
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 traversal detected: combined path escapes root directory",
|
||||
nameof(relativePath));
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
public sealed class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public BundleBuilder() : this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public BundleBuilder(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public async Task<BundleManifest> BuildAsync(
|
||||
BundleBuildRequest request,
|
||||
string outputPath,
|
||||
@@ -21,7 +34,10 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
|
||||
foreach (var feedConfig in request.Feeds)
|
||||
{
|
||||
var component = await CopyComponentAsync(feedConfig, outputPath, ct).ConfigureAwait(false);
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, feedConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(feedConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
feeds.Add(new FeedComponent(
|
||||
feedConfig.FeedId,
|
||||
feedConfig.Name,
|
||||
@@ -35,7 +51,10 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
|
||||
foreach (var policyConfig in request.Policies)
|
||||
{
|
||||
var component = await CopyComponentAsync(policyConfig, outputPath, ct).ConfigureAwait(false);
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, policyConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(policyConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
policies.Add(new PolicyComponent(
|
||||
policyConfig.PolicyId,
|
||||
policyConfig.Name,
|
||||
@@ -48,7 +67,10 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
|
||||
foreach (var cryptoConfig in request.CryptoMaterials)
|
||||
{
|
||||
var component = await CopyComponentAsync(cryptoConfig, outputPath, ct).ConfigureAwait(false);
|
||||
// Validate relative path before combining
|
||||
var targetPath = PathValidation.SafeCombine(outputPath, cryptoConfig.RelativePath);
|
||||
|
||||
var component = await CopyComponentAsync(cryptoConfig, outputPath, targetPath, ct).ConfigureAwait(false);
|
||||
cryptoMaterials.Add(new CryptoComponent(
|
||||
cryptoConfig.ComponentId,
|
||||
cryptoConfig.Name,
|
||||
@@ -65,11 +87,11 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = Guid.NewGuid().ToString(),
|
||||
BundleId = _guidProvider.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Feeds = feeds.ToImmutableArray(),
|
||||
Policies = policies.ToImmutableArray(),
|
||||
@@ -83,9 +105,9 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
private static async Task<CopiedComponent> CopyComponentAsync(
|
||||
BundleComponentSource source,
|
||||
string outputPath,
|
||||
string targetPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var targetPath = Path.Combine(outputPath, source.RelativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath);
|
||||
|
||||
await using (var input = File.OpenRead(source.SourcePath))
|
||||
|
||||
@@ -25,6 +25,19 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SnapshotBundleReader() : this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public SnapshotBundleReader(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and verifies a snapshot bundle.
|
||||
/// </summary>
|
||||
@@ -40,12 +53,12 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
||||
return SnapshotBundleReadResult.Failed("Bundle file not found");
|
||||
}
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"bundle-read-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"bundle-read-{_guidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract the bundle
|
||||
// Extract the bundle with path validation
|
||||
await ExtractBundleAsync(request.BundlePath, tempDir, cancellationToken);
|
||||
|
||||
// Read manifest
|
||||
@@ -124,7 +137,7 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
||||
// Verify time anchor if present
|
||||
if (request.VerifyTimeAnchor && manifest.TimeAnchor is not null)
|
||||
{
|
||||
var timeAnchorService = new TimeAnchorService();
|
||||
var timeAnchorService = new TimeAnchorService(_timeProvider, _guidProvider);
|
||||
var timeAnchorContent = new TimeAnchorContent
|
||||
{
|
||||
AnchorTime = manifest.TimeAnchor.AnchorTime,
|
||||
@@ -185,7 +198,34 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
||||
{
|
||||
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);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = await tarReader.GetNextEntryAsync(copyData: false, ct)) is not null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Validate entry name to prevent path traversal
|
||||
if (!PathValidation.IsSafeRelativePath(entry.Name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unsafe path detected in bundle: '{entry.Name}'. Path traversal or absolute paths are not allowed.");
|
||||
}
|
||||
|
||||
// Calculate safe target path
|
||||
var targetPath = PathValidation.SafeCombine(targetDir, entry.Name);
|
||||
var targetEntryDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrEmpty(targetEntryDir) && !Directory.Exists(targetEntryDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetEntryDir);
|
||||
}
|
||||
|
||||
if (entry.EntryType == TarEntryType.RegularFile && entry.DataStream is not null)
|
||||
{
|
||||
await using var outputStream = File.Create(targetPath);
|
||||
await entry.DataStream.CopyToAsync(outputStream, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(string filePath, CancellationToken ct)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Formats.Tar;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -26,6 +27,24 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private static readonly DateTimeOffset DeterministicMtime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SnapshotBundleWriter() : this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public SnapshotBundleWriter(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a knowledge snapshot bundle from the specified contents.
|
||||
/// </summary>
|
||||
@@ -36,18 +55,19 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputPath);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-{_guidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var entries = new List<BundleEntry>();
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var manifest = new KnowledgeSnapshotManifest
|
||||
{
|
||||
BundleId = request.BundleId ?? Guid.NewGuid().ToString("N"),
|
||||
Name = request.Name ?? $"knowledge-{DateTime.UtcNow:yyyy-MM-dd}",
|
||||
BundleId = request.BundleId ?? _guidProvider.NewGuid().ToString("N"),
|
||||
Name = request.Name ?? $"knowledge-{createdAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}",
|
||||
Version = request.Version ?? "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = createdAt,
|
||||
SchemaVersion = "1.0.0"
|
||||
};
|
||||
|
||||
@@ -75,7 +95,7 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = advisory.Content.Length,
|
||||
SnapshotAt = advisory.SnapshotAt ?? DateTimeOffset.UtcNow,
|
||||
SnapshotAt = advisory.SnapshotAt ?? createdAt,
|
||||
RecordCount = advisory.RecordCount
|
||||
});
|
||||
}
|
||||
@@ -105,7 +125,7 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = vex.Content.Length,
|
||||
SnapshotAt = vex.SnapshotAt ?? DateTimeOffset.UtcNow,
|
||||
SnapshotAt = vex.SnapshotAt ?? createdAt,
|
||||
StatementCount = vex.StatementCount
|
||||
});
|
||||
}
|
||||
@@ -321,7 +341,24 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal);
|
||||
await TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, includeBaseDirectory: false, ct);
|
||||
await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax);
|
||||
|
||||
// Collect all files and sort for deterministic ordering
|
||||
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)
|
||||
.Select(f => (FullPath: f, RelativePath: Path.GetRelativePath(sourceDir, f).Replace('\\', '/')))
|
||||
.OrderBy(f => f.RelativePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var (fullPath, relativePath) in files)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, relativePath)
|
||||
{
|
||||
DataStream = File.OpenRead(fullPath),
|
||||
ModificationTime = DeterministicMtime,
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead
|
||||
};
|
||||
await tarWriter.WriteEntryAsync(entry, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BundleEntry(string Path, string Digest, long SizeBytes);
|
||||
|
||||
@@ -23,6 +23,19 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public TimeAnchorService() : this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public TimeAnchorService(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a time anchor token for a snapshot.
|
||||
/// </summary>
|
||||
@@ -39,8 +52,8 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
return source switch
|
||||
{
|
||||
"local" => await CreateLocalAnchorAsync(request, cancellationToken),
|
||||
var s when s.StartsWith("roughtime:") => await CreateRoughtimeAnchorAsync(request, cancellationToken),
|
||||
var s when s.StartsWith("rfc3161:") => await CreateRfc3161AnchorAsync(request, cancellationToken),
|
||||
var s when s.StartsWith("roughtime:", StringComparison.Ordinal) => await CreateRoughtimeAnchorAsync(request, cancellationToken),
|
||||
var s when s.StartsWith("rfc3161:", StringComparison.Ordinal) => await CreateRfc3161AnchorAsync(request, cancellationToken),
|
||||
_ => await CreateLocalAnchorAsync(request, cancellationToken)
|
||||
};
|
||||
}
|
||||
@@ -64,7 +77,7 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
try
|
||||
{
|
||||
// Validate timestamp is within acceptable range
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var anchorAge = now - anchor.AnchorTime;
|
||||
|
||||
if (request.MaxAgeHours.HasValue && anchorAge.TotalHours > request.MaxAgeHours.Value)
|
||||
@@ -127,19 +140,19 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TimeAnchorResult> CreateLocalAnchorAsync(
|
||||
private async Task<TimeAnchorResult> CreateLocalAnchorAsync(
|
||||
TimeAnchorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
|
||||
var anchorTime = DateTimeOffset.UtcNow;
|
||||
var anchorTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Create a local anchor with a signed timestamp
|
||||
var anchorData = new LocalAnchorData
|
||||
{
|
||||
Timestamp = anchorTime,
|
||||
Nonce = Guid.NewGuid().ToString("N"),
|
||||
Nonce = _guidProvider.NewGuid().ToString("N"),
|
||||
MerkleRoot = request.MerkleRoot
|
||||
};
|
||||
|
||||
@@ -160,7 +173,7 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<TimeAnchorResult> CreateRoughtimeAnchorAsync(
|
||||
private async Task<TimeAnchorResult> CreateRoughtimeAnchorAsync(
|
||||
TimeAnchorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -169,14 +182,14 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
var serverUrl = request.Source?["roughtime:".Length..] ?? "roughtime.cloudflare.com:2003";
|
||||
|
||||
// For now, fallback to local with indication of intended source
|
||||
var anchorTime = DateTimeOffset.UtcNow;
|
||||
var anchorTime = _timeProvider.GetUtcNow();
|
||||
var anchorData = new RoughtimeAnchorData
|
||||
{
|
||||
Timestamp = anchorTime,
|
||||
Server = serverUrl,
|
||||
Midpoint = anchorTime.ToUnixTimeSeconds(),
|
||||
Radius = 1000000, // 1 second radius in microseconds
|
||||
Nonce = Guid.NewGuid().ToString("N"),
|
||||
Nonce = _guidProvider.NewGuid().ToString("N"),
|
||||
MerkleRoot = request.MerkleRoot
|
||||
};
|
||||
|
||||
@@ -200,7 +213,7 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<TimeAnchorResult> CreateRfc3161AnchorAsync(
|
||||
private async Task<TimeAnchorResult> CreateRfc3161AnchorAsync(
|
||||
TimeAnchorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -208,12 +221,12 @@ public sealed class TimeAnchorService : ITimeAnchorService
|
||||
// This is a placeholder implementation - full implementation would use a TSA client
|
||||
var tsaUrl = request.Source?["rfc3161:".Length..] ?? "http://timestamp.digicert.com";
|
||||
|
||||
var anchorTime = DateTimeOffset.UtcNow;
|
||||
var anchorTime = _timeProvider.GetUtcNow();
|
||||
var anchorData = new Rfc3161AnchorData
|
||||
{
|
||||
Timestamp = anchorTime,
|
||||
TsaUrl = tsaUrl,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
SerialNumber = _guidProvider.NewGuid().ToString("N"),
|
||||
PolicyOid = "2.16.840.1.114412.2.1", // DigiCert timestamp policy
|
||||
MerkleRoot = request.MerkleRoot
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
using System.Text;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Validation;
|
||||
|
||||
public sealed class BundleValidator : IBundleValidator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly BundleValidationOptions _options;
|
||||
|
||||
public BundleValidator() : this(TimeProvider.System, new BundleValidationOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public BundleValidator(TimeProvider timeProvider, BundleValidationOptions options)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async Task<BundleValidationResult> ValidateAsync(
|
||||
BundleManifest manifest,
|
||||
string bundlePath,
|
||||
@@ -14,6 +28,7 @@ public sealed class BundleValidator : IBundleValidator
|
||||
{
|
||||
var errors = new List<BundleValidationError>();
|
||||
var warnings = new List<BundleValidationWarning>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (manifest.Feeds.Length == 0)
|
||||
{
|
||||
@@ -25,9 +40,18 @@ public sealed class BundleValidator : IBundleValidator
|
||||
errors.Add(new BundleValidationError("CryptoMaterials", "Trust roots required"));
|
||||
}
|
||||
|
||||
// Validate feed digests and paths
|
||||
foreach (var feed in manifest.Feeds)
|
||||
{
|
||||
var filePath = Path.Combine(bundlePath, feed.RelativePath);
|
||||
// Validate path safety
|
||||
if (!PathValidation.IsSafeRelativePath(feed.RelativePath))
|
||||
{
|
||||
errors.Add(new BundleValidationError("Feeds",
|
||||
$"Feed {feed.FeedId} has unsafe relative path: {feed.RelativePath}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = PathValidation.SafeCombine(bundlePath, feed.RelativePath);
|
||||
var result = await VerifyFileDigestAsync(filePath, feed.Digest, ct).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
@@ -36,21 +60,75 @@ public sealed class BundleValidator : IBundleValidator
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
// Validate policy digests if enabled
|
||||
if (_options.ValidatePolicies)
|
||||
{
|
||||
foreach (var policy in manifest.Policies)
|
||||
{
|
||||
if (!PathValidation.IsSafeRelativePath(policy.RelativePath))
|
||||
{
|
||||
errors.Add(new BundleValidationError("Policies",
|
||||
$"Policy {policy.PolicyId} has unsafe relative path: {policy.RelativePath}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = PathValidation.SafeCombine(bundlePath, policy.RelativePath);
|
||||
var result = await VerifyFileDigestAsync(filePath, policy.Digest, ct).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new BundleValidationError("Policies",
|
||||
$"Policy {policy.PolicyId} digest mismatch: expected {policy.Digest}, got {result.ActualDigest}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate crypto material digests if enabled
|
||||
if (_options.ValidateCryptoMaterials)
|
||||
{
|
||||
foreach (var crypto in manifest.CryptoMaterials)
|
||||
{
|
||||
if (!PathValidation.IsSafeRelativePath(crypto.RelativePath))
|
||||
{
|
||||
errors.Add(new BundleValidationError("CryptoMaterials",
|
||||
$"Crypto material {crypto.ComponentId} has unsafe relative path: {crypto.RelativePath}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = PathValidation.SafeCombine(bundlePath, crypto.RelativePath);
|
||||
var result = await VerifyFileDigestAsync(filePath, crypto.Digest, ct).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new BundleValidationError("CryptoMaterials",
|
||||
$"Crypto material {crypto.ComponentId} digest mismatch: expected {crypto.Digest}, got {result.ActualDigest}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle expiration
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < now)
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("ExpiresAt", "Bundle has expired"));
|
||||
}
|
||||
|
||||
// Check feed staleness using configurable threshold
|
||||
foreach (var feed in manifest.Feeds)
|
||||
{
|
||||
var age = DateTimeOffset.UtcNow - feed.SnapshotAt;
|
||||
if (age.TotalDays > 7)
|
||||
var age = now - feed.SnapshotAt;
|
||||
if (age.TotalDays > _options.MaxFeedAgeDays)
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("Feeds",
|
||||
$"Feed {feed.FeedId} is {age.TotalDays:F0} days old"));
|
||||
var message = $"Feed {feed.FeedId} is {age.TotalDays:F0} days old (threshold: {_options.MaxFeedAgeDays} days)";
|
||||
if (_options.FailOnStaleFeed)
|
||||
{
|
||||
errors.Add(new BundleValidationError("Feeds", message));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add(new BundleValidationWarning("Feeds", message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify bundle digest if present
|
||||
if (manifest.BundleDigest is not null)
|
||||
{
|
||||
var computed = ComputeBundleDigest(manifest);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.AirGap.Persistence</RootNamespace>
|
||||
|
||||
Reference in New Issue
Block a user