Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET.
- Added `all-visibility-levels.json` to validate method visibility levels in .NET.
- Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application.
- Included `go-gin-api.json` for a Go Gin API application structure.
- Added `java-spring-boot.json` for the Spring PetClinic application in Java.
- Introduced `legacy-no-schema.json` for legacy application structure without schema.
- Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Importer.Telemetry;
namespace StellaOps.AirGap.Importer.Quarantine;
@@ -36,6 +37,8 @@ public sealed class FileSystemQuarantineService : IQuarantineService
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ReasonCode);
using var tenantScope = _logger.BeginTenantScope(request.TenantId);
if (!File.Exists(request.BundlePath))
{
return new QuarantineResult(
@@ -117,11 +120,12 @@ public sealed class FileSystemQuarantineService : IQuarantineService
cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"Bundle quarantined: tenant={TenantId} quarantineId={QuarantineId} reason={ReasonCode} path={Path}",
"offlinekit.quarantine created tenant_id={tenant_id} quarantine_id={quarantine_id} reason_code={reason_code} quarantine_path={quarantine_path} original_bundle={original_bundle}",
request.TenantId,
quarantineId,
request.ReasonCode,
quarantinePath);
quarantinePath,
Path.GetFileName(request.BundlePath));
return new QuarantineResult(
Success: true,
@@ -131,7 +135,12 @@ public sealed class FileSystemQuarantineService : IQuarantineService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to quarantine bundle to {Path}", quarantinePath);
_logger.LogError(
ex,
"offlinekit.quarantine failed tenant_id={tenant_id} quarantine_id={quarantine_id} quarantine_path={quarantine_path}",
request.TenantId,
quarantineId,
quarantinePath);
return new QuarantineResult(
Success: false,
QuarantineId: quarantineId,
@@ -221,6 +230,8 @@ public sealed class FileSystemQuarantineService : IQuarantineService
ArgumentException.ThrowIfNullOrWhiteSpace(quarantineId);
ArgumentException.ThrowIfNullOrWhiteSpace(removalReason);
using var tenantScope = _logger.BeginTenantScope(tenantId);
var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(tenantId));
var entryPath = Path.Combine(tenantRoot, quarantineId);
if (!Directory.Exists(entryPath))
@@ -245,7 +256,7 @@ public sealed class FileSystemQuarantineService : IQuarantineService
Directory.Move(entryPath, removedPath);
_logger.LogInformation(
"Quarantine removed: tenant={TenantId} quarantineId={QuarantineId} removedPath={RemovedPath}",
"offlinekit.quarantine removed tenant_id={tenant_id} quarantine_id={quarantine_id} removed_path={removed_path}",
tenantId,
quarantineId,
removedPath);

View File

@@ -0,0 +1,194 @@
namespace StellaOps.AirGap.Importer.Reconciliation;
/// <summary>
/// Digest-keyed artifact index used by the evidence reconciliation flow.
/// Designed for deterministic ordering and replay.
/// </summary>
public sealed class ArtifactIndex
{
private readonly SortedDictionary<string, ArtifactEntry> _entries = new(StringComparer.Ordinal);
public void AddOrUpdate(ArtifactEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
AddOrUpdate(entry.Digest, entry);
}
public void AddOrUpdate(string digest, ArtifactEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
var normalizedDigest = NormalizeDigest(digest);
var normalizedEntry = entry with { Digest = normalizedDigest };
if (_entries.TryGetValue(normalizedDigest, out var existing))
{
_entries[normalizedDigest] = existing.Merge(normalizedEntry);
return;
}
_entries[normalizedDigest] = normalizedEntry;
}
public ArtifactEntry? Get(string digest)
{
var normalizedDigest = NormalizeDigest(digest);
return _entries.TryGetValue(normalizedDigest, out var entry) ? entry : null;
}
public IEnumerable<KeyValuePair<string, ArtifactEntry>> GetAll() => _entries;
public static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest is required.", nameof(digest));
}
digest = digest.Trim();
const string prefix = "sha256:";
string hex;
if (digest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
hex = digest[prefix.Length..];
}
else if (digest.Contains(':', StringComparison.Ordinal))
{
throw new FormatException($"Unsupported digest algorithm in '{digest}'. Only sha256 is supported.");
}
else
{
hex = digest;
}
hex = hex.Trim().ToLowerInvariant();
if (hex.Length != 64 || !IsLowerHex(hex.AsSpan()))
{
throw new FormatException($"Invalid sha256 digest '{digest}'. Expected 64 hex characters.");
}
return prefix + hex;
}
private static bool IsLowerHex(ReadOnlySpan<char> value)
{
foreach (var c in value)
{
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
{
continue;
}
return false;
}
return true;
}
}
public sealed record ArtifactEntry(
string Digest,
string? Name,
IReadOnlyList<SbomReference> Sboms,
IReadOnlyList<AttestationReference> Attestations,
IReadOnlyList<VexReference> VexDocuments)
{
public static ArtifactEntry Empty(string digest, string? name = null) =>
new(
digest,
name,
Array.Empty<SbomReference>(),
Array.Empty<AttestationReference>(),
Array.Empty<VexReference>());
public ArtifactEntry Merge(ArtifactEntry other)
{
ArgumentNullException.ThrowIfNull(other);
return this with
{
Name = ChooseName(Name, other.Name),
Sboms = MergeByContentHash(Sboms, other.Sboms, s => s.ContentHash, s => s.FilePath),
Attestations = MergeByContentHash(Attestations, other.Attestations, a => a.ContentHash, a => a.FilePath),
VexDocuments = MergeByContentHash(VexDocuments, other.VexDocuments, v => v.ContentHash, v => v.FilePath),
};
}
private static string? ChooseName(string? left, string? right)
{
if (left is null)
{
return right;
}
if (right is null)
{
return left;
}
return string.CompareOrdinal(left, right) <= 0 ? left : right;
}
private static IReadOnlyList<T> MergeByContentHash<T>(
IReadOnlyList<T> left,
IReadOnlyList<T> right,
Func<T, string> contentHashSelector,
Func<T, string> filePathSelector)
{
var merged = left
.Concat(right)
.OrderBy(x => contentHashSelector(x), StringComparer.Ordinal)
.ThenBy(x => filePathSelector(x), StringComparer.Ordinal)
.ToList();
return merged.DistinctBy(contentHashSelector).ToList();
}
}
public sealed record SbomReference(
string ContentHash,
string FilePath,
SbomFormat Format,
DateTimeOffset? CreatedAt);
public sealed record AttestationReference(
string ContentHash,
string FilePath,
string PredicateType,
IReadOnlyList<string> Subjects,
bool SignatureVerified,
bool TlogVerified,
string? RekorUuid);
public sealed record VexReference(
string ContentHash,
string FilePath,
VexFormat Format,
SourcePrecedence Precedence,
DateTimeOffset? Timestamp);
public enum SbomFormat
{
CycloneDx,
Spdx,
Unknown
}
public enum VexFormat
{
OpenVex,
CsafVex,
CycloneDxVex,
Unknown
}
public enum SourcePrecedence
{
Vendor = 1,
Maintainer = 2,
ThirdParty = 3,
Unknown = 99
}

