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
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user