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:
StellaOps Bot
2026-01-04 11:21:16 +02:00
parent bc4dd4f377
commit e411fde1a9
438 changed files with 2648 additions and 668 deletions

View File

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

View File

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

View File

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

View File

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

View File

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