work
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, deterministic writer for Concelier air-gap bundles. Intended as the
|
||||
/// first increment for CONCELIER-AIRGAP-56-001; produces a stable NDJSON file
|
||||
/// from link-not-merge cache items without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class AirgapBundleBuilder
|
||||
{
|
||||
private const string BundleFileName = "concelier-airgap.ndjson";
|
||||
private const string ManifestFileName = "bundle.manifest.json";
|
||||
private const string EntryTraceFileName = "bundle.entry-trace.json";
|
||||
|
||||
public async Task<AirgapBundleResult> BuildAsync(
|
||||
IEnumerable<string> cacheItems,
|
||||
string outputDirectory,
|
||||
DateTimeOffset? createdUtc = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (cacheItems is null) throw new ArgumentNullException(nameof(cacheItems));
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory)) throw new ArgumentException("Output directory is required", nameof(outputDirectory));
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var ordered = cacheItems
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var bundlePath = Path.Combine(outputDirectory, BundleFileName);
|
||||
await WriteNdjsonAsync(bundlePath, ordered, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleSha = ComputeSha256FromPath(bundlePath);
|
||||
|
||||
var entries = ordered
|
||||
.Select((value, index) => new AirgapBundleEntry
|
||||
{
|
||||
LineNumber = index + 1,
|
||||
Sha256 = ComputeSha256(value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var manifestCreated = createdUtc ?? DateTimeOffset.UnixEpoch;
|
||||
var manifest = new AirgapBundleManifest
|
||||
{
|
||||
Items = ordered,
|
||||
Entries = entries,
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedUtc = manifestCreated,
|
||||
Count = ordered.Length
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(outputDirectory, ManifestFileName);
|
||||
await WriteManifest(manifestPath, manifest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entryTracePath = Path.Combine(outputDirectory, EntryTraceFileName);
|
||||
await WriteEntryTrace(entryTracePath, entries, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AirgapBundleResult(bundlePath, manifestPath, entryTracePath, bundleSha, ordered.Length);
|
||||
}
|
||||
|
||||
private static async Task WriteNdjsonAsync(string bundlePath, IReadOnlyList<string> orderedItems, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
foreach (var item in orderedItems)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteLineAsync(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteManifest(string manifestPath, AirgapBundleManifest manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteEntryTrace(string entryTracePath, IReadOnlyList<AirgapBundleEntry> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(entryTracePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromPath(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleResult(string BundlePath, string ManifestPath, string EntryTracePath, string Sha256, int ItemCount);
|
||||
|
||||
public sealed record AirgapBundleManifest
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public string[] Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public AirgapBundleEntry[] Entries { get; init; } = Array.Empty<AirgapBundleEntry>();
|
||||
|
||||
[JsonPropertyName("bundleSha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdUtc")]
|
||||
public DateTimeOffset CreatedUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleEntry
|
||||
{
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int LineNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
public sealed class AirgapBundleValidator
|
||||
{
|
||||
public async Task<AirgapBundleValidationResult> ValidateAsync(
|
||||
string bundlePath,
|
||||
string manifestPath,
|
||||
string? entryTracePath = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
errors.Add($"Bundle file missing: {bundlePath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add($"Manifest file missing: {manifestPath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
AirgapBundleManifest? manifest = null;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Manifest parse error: {ex.Message}");
|
||||
}
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
var bundleSha = ComputeSha256FromFile(bundlePath);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!string.Equals(bundleSha, manifest.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add("Bundle hash mismatch");
|
||||
}
|
||||
|
||||
if (manifest.Count != lines.Length)
|
||||
{
|
||||
errors.Add($"Manifest count {manifest.Count} != bundle lines {lines.Length}");
|
||||
}
|
||||
|
||||
var ordered = lines.ToArray();
|
||||
if (!manifest.Items.SequenceEqual(ordered))
|
||||
{
|
||||
errors.Add("Manifest items differ from bundle payload");
|
||||
}
|
||||
|
||||
// If entry trace exists (either provided or embedded in manifest), verify per-line hashes.
|
||||
AirgapBundleEntry[] entries = manifest.Entries ?? Array.Empty<AirgapBundleEntry>();
|
||||
if (!string.IsNullOrWhiteSpace(entryTracePath) && File.Exists(entryTracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var traceJson = await File.ReadAllTextAsync(entryTracePath!, cancellationToken).ConfigureAwait(false);
|
||||
var traceEntries = JsonSerializer.Deserialize<AirgapBundleEntry[]>(traceJson);
|
||||
if (traceEntries is not null)
|
||||
{
|
||||
entries = traceEntries;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Entry trace parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
if (entries.Length != lines.Length)
|
||||
{
|
||||
errors.Add($"Entry trace length {entries.Length} != bundle lines {lines.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var expectedHash = ComputeSha256(lines[i]);
|
||||
if (!string.Equals(entries[i].Sha256, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Entry trace hash mismatch at line {i + 1}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AirgapBundleValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromFile(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleValidationResult(bool IsValid, IReadOnlyList<string> Errors);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record VerifyAttestationRequest(
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? PipelineVersion);
|
||||
|
||||
public readonly record struct EvidencePathResolutionResult(
|
||||
bool IsValid,
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? Error,
|
||||
string? ErrorDetails)
|
||||
{
|
||||
public static EvidencePathResolutionResult Valid(string bundlePath, string manifestPath, string? transparencyPath) =>
|
||||
new(true, bundlePath, manifestPath, transparencyPath, null, null);
|
||||
|
||||
public static EvidencePathResolutionResult Invalid(string error, string? details = null) =>
|
||||
new(false, null, null, null, error, details);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record EvidenceBatchRequest(
|
||||
IReadOnlyCollection<EvidenceBatchItemRequest> Items,
|
||||
int? ObservationLimit,
|
||||
int? LinksetLimit);
|
||||
|
||||
public sealed record EvidenceBatchItemRequest(
|
||||
string? ComponentId,
|
||||
IReadOnlyCollection<string>? Purls,
|
||||
IReadOnlyCollection<string>? Aliases);
|
||||
|
||||
public sealed record EvidenceBatchItemResponse(
|
||||
string ComponentId,
|
||||
IReadOnlyCollection<AdvisoryObservation> Observations,
|
||||
IReadOnlyCollection<AdvisoryLinkset> Linksets,
|
||||
bool HasMore,
|
||||
DateTimeOffset RetrievedAt);
|
||||
|
||||
public sealed record EvidenceBatchResponse(
|
||||
IReadOnlyCollection<EvidenceBatchItemResponse> Items);
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record EvidenceSnapshotResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("manifestHash")] string ManifestHash,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
|
||||
public sealed record AttestationStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("claims")] AttestationClaims Claims,
|
||||
[property: JsonPropertyName("bundlePath")] string BundlePath,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record IncidentUpsertRequest(
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("cooldownMinutes")] int? CooldownMinutes);
|
||||
|
||||
public sealed record IncidentStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("activatedAt")] string ActivatedAt,
|
||||
[property: JsonPropertyName("cooldownUntil")] string CooldownUntil,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion,
|
||||
[property: JsonPropertyName("active")] bool Active);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal static class IncidentFileStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static string GetIncidentFilePath(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceOptions);
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
ArgumentNullException.ThrowIfNull(advisoryKey);
|
||||
|
||||
var root = evidenceOptions.RootAbsolute ?? evidenceOptions.Root ?? string.Empty;
|
||||
return Path.Combine(root, tenant.Trim(), advisoryKey.Trim(), "incident.json");
|
||||
}
|
||||
|
||||
public static async Task WriteAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
string reason,
|
||||
int cooldownMinutes,
|
||||
string? pipelineVersion,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var activatedAt = now.ToUniversalTime();
|
||||
var cooldownUntil = activatedAt.AddMinutes(cooldownMinutes);
|
||||
|
||||
var payload = new IncidentFile
|
||||
{
|
||||
AdvisoryKey = advisoryKey.Trim(),
|
||||
Tenant = tenant.Trim(),
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
|
||||
ActivatedAt = activatedAt,
|
||||
CooldownUntil = cooldownUntil,
|
||||
PipelineVersion = pipelineVersion,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<IncidentStatusResponse?> ReadAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var payload = await JsonSerializer.DeserializeAsync<IncidentFile>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var active = payload.CooldownUntil > now.ToUniversalTime();
|
||||
return new IncidentStatusResponse(
|
||||
payload.AdvisoryKey,
|
||||
payload.Tenant,
|
||||
payload.Reason,
|
||||
payload.ActivatedAt.ToUniversalTime().ToString("O"),
|
||||
payload.CooldownUntil.ToUniversalTime().ToString("O"),
|
||||
payload.PipelineVersion,
|
||||
active);
|
||||
}
|
||||
|
||||
public static Task DeleteAsync(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record IncidentFile
|
||||
{
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = "unspecified";
|
||||
public DateTimeOffset ActivatedAt { get; init; }
|
||||
public DateTimeOffset CooldownUntil { get; init; }
|
||||
public string? PipelineVersion { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Normalization.SemVer;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
|
||||
internal static class CccsMapper
|
||||
{
|
||||
@@ -108,44 +110,149 @@ internal static class CccsMapper
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Products.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>(dto.Products.Count);
|
||||
foreach (var product in dto.Products)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = product.Trim();
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"package",
|
||||
identifier,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
platform: null,
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
|
||||
}
|
||||
|
||||
return packages.Count == 0
|
||||
? Array.Empty<AffectedPackage>()
|
||||
: packages
|
||||
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Products.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>(dto.Products.Count);
|
||||
for (var index = 0; index < dto.Products.Count; index++)
|
||||
{
|
||||
var product = dto.Products[index];
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = product.Trim();
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"package",
|
||||
identifier,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
var rangeAnchor = $"cccs:{dto.SerialNumber}:{index}";
|
||||
var versionRanges = BuildVersionRanges(product, rangeAnchor, recordedAt);
|
||||
var normalizedVersions = BuildNormalizedVersions(versionRanges, rangeAnchor);
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
platform: null,
|
||||
versionRanges: versionRanges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: normalizedVersions));
|
||||
}
|
||||
|
||||
return packages.Count == 0
|
||||
? Array.Empty<AffectedPackage>()
|
||||
: packages
|
||||
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string productText, string rangeAnchor, DateTimeOffset recordedAt)
|
||||
{
|
||||
var versionText = ExtractFirstVersionToken(productText);
|
||||
if (string.IsNullOrWhiteSpace(versionText))
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"range",
|
||||
rangeAnchor,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
|
||||
var vendorExtensions = new Dictionary<string, string>
|
||||
{
|
||||
["cccs.version.raw"] = versionText!,
|
||||
["cccs.anchor"] = rangeAnchor,
|
||||
};
|
||||
|
||||
var semVerResults = SemVerRangeRuleBuilder.Build(versionText!, patchedVersion: null, provenanceNote: rangeAnchor);
|
||||
if (semVerResults.Count > 0)
|
||||
{
|
||||
return semVerResults.Select(result =>
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: result.Primitive.Introduced,
|
||||
fixedVersion: result.Primitive.Fixed,
|
||||
lastAffectedVersion: result.Primitive.LastAffected,
|
||||
rangeExpression: result.Expression ?? versionText!,
|
||||
provenance: provenance,
|
||||
primitives: new RangePrimitives(
|
||||
result.Primitive,
|
||||
Nevra: null,
|
||||
Evr: null,
|
||||
VendorExtensions: vendorExtensions)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var primitives = new RangePrimitives(
|
||||
new SemVerPrimitive(
|
||||
Introduced: versionText,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: true,
|
||||
ConstraintExpression: null,
|
||||
ExactValue: versionText),
|
||||
Nevra: null,
|
||||
Evr: null,
|
||||
VendorExtensions: vendorExtensions);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: versionText,
|
||||
provenance: provenance,
|
||||
primitives: primitives),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
IReadOnlyList<AffectedVersionRange> ranges,
|
||||
string rangeAnchor)
|
||||
{
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var rules = new List<NormalizedVersionRule>(ranges.Count);
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var rule = range.ToNormalizedVersionRule(rangeAnchor);
|
||||
if (rule is not null)
|
||||
{
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
|
||||
}
|
||||
|
||||
private static string? ExtractFirstVersionToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = Regex.Match(value, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?");
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Normalization.SemVer;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertBund.Internal;
|
||||
|
||||
@@ -116,23 +118,9 @@ internal static class CertBundMapper
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
var ranges = string.IsNullOrWhiteSpace(product.Versions)
|
||||
? Array.Empty<AffectedVersionRange>()
|
||||
: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "string",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: product.Versions,
|
||||
provenance: new AdvisoryProvenance(
|
||||
CertBundConnectorPlugin.SourceName,
|
||||
"package-range",
|
||||
product.Versions,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges }))
|
||||
};
|
||||
var anchor = $"certbund:{dto.AdvisoryId}:{vendor.ToLowerInvariant().Replace(' ', '-')}";
|
||||
var ranges = BuildVersionRanges(product.Versions, anchor, recordedAt);
|
||||
var normalized = BuildNormalizedVersions(ranges, anchor);
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
@@ -141,7 +129,7 @@ internal static class CertBundMapper
|
||||
versionRanges: ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
|
||||
normalizedVersions: normalized));
|
||||
}
|
||||
|
||||
return packages
|
||||
@@ -150,6 +138,87 @@ internal static class CertBundMapper
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string? versions, string anchor, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(versions)
|
||||
|| string.Equals(versions.Trim(), "alle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var tokens = Regex.Matches(versions, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?")
|
||||
.Select(match => match.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var introduced = tokens.First();
|
||||
var fixedVersion = tokens.Count > 1 ? tokens.Last() : null;
|
||||
|
||||
var vendorExtensions = new Dictionary<string, string>
|
||||
{
|
||||
["certbund.version.raw"] = versions!,
|
||||
["certbund.anchor"] = anchor,
|
||||
};
|
||||
|
||||
var semVer = new SemVerPrimitive(
|
||||
Introduced: introduced,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: fixedVersion,
|
||||
FixedInclusive: true,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: true,
|
||||
ConstraintExpression: null,
|
||||
ExactValue: tokens.Count == 1 ? introduced : null);
|
||||
|
||||
var rangeProvenance = new AdvisoryProvenance(
|
||||
CertBundConnectorPlugin.SourceName,
|
||||
"package-range",
|
||||
anchor,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
|
||||
var primitives = new RangePrimitives(semVer, Nevra: null, Evr: null, VendorExtensions: vendorExtensions);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: introduced,
|
||||
fixedVersion: fixedVersion,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: versions!,
|
||||
provenance: rangeProvenance,
|
||||
primitives: primitives),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
IReadOnlyList<AffectedVersionRange> ranges,
|
||||
string anchor)
|
||||
{
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var rules = new List<NormalizedVersionRule>(ranges.Count);
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var rule = range.ToNormalizedVersionRule(anchor);
|
||||
if (rule is not null)
|
||||
{
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
|
||||
}
|
||||
|
||||
private static string? MapSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
|
||||
|
||||
@@ -35,9 +36,17 @@ public sealed class CccsMapperTests
|
||||
advisory.AdvisoryKey.Should().Be("TEST-001");
|
||||
advisory.Title.Should().Be(dto.Title);
|
||||
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
|
||||
}
|
||||
}
|
||||
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
|
||||
|
||||
var first = advisory.AffectedPackages[0];
|
||||
first.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "1.0");
|
||||
first.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:0" && rule.Value == "1.0");
|
||||
|
||||
var second = advisory.AffectedPackages[1];
|
||||
second.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "2.0");
|
||||
second.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:1" && rule.Value == "2.0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.CertBund.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
@@ -57,16 +58,27 @@ public sealed class CertBundConnectorTests : IAsyncLifetime
|
||||
advisories.Should().HaveCount(1);
|
||||
|
||||
var advisory = advisories[0];
|
||||
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-1234");
|
||||
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
|
||||
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
|
||||
advisory.Language.Should().Be("de");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-1234");
|
||||
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
|
||||
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
|
||||
advisory.Language.Should().Be("de");
|
||||
|
||||
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
|
||||
.Subject;
|
||||
endpoint.VersionRanges.Should().ContainSingle(range =>
|
||||
range.RangeKind == NormalizedVersionSchemes.SemVer &&
|
||||
range.IntroducedVersion == "2023.1" &&
|
||||
range.FixedVersion == "2024.2");
|
||||
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
|
||||
rule.Min == "2023.1" &&
|
||||
rule.Max == "2024.2" &&
|
||||
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
|
||||
pendingDocs!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_ProducesClaimsFromSampleBundle()
|
||||
{
|
||||
var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle");
|
||||
@@ -22,7 +23,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
tarPath,
|
||||
manifestPath,
|
||||
transparencyPath,
|
||||
pipelineVersion: "git:test-sha"),
|
||||
"git:test-sha"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("evidence-bundle-m0", claims.SubjectName);
|
||||
@@ -38,6 +39,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_EnforcesLowercaseTenant()
|
||||
{
|
||||
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
|
||||
@@ -64,4 +66,30 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
|
||||
Assert.Contains("Tenant must be lowercase", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_RequiresTenant()
|
||||
{
|
||||
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
|
||||
var manifest = """
|
||||
{
|
||||
"bundle_id": "test-bundle",
|
||||
"version": "1.0.0",
|
||||
"created": "2025-11-19T00:00:00Z",
|
||||
"scope": "vex"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(tempManifest, manifest);
|
||||
|
||||
var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz");
|
||||
await File.WriteAllTextAsync(tempTar, "dummy");
|
||||
|
||||
var builder = new EvidenceBundleAttestationBuilder();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None));
|
||||
|
||||
Assert.Contains("Tenant must be present", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Attestation;
|
||||
|
||||
public class EvidenceBundleAttestationValidator
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_RejectsMissingTenant()
|
||||
{
|
||||
var bundle = Path.GetTempFileName();
|
||||
var manifest = Path.GetTempFileName();
|
||||
await File.WriteAllTextAsync(bundle, "dummy");
|
||||
await File.WriteAllTextAsync(manifest, "{\"tenant\":\"ACME\"}");
|
||||
|
||||
var builder = new EvidenceBundleAttestationBuilder();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
builder.BuildAsync(new EvidenceBundleAttestationRequest(bundle, manifest, null, "git:test")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.WebService.AirGap;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.AirGap;
|
||||
|
||||
public class AirgapBundleBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_WritesDeterministicNdjson()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var created = DateTimeOffset.Parse("2025-11-01T00:00:00Z");
|
||||
var items = new[]
|
||||
{
|
||||
"b:2",
|
||||
"a:1",
|
||||
"c:3",
|
||||
"a:1" // duplicate should still appear twice to preserve raw cache content
|
||||
};
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-test");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName, created);
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(result.BundlePath);
|
||||
|
||||
Assert.Equal(4, lines.Length);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, lines);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
|
||||
Assert.Equal(4, result.ItemCount);
|
||||
Assert.True(File.Exists(result.ManifestPath));
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(result.ManifestPath);
|
||||
var manifest = System.Text.Json.JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson)!;
|
||||
Assert.Equal(result.Sha256, manifest.BundleSha256);
|
||||
Assert.Equal(4, manifest.Count);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, manifest.Items);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }.Select(v => v.GetDeterministicHash()), manifest.Entries.Select(e => e.Sha256));
|
||||
Assert.Equal(created, manifest.CreatedUtc);
|
||||
|
||||
var manifestJsonFirstRun = manifestJson;
|
||||
var entryTraceJsonFirstRun = await File.ReadAllTextAsync(result.EntryTracePath);
|
||||
|
||||
// Second run should produce identical hash
|
||||
var result2 = await builder.BuildAsync(items, tempDir.FullName, created);
|
||||
Assert.Equal(result.Sha256, result2.Sha256);
|
||||
Assert.Equal(result.ManifestPath, result2.ManifestPath); // paths stable in same directory
|
||||
|
||||
var manifestJsonSecondRun = await File.ReadAllTextAsync(result2.ManifestPath);
|
||||
var entryTraceJsonSecondRun = await File.ReadAllTextAsync(result2.EntryTracePath);
|
||||
Assert.Equal(manifestJsonFirstRun, manifestJsonSecondRun);
|
||||
Assert.Equal(entryTraceJsonFirstRun, entryTraceJsonSecondRun);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HashTestExtensions
|
||||
{
|
||||
public static string GetDeterministicHash(this string content)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
return System.Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.WebService.AirGap;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.AirGap;
|
||||
|
||||
public class AirgapBundleValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Succeeds_ForBuilderOutput()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var validator = new AirgapBundleValidator();
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator");
|
||||
|
||||
try
|
||||
{
|
||||
var items = new[] { "b:2", "a:1" };
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName);
|
||||
|
||||
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
|
||||
|
||||
Assert.True(validation.IsValid, string.Join(";", validation.Errors));
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Fails_WhenManifestTampered()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var validator = new AirgapBundleValidator();
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator-bad");
|
||||
|
||||
try
|
||||
{
|
||||
var items = new[] { "b:2", "a:1" };
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName);
|
||||
|
||||
// Tamper manifest count
|
||||
var manifest = await File.ReadAllTextAsync(result.ManifestPath);
|
||||
manifest = manifest.Replace("\"count\":2", "\"count\":3");
|
||||
await File.WriteAllTextAsync(result.ManifestPath, manifest);
|
||||
|
||||
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
|
||||
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Contains(validation.Errors, e => e.Contains("count", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,99 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierHealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ConcelierHealthEndpointTests(WebApplicationFactory<Program> factory)
|
||||
public HealthWebAppFactory()
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
// Ensure options binder sees required storage values before Program.Main executes.
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "mongodb://localhost:27017/test-health");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
var overrides = new Dictionary<string, string?>
|
||||
{
|
||||
{"Storage:Dsn", "mongodb://localhost:27017/test-health"},
|
||||
{"Storage:Driver", "mongo"},
|
||||
{"Storage:CommandTimeoutSeconds", "30"},
|
||||
{"Telemetry:Enabled", "false"}
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(overrides);
|
||||
});
|
||||
|
||||
builder.UseSetting("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
|
||||
builder.UseSetting("CONCELIER__STORAGE__DRIVER", "mongo");
|
||||
builder.UseSetting("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
builder.UseSetting("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
Storage = new ConcelierOptions.StorageOptions
|
||||
{
|
||||
Dsn = "mongodb://localhost:27017/test-health",
|
||||
Driver = "mongo",
|
||||
CommandTimeoutSeconds = 30
|
||||
},
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(sp => new ConfigureOptions<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.Storage ??= new ConcelierOptions.StorageOptions();
|
||||
opts.Storage.Driver = "mongo";
|
||||
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
|
||||
opts.Storage.CommandTimeoutSeconds = 30;
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
}));
|
||||
services.PostConfigure<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.Storage ??= new ConcelierOptions.StorageOptions();
|
||||
opts.Storage.Driver = "mongo";
|
||||
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
|
||||
opts.Storage.CommandTimeoutSeconds = 30;
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class ConcelierHealthEndpointTests : IClassFixture<HealthWebAppFactory>
|
||||
{
|
||||
private readonly HealthWebAppFactory _factory;
|
||||
|
||||
public ConcelierHealthEndpointTests(HealthWebAppFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Health_requires_tenant_header()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Concelier.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Services;
|
||||
|
||||
public sealed class IncidentFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteReadDelete_RoundTripsIncident()
|
||||
{
|
||||
var temp = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "incident-store-tests", Path.GetRandomFileName()));
|
||||
var options = new ConcelierOptions
|
||||
{
|
||||
Evidence = new ConcelierOptions.EvidenceBundleOptions
|
||||
{
|
||||
RootAbsolute = temp.FullName,
|
||||
DefaultManifestFileName = "manifest.json",
|
||||
DefaultTransparencyFileName = "transparency.json",
|
||||
PipelineVersion = "git:test",
|
||||
},
|
||||
};
|
||||
|
||||
var now = new DateTimeOffset(2025, 11, 25, 12, 0, 0, TimeSpan.Zero);
|
||||
await IncidentFileStore.WriteAsync(options.Evidence!, "tenant-a", "ADV-1", "test-reason", 30, options.Evidence!.PipelineVersion, now, CancellationToken.None);
|
||||
|
||||
var status = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
|
||||
status.Should().NotBeNull();
|
||||
status!.Reason.Should().Be("test-reason");
|
||||
status.Active.Should().BeTrue();
|
||||
status.Tenant.Should().Be("tenant-a");
|
||||
status.AdvisoryKey.Should().Be("ADV-1");
|
||||
status.PipelineVersion.Should().Be("git:test");
|
||||
|
||||
await IncidentFileStore.DeleteAsync(options.Evidence!, "tenant-a", "ADV-1", CancellationToken.None);
|
||||
var afterDelete = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
|
||||
afterDelete.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<CopyOutputSymbolsToOutputDirectory>true</CopyOutputSymbolsToOutputDirectory>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
|
||||
|
||||
@@ -27,6 +27,8 @@ using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using static StellaOps.Concelier.WebService.Program;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Models;
|
||||
@@ -39,6 +41,7 @@ using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.WebService.Jobs;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
using StellaOps.Concelier.WebService;
|
||||
using Xunit.Sdk;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
@@ -73,7 +76,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
PrepareMongoEnvironment();
|
||||
if (TryStartExternalMongo(out var externalConnectionString))
|
||||
if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString))
|
||||
{
|
||||
_factory = new ConcelierApplicationFactory(externalConnectionString);
|
||||
}
|
||||
@@ -381,6 +384,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
|
||||
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
|
||||
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
|
||||
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
|
||||
|
||||
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
|
||||
detailResponse.EnsureSuccessStatusCode();
|
||||
@@ -390,6 +395,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
|
||||
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -713,6 +719,66 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task InternalAttestationVerify_ReturnsClaims()
|
||||
{
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
|
||||
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
|
||||
var manifestPath = Path.Combine(sampleDir, "manifest.json");
|
||||
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
||||
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
|
||||
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
|
||||
|
||||
var claims = JsonSerializer.Deserialize<AttestationClaims>(
|
||||
responseBody,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
Assert.NotNull(claims);
|
||||
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
|
||||
Assert.Equal("git:test-sha", claims.PipelineVersion);
|
||||
Assert.Equal(tarPath, claims.EvidenceBundlePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
|
||||
|
||||
var request = new EvidenceBatchRequest(
|
||||
new[]
|
||||
{
|
||||
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
|
||||
},
|
||||
ObservationLimit: 5,
|
||||
LinksetLimit: 5);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var item = Assert.Single(payload!.Items);
|
||||
Assert.Equal("component-a", item.ComponentId);
|
||||
Assert.Empty(item.Observations);
|
||||
Assert.Empty(item.Linksets);
|
||||
Assert.False(item.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
|
||||
{
|
||||
@@ -1300,7 +1366,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
|
||||
Assert.NotNull(payload);
|
||||
var conflict = Assert.Single(payload!.Conflicts);
|
||||
var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
|
||||
var conflict = Assert.Single(conflicts);
|
||||
Assert.Equal(conflictId, conflict.ConflictId);
|
||||
Assert.Equal("severity", conflict.Explainer.Type);
|
||||
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
||||
@@ -1977,6 +2044,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
|
||||
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
|
||||
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
|
||||
|
||||
var opensslPath = ResolveOpenSsl11Path();
|
||||
if (!string.IsNullOrEmpty(opensslPath))
|
||||
{
|
||||
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
||||
var merged = string.IsNullOrWhiteSpace(currentLd)
|
||||
? opensslPath
|
||||
: string.Join(':', opensslPath, currentLd);
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
@@ -1984,6 +2062,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
|
||||
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
|
||||
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
|
||||
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
|
||||
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
|
||||
{
|
||||
@@ -2002,6 +2084,23 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveOpenSsl11Path()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = Path.GetFullPath(Path.Combine(current, ".."));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
@@ -2035,7 +2134,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
options.Telemetry.EnableMetrics = false;
|
||||
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
_authorityConfigure?.Invoke(options.Authority);
|
||||
|
||||
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
options.Evidence.Root = repoRoot;
|
||||
options.Evidence.RootAbsolute = repoRoot;
|
||||
});
|
||||
|
||||
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
var wwwroot = Path.Combine(contentRoot, "wwwroot");
|
||||
Directory.CreateDirectory(wwwroot);
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
@@ -3093,4 +3202,5 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(map);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user