View File

@@ -0,0 +1,89 @@
using System.Security.Cryptography;
namespace StellaOps.AirGap.Importer.Reconciliation;
public static class EvidenceDirectoryDiscovery
{
private static readonly string[] EvidenceRoots = new[] { "sboms", "attestations", "vex" };
public static IReadOnlyList<DiscoveredEvidenceFile> Discover(string evidenceDirectory)
{
if (string.IsNullOrWhiteSpace(evidenceDirectory))
{
throw new ArgumentException("Evidence directory is required.", nameof(evidenceDirectory));
}
if (!Directory.Exists(evidenceDirectory))
{
throw new DirectoryNotFoundException($"Evidence directory not found: {evidenceDirectory}");
}
var candidates = new List<(string FullPath, string RelativePath)>();
foreach (var root in EvidenceRoots)
{
var rootPath = Path.Combine(evidenceDirectory, root);
if (!Directory.Exists(rootPath))
{
continue;
}
foreach (var file in Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories))
{
var relative = NormalizeRelativePath(Path.GetRelativePath(evidenceDirectory, file));
candidates.Add((file, relative));
}
}
return candidates
.OrderBy(c => c.RelativePath, StringComparer.Ordinal)
.Select(c => new DiscoveredEvidenceFile(
RelativePath: c.RelativePath,
ContentSha256: ComputeSha256(c.FullPath),
Kind: Classify(c.RelativePath)))
.ToList();
}
private static string NormalizeRelativePath(string path) => path.Replace('\\', '/');
private static EvidenceFileKind Classify(string relativePath)
{
if (relativePath.StartsWith("sboms/", StringComparison.OrdinalIgnoreCase))
{
return EvidenceFileKind.Sbom;
}
if (relativePath.StartsWith("attestations/", StringComparison.OrdinalIgnoreCase))
{
return EvidenceFileKind.Attestation;
}
if (relativePath.StartsWith("vex/", StringComparison.OrdinalIgnoreCase))
{
return EvidenceFileKind.Vex;
}
return EvidenceFileKind.Unknown;
}
private static string ComputeSha256(string fullPath)
{
using var stream = File.OpenRead(fullPath);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(stream);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
public enum EvidenceFileKind
{
Sbom,
Attestation,
Vex,
Unknown
}
public sealed record DiscoveredEvidenceFile(
string RelativePath,
string ContentSha256,
EvidenceFileKind Kind);

View File

@@ -0,0 +1,24 @@
namespace StellaOps.AirGap.Importer.Telemetry;
/// <summary>
/// Stable structured logging field names for Offline Kit / air-gap import flows.
/// </summary>
public static class OfflineKitLogFields
{
public const string TenantId = "tenant_id";
public const string BundleType = "bundle_type";
public const string BundleDigest = "bundle_digest";
public const string BundlePath = "bundle_path";
public const string ManifestVersion = "manifest_version";
public const string ManifestCreatedAt = "manifest_created_at";
public const string ForceActivate = "force_activate";
public const string ForceActivateReason = "force_activate_reason";
public const string Result = "result";
public const string ReasonCode = "reason_code";
public const string ReasonMessage = "reason_message";
public const string QuarantineId = "quarantine_id";
public const string QuarantinePath = "quarantine_path";
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.AirGap.Importer.Telemetry;
public static class OfflineKitLogScopes
{
public static IDisposable? BeginTenantScope(this ILogger logger, string tenantId)
{
ArgumentNullException.ThrowIfNull(logger);
if (string.IsNullOrWhiteSpace(tenantId))
{
return null;
}
return logger.BeginScope(new Dictionary<string, object?>(StringComparer.Ordinal)
{
[OfflineKitLogFields.TenantId] = tenantId
});
}
}

View File

@@ -0,0 +1,142 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.AirGap.Importer.Telemetry;
/// <summary>
/// Metrics for Offline Kit operations.
/// </summary>
public sealed class OfflineKitMetrics : IDisposable
{
public const string MeterName = "StellaOps.AirGap.Importer";
public static class TagNames
{
public const string TenantId = "tenant_id";
public const string Status = "status";
public const string AttestationType = "attestation_type";
public const string Success = "success";
public const string Mode = "mode";
public const string Reason = "reason";
}
private readonly Meter _meter;
private readonly Counter<long> _importTotal;
private readonly Histogram<double> _attestationVerifyLatencySeconds;
private readonly Counter<long> _rekorSuccessTotal;
private readonly Counter<long> _rekorRetryTotal;
private readonly Histogram<double> _rekorInclusionLatencySeconds;
private bool _disposed;
public OfflineKitMetrics(IMeterFactory? meterFactory = null)
{
_meter = meterFactory?.Create(MeterName, version: "1.0.0") ?? new Meter(MeterName, "1.0.0");
_importTotal = _meter.CreateCounter<long>(
name: "offlinekit_import_total",
unit: "{imports}",
description: "Total number of offline kit import attempts");
_attestationVerifyLatencySeconds = _meter.CreateHistogram<double>(
name: "offlinekit_attestation_verify_latency_seconds",
unit: "s",
description: "Time taken to verify attestations during import");
_rekorSuccessTotal = _meter.CreateCounter<long>(
name: "attestor_rekor_success_total",
unit: "{verifications}",
description: "Successful Rekor verification count");
_rekorRetryTotal = _meter.CreateCounter<long>(
name: "attestor_rekor_retry_total",
unit: "{retries}",
description: "Rekor verification retry count");
_rekorInclusionLatencySeconds = _meter.CreateHistogram<double>(
name: "rekor_inclusion_latency",
unit: "s",
description: "Time to verify Rekor inclusion proof");
}
public void RecordImport(string status, string tenantId)
{
if (string.IsNullOrWhiteSpace(status))
{
status = "unknown";
}
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "unknown";
}
_importTotal.Add(1, new TagList
{
{ TagNames.Status, status },
{ TagNames.TenantId, tenantId }
});
}
public void RecordAttestationVerifyLatency(string attestationType, double seconds, bool success)
{
if (string.IsNullOrWhiteSpace(attestationType))
{
attestationType = "unknown";
}
if (seconds < 0)
{
seconds = 0;
}
_attestationVerifyLatencySeconds.Record(seconds, new TagList
{
{ TagNames.AttestationType, attestationType },
{ TagNames.Success, success ? "true" : "false" }
});
}
public void RecordRekorSuccess(string mode)
{
if (string.IsNullOrWhiteSpace(mode))
{
mode = "unknown";
}
_rekorSuccessTotal.Add(1, new TagList { { TagNames.Mode, mode } });
}
public void RecordRekorRetry(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
{
reason = "unknown";
}
_rekorRetryTotal.Add(1, new TagList { { TagNames.Reason, reason } });
}
public void RecordRekorInclusionLatency(double seconds, bool success)
{
if (seconds < 0)
{
seconds = 0;
}
_rekorInclusionLatencySeconds.Record(seconds, new TagList
{
{ TagNames.Success, success ? "true" : "false" }
});
}
public void Dispose()
{
if (_disposed)
{
return;
}
_meter.Dispose();
_disposed = true;
}
}

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
namespace StellaOps.AirGap.Importer.Validation;
@@ -13,13 +14,24 @@ public sealed class DsseVerifier
{
private const string PaePrefix = "DSSEv1";
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots)
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots, ILogger? logger = null)
{
if (trustRoots.TrustedKeyFingerprints.Count == 0 || trustRoots.PublicKeys.Count == 0)
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} trusted_fingerprints={trusted_fingerprints} public_keys={public_keys}",
"TRUST_ROOTS_REQUIRED",
trustRoots.TrustedKeyFingerprints.Count,
trustRoots.PublicKeys.Count);
return BundleValidationResult.Failure("trust-roots-required");
}
logger?.LogDebug(
"offlinekit.dsse.verify start payload_type={payload_type} signatures={signatures} public_keys={public_keys}",
envelope.PayloadType,
envelope.Signatures.Count,
trustRoots.PublicKeys.Count);
foreach (var signature in envelope.Signatures)
{
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
@@ -36,10 +48,20 @@ public sealed class DsseVerifier
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
{
logger?.LogInformation(
"offlinekit.dsse.verify succeeded key_id={key_id} fingerprint={fingerprint} payload_type={payload_type}",
signature.KeyId,
fingerprint,
envelope.PayloadType);
return BundleValidationResult.Success("dsse-signature-verified");
}
}
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} signatures={signatures} public_keys={public_keys}",
"DSSE_SIGNATURE_INVALID",
envelope.Signatures.Count,
trustRoots.PublicKeys.Count);
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
using StellaOps.AirGap.Importer.Telemetry;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Validation;
@@ -46,6 +47,7 @@ public sealed class ImportValidator
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ManifestVersion);
using var tenantScope = _logger.BeginTenantScope(request.TenantId);
var verificationLog = new List<string>(capacity: 16);
var tufResult = _tuf.Validate(request.RootJson, request.SnapshotJson, request.TimestampJson);
@@ -53,16 +55,30 @@ public sealed class ImportValidator
{
var failed = tufResult with { Reason = $"tuf:{tufResult.Reason}" };
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"TUF_INVALID",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"tuf:{tufResult.Reason}");
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots);
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots, _logger);
if (!dsseResult.IsValid)
{
var failed = dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"DSSE_INVALID",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
@@ -73,6 +89,13 @@ public sealed class ImportValidator
{
var failed = BundleValidationResult.Failure("merkle-empty");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"HASH_MISMATCH",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
@@ -83,6 +106,13 @@ public sealed class ImportValidator
{
var failed = rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"ROTATION_INVALID",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
@@ -97,6 +127,14 @@ public sealed class ImportValidator
{
var failed = BundleValidationResult.Failure($"manifest-version-parse-failed:{ex.GetType().Name.ToLowerInvariant()}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
ex,
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"VERSION_PARSE_FAILED",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
@@ -112,6 +150,13 @@ public sealed class ImportValidator
var failed = BundleValidationResult.Failure(
$"version-non-monotonic:incoming={incomingVersion.SemVer}:current={monotonicity.CurrentVersion?.SemVer ?? "(none)"}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"VERSION_NON_MONOTONIC",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
@@ -122,14 +167,22 @@ public sealed class ImportValidator
{
var failed = BundleValidationResult.Failure("force-activate-reason-required");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"FORCE_ACTIVATE_REASON_REQUIRED",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
_logger.LogWarning(
"Non-monotonic activation forced: tenant={TenantId} bundleType={BundleType} incoming={Incoming} current={Current} reason={Reason}",
"offlinekit.import.force_activation tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} incoming_version={incoming_version} current_version={current_version} force_activate_reason={force_activate_reason}",
request.TenantId,
request.BundleType,
request.BundleDigest,
incomingVersion.SemVer,
monotonicity.CurrentVersion?.SemVer,
request.ForceActivateReason);
@@ -148,13 +201,25 @@ public sealed class ImportValidator
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record bundle activation for tenant={TenantId} bundleType={BundleType}", request.TenantId, request.BundleType);
_logger.LogError(
ex,
"offlinekit.import.activation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest}",
request.TenantId,
request.BundleType,
request.BundleDigest);
var failed = BundleValidationResult.Failure($"version-store-write-failed:{ex.GetType().Name.ToLowerInvariant()}");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
_logger.LogInformation(
"offlinekit.import.validation succeeded tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} manifest_version={manifest_version} force_activate={force_activate}",
request.TenantId,
request.BundleType,
request.BundleDigest,
request.ManifestVersion,
request.ForceActivate);
return BundleValidationResult.Success("import-validated");
}
@@ -199,7 +264,7 @@ public sealed class ImportValidator
if (!quarantine.Success)
{
_logger.LogError(
"Failed to quarantine bundle for tenant={TenantId} path={BundlePath} error={Error}",
"offlinekit.import.quarantine failed tenant_id={tenant_id} bundle_path={bundle_path} reason_code={reason_code}",
request.TenantId,
request.BundlePath,
quarantine.ErrorMessage);
@@ -207,7 +272,11 @@ public sealed class ImportValidator
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to quarantine bundle for tenant={TenantId} path={BundlePath}", request.TenantId, request.BundlePath);
_logger.LogError(
ex,
"offlinekit.import.quarantine failed tenant_id={tenant_id} bundle_path={bundle_path}",
request.TenantId,
request.BundlePath);
}
}
}

View File

@@ -19,3 +19,5 @@
| MR-T10.6.2 | DONE | DI simplified to register in-memory air-gap state store (no Mongo options or client). | 2025-12-11 |
| MR-T10.6.3 | DONE | Converted controller tests to in-memory store; dropped Mongo2Go dependency. | 2025-12-11 |
| AIRGAP-IMP-0338 | DONE | Implemented monotonicity enforcement + quarantine service (version primitives/checker, Postgres version store, importer validator integration, unit/integration tests). | 2025-12-15 |
| AIRGAP-OBS-0341-001 | DONE | Sprint 0341: OfflineKit metrics + structured logging fields/scopes in Importer; DSSE/quarantine logs aligned; metrics tests passing. | 2025-12-15 |
| AIRGAP-IMP-0342 | DOING | Sprint 0342: deterministic evidence reconciliation primitives per advisory §5 (ArtifactIndex/normalization first); tests pending. | 2025-12-15 |