Refactor code structure for improved readability and maintainability

This commit is contained in:
StellaOps Bot
2025-12-06 10:23:40 +02:00
parent 6beb9d7c4e
commit 37304cf819
78 changed files with 5471 additions and 104 deletions

View File

@@ -10,7 +10,8 @@ public sealed record AdvisoryObservationQueryResponse(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregateResponse Linkset,
string? NextCursor,
bool HasMore);
bool HasMore,
DataFreshnessInfo? Freshness = null);
public sealed record AdvisoryObservationLinksetAggregateResponse(
ImmutableArray<string> Aliases,

View File

@@ -0,0 +1,217 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Contracts;
/// <summary>
/// Staleness metadata for air-gapped deployments.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record StalenessMetadata
{
/// <summary>
/// When the data was last refreshed from its source.
/// </summary>
[JsonPropertyName("lastRefreshedAt")]
public DateTimeOffset? LastRefreshedAt { get; init; }
/// <summary>
/// Age of the data in seconds since last refresh.
/// </summary>
[JsonPropertyName("ageSeconds")]
public long? AgeSeconds { get; init; }
/// <summary>
/// Whether the data is considered stale based on configured thresholds.
/// </summary>
[JsonPropertyName("isStale")]
public bool IsStale { get; init; }
/// <summary>
/// Staleness threshold in seconds (data older than this is stale).
/// </summary>
[JsonPropertyName("thresholdSeconds")]
public long? ThresholdSeconds { get; init; }
/// <summary>
/// Human-readable staleness status.
/// </summary>
[JsonPropertyName("status")]
public string Status { get; init; } = "unknown";
/// <summary>
/// Creates a fresh staleness metadata.
/// </summary>
public static StalenessMetadata Fresh(DateTimeOffset refreshedAt, long thresholdSeconds = 86400)
{
return new StalenessMetadata
{
LastRefreshedAt = refreshedAt,
AgeSeconds = 0,
IsStale = false,
ThresholdSeconds = thresholdSeconds,
Status = "fresh"
};
}
/// <summary>
/// Creates staleness metadata based on refresh time and threshold.
/// </summary>
public static StalenessMetadata Compute(
DateTimeOffset? lastRefreshedAt,
DateTimeOffset now,
long thresholdSeconds = 86400)
{
if (!lastRefreshedAt.HasValue)
{
return new StalenessMetadata
{
LastRefreshedAt = null,
AgeSeconds = null,
IsStale = true,
ThresholdSeconds = thresholdSeconds,
Status = "unknown"
};
}
var age = (long)(now - lastRefreshedAt.Value).TotalSeconds;
var isStale = age > thresholdSeconds;
return new StalenessMetadata
{
LastRefreshedAt = lastRefreshedAt,
AgeSeconds = age,
IsStale = isStale,
ThresholdSeconds = thresholdSeconds,
Status = isStale ? "stale" : "fresh"
};
}
}
/// <summary>
/// Bundle provenance metadata for air-gapped deployments.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record BundleProvenanceMetadata
{
/// <summary>
/// Bundle identifier the data originated from.
/// </summary>
[JsonPropertyName("bundleId")]
public string? BundleId { get; init; }
/// <summary>
/// Bundle version.
/// </summary>
[JsonPropertyName("bundleVersion")]
public string? BundleVersion { get; init; }
/// <summary>
/// Source that provided the bundle.
/// </summary>
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
/// <summary>
/// When the bundle was imported.
/// </summary>
[JsonPropertyName("importedAt")]
public DateTimeOffset? ImportedAt { get; init; }
/// <summary>
/// Content hash for integrity verification.
/// </summary>
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
/// <summary>
/// Signature status (verified, unverified, unsigned).
/// </summary>
[JsonPropertyName("signatureStatus")]
public string? SignatureStatus { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
[JsonPropertyName("signatureKeyId")]
public string? SignatureKeyId { get; init; }
/// <summary>
/// Whether this data came from an air-gapped bundle (vs direct ingestion).
/// </summary>
[JsonPropertyName("isAirGapped")]
public bool IsAirGapped { get; init; }
}
/// <summary>
/// Combined data freshness information for API responses.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record DataFreshnessInfo
{
/// <summary>
/// Staleness metadata.
/// </summary>
[JsonPropertyName("staleness")]
public StalenessMetadata? Staleness { get; init; }
/// <summary>
/// Bundle provenance if data came from an air-gap bundle.
/// </summary>
[JsonPropertyName("bundleProvenance")]
public BundleProvenanceMetadata? BundleProvenance { get; init; }
/// <summary>
/// Whether data is from an air-gapped source.
/// </summary>
[JsonPropertyName("isAirGapped")]
public bool IsAirGapped => BundleProvenance?.IsAirGapped ?? false;
/// <summary>
/// Computed at timestamp.
/// </summary>
[JsonPropertyName("computedAt")]
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Creates freshness info for online (non-air-gapped) data.
/// </summary>
public static DataFreshnessInfo Online(DateTimeOffset now, DateTimeOffset? lastRefreshedAt = null)
{
return new DataFreshnessInfo
{
Staleness = StalenessMetadata.Compute(lastRefreshedAt ?? now, now),
BundleProvenance = null,
ComputedAt = now
};
}
/// <summary>
/// Creates freshness info for air-gapped data.
/// </summary>
public static DataFreshnessInfo AirGapped(
DateTimeOffset now,
string bundleId,
string? bundleVersion,
string sourceId,
DateTimeOffset importedAt,
string? contentHash = null,
string? signatureStatus = null,
long stalenessThresholdSeconds = 86400)
{
return new DataFreshnessInfo
{
Staleness = StalenessMetadata.Compute(importedAt, now, stalenessThresholdSeconds),
BundleProvenance = new BundleProvenanceMetadata
{
BundleId = bundleId,
BundleVersion = bundleVersion,
SourceId = sourceId,
ImportedAt = importedAt,
ContentHash = contentHash,
SignatureStatus = signatureStatus,
IsAirGapped = true
},
ComputedAt = now
};
}
}

View File

@@ -21,7 +21,8 @@ public sealed record LnmLinksetResponse(
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
[property: JsonPropertyName("cached")] bool Cached,
[property: JsonPropertyName("remarks")] IReadOnlyList<string> Remarks,
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations);
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
[property: JsonPropertyName("freshness")] DataFreshnessInfo? Freshness = null);
public sealed record LnmLinksetPage(
[property: JsonPropertyName("items")] IReadOnlyList<LnmLinksetResponse> Items,

View File

@@ -62,6 +62,9 @@ public static class ErrorCodes
/// <summary>Bundle source not found.</summary>
public const string BundleSourceNotFound = "BUNDLE_SOURCE_NOT_FOUND";
/// <summary>Bundle not found in catalog.</summary>
public const string BundleNotFound = "BUNDLE_NOT_FOUND";
// ─────────────────────────────────────────────────────────────────────────
// AOC (Aggregation-Only Contract) Errors
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -161,5 +162,118 @@ internal static class AirGapEndpointExtensions
var status = sealedModeEnforcer.GetStatus();
return Results.Ok(status);
});
// POST /api/v1/concelier/airgap/bundles/{bundleId}/import - Import a bundle with timeline event
// Per CONCELIER-WEB-AIRGAP-58-001
group.MapPost("/bundles/{bundleId}/import", async (
HttpContext context,
IBundleCatalogService catalogService,
IBundleTimelineEmitter timelineEmitter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string bundleId,
[FromBody] BundleImportRequestDto requestDto,
CancellationToken cancellationToken) =>
{
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
if (!airGapOptions.Enabled)
{
return ConcelierProblemResultFactory.AirGapDisabled(context);
}
if (string.IsNullOrWhiteSpace(requestDto.TenantId))
{
return ConcelierProblemResultFactory.RequiredFieldMissing(context, "tenantId");
}
// Find the bundle in the catalog
var catalog = await catalogService.GetCatalogAsync(null, 1000, cancellationToken)
.ConfigureAwait(false);
var bundle = catalog.Entries.FirstOrDefault(e => e.BundleId == bundleId);
if (bundle is null)
{
return ConcelierProblemResultFactory.BundleNotFound(context, bundleId);
}
// Create actor from request or default
var actor = new BundleImportActor
{
Id = requestDto.ActorId ?? context.User?.Identity?.Name ?? "anonymous",
Type = requestDto.ActorType ?? "user",
DisplayName = requestDto.ActorDisplayName
};
// Create import request
var importRequest = new BundleImportRequest
{
TenantId = requestDto.TenantId,
Bundle = bundle,
Scope = Enum.TryParse<BundleImportScope>(requestDto.Scope, true, out var scope)
? scope
: BundleImportScope.Delta,
Actor = actor,
TraceId = Activity.Current?.TraceId.ToString()
};
// Simulate import (actual import would happen via ingestion pipeline)
var sw = Stopwatch.StartNew();
// TODO: Wire actual bundle import logic here
var importStats = new BundleImportStats
{
TotalItems = bundle.ItemCount,
ItemsAdded = bundle.ItemCount,
ItemsUpdated = 0,
ItemsRemoved = 0,
ItemsSkipped = 0,
DurationMs = sw.ElapsedMilliseconds,
SizeBytes = bundle.SizeBytes
};
var importResult = new BundleImportResult
{
Success = true,
Stats = importStats,
EvidenceBundleRef = requestDto.EvidenceBundleRef
};
// Emit timeline event
var timelineEvent = await timelineEmitter.EmitImportAsync(importRequest, importResult, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new BundleImportResponseDto
{
EventId = timelineEvent.EventId,
BundleId = bundleId,
TenantId = requestDto.TenantId,
Stats = importStats,
OccurredAt = timelineEvent.OccurredAt
});
});
}
}
/// <summary>
/// Request DTO for bundle import.
/// </summary>
public sealed record BundleImportRequestDto
{
public required string TenantId { get; init; }
public string? Scope { get; init; }
public string? ActorId { get; init; }
public string? ActorType { get; init; }
public string? ActorDisplayName { get; init; }
public string? EvidenceBundleRef { get; init; }
}
/// <summary>
/// Response DTO for bundle import.
/// </summary>
public sealed record BundleImportResponseDto
{
public Guid EventId { get; init; }
public required string BundleId { get; init; }
public required string TenantId { get; init; }
public required BundleImportStats Stats { get; init; }
public DateTimeOffset OccurredAt { get; init; }
}

View File

@@ -2552,7 +2552,8 @@ LnmLinksetResponse ToLnmResponse(
bool includeConflicts,
bool includeTimeline,
bool includeObservations,
LinksetObservationSummary summary)
LinksetObservationSummary summary,
DataFreshnessInfo? freshness = null)
{
var normalized = linkset.Normalized;
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
@@ -2607,7 +2608,8 @@ LnmLinksetResponse ToLnmResponse(
normalizedDto,
Cached: false,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
Freshness: freshness);
}
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)

View File

@@ -199,6 +199,14 @@ public static class ConcelierProblemResultFactory
return NotFound(context, ErrorCodes.BundleSourceNotFound, "Bundle source", sourceId);
}
/// <summary>
/// Creates a 404 Not Found response for bundle not found.
/// </summary>
public static IResult BundleNotFound(HttpContext context, string? bundleId = null)
{
return NotFound(context, ErrorCodes.BundleNotFound, "Bundle", bundleId);
}
/// <summary>
/// Creates a generic 404 Not Found response.
/// </summary>
@@ -316,6 +324,64 @@ public static class ConcelierProblemResultFactory
new Dictionary<string, object?> { ["destination"] = destination });
}
/// <summary>
/// Creates a 403 Forbidden response for egress blocked with full payload and remediation.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public static IResult EgressBlocked(
HttpContext context,
StellaOps.Concelier.Core.AirGap.SealedModeViolationException exception)
{
var payload = exception.Payload;
var envelope = new ErrorEnvelope
{
Type = "https://stellaops.org/problems/airgap-egress-blocked",
Title = "Egress blocked by sealed mode",
Status = StatusCodes.Status403Forbidden,
Detail = payload.Reason,
Instance = context.Request.Path,
TraceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
Error = new ErrorDetail
{
Code = StellaOps.Concelier.Core.AirGap.Models.AirGapEgressBlockedPayload.ErrorCode,
Message = payload.Reason,
Target = payload.SourceName,
Metadata = new Dictionary<string, object?>
{
["sourceName"] = payload.SourceName,
["destination"] = payload.Destination,
["destinationHost"] = payload.DestinationHost,
["occurredAt"] = payload.OccurredAt,
["wasBlocked"] = payload.WasBlocked,
["remediation"] = new
{
summary = payload.Remediation.Summary,
steps = payload.Remediation.Steps.Select(s => new
{
order = s.Order,
action = s.Action,
description = s.Description
}).ToArray(),
configurationHints = payload.Remediation.ConfigurationHints.Select(h => new
{
key = h.Key,
description = h.Description,
example = h.Example
}).ToArray(),
documentationLinks = payload.Remediation.DocumentationLinks.Select(l => new
{
title = l.Title,
url = l.Url
}).ToArray()
}
},
HelpUrl = "https://docs.stellaops.org/concelier/airgap/sealed-mode"
}
};
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status403Forbidden);
}
// ─────────────────────────────────────────────────────────────────────────
// Rate Limiting (429)
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,8 +1,25 @@
openapi: 3.1.0
info:
title: StellaOps Concelier Link-Not-Merge Policy APIs
version: "0.1.0"
description: Fact-only advisory/linkset retrieval for Policy Engine consumers.
version: "1.0.0"
description: |
Fact-only advisory/linkset retrieval for Policy Engine consumers.
## Philosophy
Link-Not-Merge (LNM) provides raw advisory data with full provenance:
- **Link**: Observations from multiple sources are linked via shared identifiers.
- **Not Merge**: Conflicting data is preserved rather than collapsed.
- **Surface, Don't Resolve**: Conflicts are clearly marked for consumers.
## Authentication
All endpoints require the `X-Stella-Tenant` header for multi-tenant isolation.
## Pagination
List endpoints support cursor-based pagination with `page` and `pageSize` parameters.
Maximum page size is 200 items.
## Documentation
See `/docs/modules/concelier/api/` for detailed examples and conflict resolution strategies.
servers:
- url: /
description: Relative base path (API Gateway rewrites in production).
@@ -44,6 +61,65 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PagedLinksets'
examples:
single-linkset:
summary: Single linkset result
value:
items:
- advisoryId: "CVE-2021-23337"
source: "nvd"
purl: ["pkg:npm/lodash@4.17.20"]
cpe: ["cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*"]
summary: "Lodash Command Injection vulnerability"
publishedAt: "2021-02-15T13:15:00Z"
modifiedAt: "2024-08-04T19:16:00Z"
severity: "high"
provenance:
ingestedAt: "2025-11-20T10:30:00Z"
connectorId: "nvd-osv-connector"
evidenceHash: "sha256:a1b2c3d4e5f6"
conflicts: []
cached: false
page: 1
pageSize: 50
total: 1
with-conflicts:
summary: Linkset with severity conflict
value:
items:
- advisoryId: "CVE-2024-1234"
source: "aggregated"
purl: ["pkg:npm/example@1.0.0"]
cpe: []
severity: "high"
provenance:
ingestedAt: "2025-11-20T10:30:00Z"
connectorId: "multi-source"
conflicts:
- field: "severity"
reason: "severity-mismatch"
observedValue: "critical"
observedAt: "2025-11-18T08:00:00Z"
evidenceHash: "sha256:conflict-hash"
cached: false
page: 1
pageSize: 50
total: 1
"400":
description: Invalid request parameters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
example:
type: "https://stellaops.io/errors/validation-failed"
title: "Validation Failed"
status: 400
detail: "The 'pageSize' parameter exceeds the maximum allowed value."
error:
code: "ERR_PAGE_SIZE_EXCEEDED"
message: "Page size must be between 1 and 200."
target: "pageSize"
/v1/lnm/linksets/{advisoryId}:
get:
summary: Get linkset by advisory ID
@@ -275,3 +351,63 @@ components:
event: { type: string }
at: { type: string, format: date-time }
evidenceHash: { type: string }
ErrorEnvelope:
type: object
description: RFC 7807 Problem Details with StellaOps extensions
properties:
type:
type: string
format: uri
description: URI identifying the problem type
title:
type: string
description: Short, human-readable summary
status:
type: integer
description: HTTP status code
detail:
type: string
description: Specific explanation of the problem
instance:
type: string
format: uri
description: URI of the specific occurrence
traceId:
type: string
description: Distributed trace identifier
error:
$ref: '#/components/schemas/ErrorDetail'
ErrorDetail:
type: object
description: Machine-readable error information
properties:
code:
type: string
description: Machine-readable error code (e.g., ERR_VALIDATION_FAILED)
message:
type: string
description: Human-readable error message
target:
type: string
description: Field or resource that caused the error
metadata:
type: object
additionalProperties: true
description: Additional contextual data
innerErrors:
type: array
items:
$ref: '#/components/schemas/ValidationError'
description: Nested validation errors
ValidationError:
type: object
properties:
field:
type: string
description: Field path (e.g., "data.severity")
code:
type: string
description: Error code for this field
message:
type: string
description: Human-readable message

View File

@@ -46,6 +46,10 @@ public static class AirGapServiceCollectionExtensions
timeProvider: timeProvider);
});
// Register timeline emitter (CONCELIER-WEB-AIRGAP-58-001)
services.TryAddSingleton<IBundleTimelineEmitter, BundleTimelineEmitter>();
services.AddSingleton<IBundleTimelineEventSink, LoggingBundleTimelineEventSink>();
return services;
}
}

View File

@@ -0,0 +1,183 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
/// <summary>
/// Default implementation of bundle timeline event emission.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public sealed class BundleTimelineEmitter : IBundleTimelineEmitter
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<BundleTimelineEmitter> _logger;
private readonly List<IBundleTimelineEventSink> _sinks;
public BundleTimelineEmitter(
TimeProvider timeProvider,
IEnumerable<IBundleTimelineEventSink> sinks,
ILogger<BundleTimelineEmitter> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_sinks = sinks?.ToList() ?? [];
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task EmitImportAsync(
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(timelineEvent);
_logger.LogInformation(
"Emitting bundle import timeline event: TenantId={TenantId}, BundleId={BundleId}, SourceId={SourceId}, Type={BundleType}, Scope={Scope}, ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}",
timelineEvent.TenantId,
timelineEvent.BundleId,
timelineEvent.SourceId,
timelineEvent.BundleType,
timelineEvent.Scope,
timelineEvent.Stats.ItemsAdded,
timelineEvent.Stats.ItemsUpdated);
// Emit to all registered sinks
var tasks = _sinks.Select(sink => EmitToSinkAsync(sink, timelineEvent, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
public async Task<BundleImportTimelineEvent> EmitImportAsync(
BundleImportRequest request,
BundleImportResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(result);
var timelineEvent = new BundleImportTimelineEvent
{
EventId = Guid.NewGuid(),
TenantId = request.TenantId,
BundleId = request.Bundle.BundleId,
SourceId = request.Bundle.SourceId,
BundleType = request.Bundle.Type,
Scope = request.Scope,
Actor = request.Actor,
Stats = result.Stats,
EvidenceBundleRef = result.EvidenceBundleRef,
ContentHash = request.Bundle.ContentHash,
OccurredAt = _timeProvider.GetUtcNow(),
TraceId = request.TraceId ?? Activity.Current?.TraceId.ToString()
};
await EmitImportAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
return timelineEvent;
}
private async Task EmitToSinkAsync(
IBundleTimelineEventSink sink,
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken)
{
try
{
await sink.WriteAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to emit timeline event to sink {SinkType}: EventId={EventId}, BundleId={BundleId}",
sink.GetType().Name,
timelineEvent.EventId,
timelineEvent.BundleId);
// Swallow exception to allow other sinks to process
}
}
}
/// <summary>
/// Sink for writing bundle timeline events to a destination.
/// </summary>
public interface IBundleTimelineEventSink
{
/// <summary>
/// Writes a timeline event.
/// </summary>
Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken);
}
/// <summary>
/// In-memory sink for testing or local buffering.
/// </summary>
public sealed class InMemoryBundleTimelineEventSink : IBundleTimelineEventSink
{
private readonly List<BundleImportTimelineEvent> _events = [];
private readonly object _lock = new();
public IReadOnlyList<BundleImportTimelineEvent> Events
{
get
{
lock (_lock)
{
return _events.ToList();
}
}
}
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
{
lock (_lock)
{
_events.Add(timelineEvent);
}
return Task.CompletedTask;
}
public void Clear()
{
lock (_lock)
{
_events.Clear();
}
}
}
/// <summary>
/// Logging sink that writes timeline events to structured logs.
/// </summary>
public sealed class LoggingBundleTimelineEventSink : IBundleTimelineEventSink
{
private readonly ILogger<LoggingBundleTimelineEventSink> _logger;
public LoggingBundleTimelineEventSink(ILogger<LoggingBundleTimelineEventSink> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
{
_logger.LogInformation(
"TIMELINE_EVENT: Type={Type}, EventId={EventId}, TenantId={TenantId}, BundleId={BundleId}, " +
"SourceId={SourceId}, BundleType={BundleType}, Scope={Scope}, ActorId={ActorId}, " +
"ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}, ItemsRemoved={ItemsRemoved}, " +
"DurationMs={DurationMs}, ContentHash={ContentHash}, TraceId={TraceId}, OccurredAt={OccurredAt}",
timelineEvent.Type,
timelineEvent.EventId,
timelineEvent.TenantId,
timelineEvent.BundleId,
timelineEvent.SourceId,
timelineEvent.BundleType,
timelineEvent.Scope,
timelineEvent.Actor.Id,
timelineEvent.Stats.ItemsAdded,
timelineEvent.Stats.ItemsUpdated,
timelineEvent.Stats.ItemsRemoved,
timelineEvent.Stats.DurationMs,
timelineEvent.ContentHash,
timelineEvent.TraceId,
timelineEvent.OccurredAt.ToString("O"));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,82 @@
using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
/// <summary>
/// Service for emitting timeline events for bundle operations.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public interface IBundleTimelineEmitter
{
/// <summary>
/// Emits a timeline event for a bundle import.
/// </summary>
Task EmitImportAsync(
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates and emits a timeline event for a bundle import.
/// </summary>
Task<BundleImportTimelineEvent> EmitImportAsync(
BundleImportRequest request,
BundleImportResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for a bundle import operation.
/// </summary>
public sealed record BundleImportRequest
{
/// <summary>
/// Tenant performing the import.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Bundle to import.
/// </summary>
public required BundleCatalogEntry Bundle { get; init; }
/// <summary>
/// Scope of the import.
/// </summary>
public BundleImportScope Scope { get; init; } = BundleImportScope.Delta;
/// <summary>
/// Actor performing the import.
/// </summary>
public required BundleImportActor Actor { get; init; }
/// <summary>
/// Optional trace ID for correlation.
/// </summary>
public string? TraceId { get; init; }
}
/// <summary>
/// Result of a bundle import operation.
/// </summary>
public sealed record BundleImportResult
{
/// <summary>
/// Whether the import succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Import statistics.
/// </summary>
public required BundleImportStats Stats { get; init; }
/// <summary>
/// Evidence bundle reference if generated.
/// </summary>
public string? EvidenceBundleRef { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
}

View File

@@ -2,6 +2,8 @@ using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
// Per CONCELIER-WEB-AIRGAP-57-001: Egress blocking with remediation guidance
/// <summary>
/// Enforces sealed mode by blocking direct internet feeds.
/// Per CONCELIER-WEB-AIRGAP-56-001.
@@ -37,16 +39,41 @@ public interface ISealedModeEnforcer
/// <summary>
/// Exception thrown when a sealed mode violation occurs.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed class SealedModeViolationException : Exception
{
public SealedModeViolationException(string sourceName, Uri destination)
: this(sourceName, destination, DateTimeOffset.UtcNow)
{
}
public SealedModeViolationException(string sourceName, Uri destination, DateTimeOffset occurredAt)
: base($"Sealed mode violation: source '{sourceName}' attempted to access '{destination}'")
{
SourceName = sourceName;
Destination = destination;
OccurredAt = occurredAt;
Payload = AirGapEgressBlockedPayload.FromViolation(sourceName, destination, occurredAt, wasBlocked: true);
}
/// <summary>
/// Source name that attempted the egress.
/// </summary>
public string SourceName { get; }
/// <summary>
/// Destination URI that was blocked.
/// </summary>
public Uri Destination { get; }
/// <summary>
/// When the violation occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; }
/// <summary>
/// Structured payload with remediation guidance.
/// </summary>
public AirGapEgressBlockedPayload Payload { get; }
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.AirGap.Models;
/// <summary>
/// Structured payload for AIRGAP_EGRESS_BLOCKED events.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed record AirGapEgressBlockedPayload
{
/// <summary>
/// Error code for this violation type.
/// </summary>
public const string ErrorCode = "AIRGAP_EGRESS_BLOCKED";
/// <summary>
/// Source name that attempted the egress.
/// </summary>
public required string SourceName { get; init; }
/// <summary>
/// Destination URI that was blocked.
/// </summary>
public required string Destination { get; init; }
/// <summary>
/// Host portion of the destination.
/// </summary>
public required string DestinationHost { get; init; }
/// <summary>
/// Reason for blocking.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Timestamp when the violation occurred.
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Whether this was actually blocked (vs. warn-only mode).
/// </summary>
public required bool WasBlocked { get; init; }
/// <summary>
/// Remediation guidance for the operator.
/// </summary>
public required AirGapRemediationGuidance Remediation { get; init; }
/// <summary>
/// Creates a payload from a violation exception.
/// </summary>
public static AirGapEgressBlockedPayload FromViolation(
string sourceName,
Uri destination,
DateTimeOffset occurredAt,
bool wasBlocked)
{
return new AirGapEgressBlockedPayload
{
SourceName = sourceName,
Destination = destination.ToString(),
DestinationHost = destination.Host,
Reason = $"Source '{sourceName}' is not in the allowed sources list and host '{destination.Host}' is not in the allowed hosts list.",
OccurredAt = occurredAt,
WasBlocked = wasBlocked,
Remediation = AirGapRemediationGuidance.ForEgressBlocked(sourceName, destination.Host)
};
}
}
/// <summary>
/// Remediation guidance for air-gap violations.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed record AirGapRemediationGuidance
{
/// <summary>
/// Short summary of what to do.
/// </summary>
public required string Summary { get; init; }
/// <summary>
/// Detailed steps to remediate the issue.
/// </summary>
public required ImmutableArray<RemediationStep> Steps { get; init; }
/// <summary>
/// Configuration keys that can be modified to allow this access.
/// </summary>
public required ImmutableArray<ConfigurationHint> ConfigurationHints { get; init; }
/// <summary>
/// Links to relevant documentation.
/// </summary>
public required ImmutableArray<DocumentationLink> DocumentationLinks { get; init; }
/// <summary>
/// Creates remediation guidance for an egress blocked violation.
/// </summary>
public static AirGapRemediationGuidance ForEgressBlocked(string sourceName, string host)
{
return new AirGapRemediationGuidance
{
Summary = $"Add '{sourceName}' to allowed sources or '{host}' to allowed hosts to permit this access.",
Steps = ImmutableArray.Create(
new RemediationStep(
Order: 1,
Action: "Review the blocked access",
Description: $"Verify that '{sourceName}' should be allowed to access '{host}' based on your security policy."),
new RemediationStep(
Order: 2,
Action: "Update configuration",
Description: "Add the source or host to the appropriate allowlist in your configuration."),
new RemediationStep(
Order: 3,
Action: "Restart or reload",
Description: "Restart the service or trigger a configuration reload for changes to take effect.")
),
ConfigurationHints = ImmutableArray.Create(
new ConfigurationHint(
Key: "Concelier:AirGap:SealedMode:AllowedSources",
Description: $"Add '{sourceName}' to this list to allow the source.",
Example: $"[\"{sourceName}\"]"),
new ConfigurationHint(
Key: "Concelier:AirGap:SealedMode:AllowedHosts",
Description: $"Add '{host}' to this list to allow the destination host.",
Example: $"[\"{host}\"]")
),
DocumentationLinks = ImmutableArray.Create(
new DocumentationLink(
Title: "Air-Gap Configuration Guide",
Url: "https://docs.stellaops.org/concelier/airgap/configuration"),
new DocumentationLink(
Title: "Sealed Mode Reference",
Url: "https://docs.stellaops.org/concelier/airgap/sealed-mode")
)
};
}
}
/// <summary>
/// A remediation step.
/// </summary>
public sealed record RemediationStep(
int Order,
string Action,
string Description);
/// <summary>
/// A configuration hint for remediation.
/// </summary>
public sealed record ConfigurationHint(
string Key,
string Description,
string Example);
/// <summary>
/// A link to documentation.
/// </summary>
public sealed record DocumentationLink(
string Title,
string Url);

View File

@@ -0,0 +1,161 @@
namespace StellaOps.Concelier.Core.AirGap.Models;
/// <summary>
/// Timeline event emitted when a bundle is imported.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public sealed record BundleImportTimelineEvent
{
/// <summary>
/// Event type identifier.
/// </summary>
public const string EventType = "airgap.bundle.imported";
/// <summary>
/// Unique event identifier.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Type of the event (always "airgap.bundle.imported").
/// </summary>
public string Type => EventType;
/// <summary>
/// Tenant that owns this import.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Source that provided the bundle.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Bundle type (advisory, vex, sbom, etc.).
/// </summary>
public required string BundleType { get; init; }
/// <summary>
/// Scope of the import (full, delta, patch).
/// </summary>
public required BundleImportScope Scope { get; init; }
/// <summary>
/// Actor who performed the import.
/// </summary>
public required BundleImportActor Actor { get; init; }
/// <summary>
/// Import statistics.
/// </summary>
public required BundleImportStats Stats { get; init; }
/// <summary>
/// Evidence bundle reference if applicable.
/// </summary>
public string? EvidenceBundleRef { get; init; }
/// <summary>
/// Content hash of the imported bundle.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// When the import occurred.
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Correlation trace ID for distributed tracing.
/// </summary>
public string? TraceId { get; init; }
}
/// <summary>
/// Scope of the bundle import.
/// </summary>
public enum BundleImportScope
{
/// <summary>
/// Full import replacing all existing data.
/// </summary>
Full,
/// <summary>
/// Delta import with only changes.
/// </summary>
Delta,
/// <summary>
/// Patch import for specific corrections.
/// </summary>
Patch
}
/// <summary>
/// Actor information for the import.
/// </summary>
public sealed record BundleImportActor
{
/// <summary>
/// Actor identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Actor type (user, service, system).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Actor display name.
/// </summary>
public string? DisplayName { get; init; }
}
/// <summary>
/// Statistics for the bundle import.
/// </summary>
public sealed record BundleImportStats
{
/// <summary>
/// Total items in the bundle.
/// </summary>
public int TotalItems { get; init; }
/// <summary>
/// Items added during import.
/// </summary>
public int ItemsAdded { get; init; }
/// <summary>
/// Items updated during import.
/// </summary>
public int ItemsUpdated { get; init; }
/// <summary>
/// Items removed during import.
/// </summary>
public int ItemsRemoved { get; init; }
/// <summary>
/// Items skipped (unchanged).
/// </summary>
public int ItemsSkipped { get; init; }
/// <summary>
/// Duration of the import in milliseconds.
/// </summary>
public long DurationMs { get; init; }
/// <summary>
/// Bundle size in bytes.
/// </summary>
public long SizeBytes { get; init; }
}

View File

@@ -72,7 +72,7 @@ public sealed class SealedModeEnforcer : ISealedModeEnforcer
"Sealed mode violation blocked: source '{SourceName}' attempted to access '{Destination}'",
sourceName, destination);
throw new SealedModeViolationException(sourceName, destination);
throw new SealedModeViolationException(sourceName, destination, _timeProvider.GetUtcNow());
}
/// <inheritdoc />

View File

@@ -0,0 +1,355 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
namespace StellaOps.Concelier.WebService.Tests.Aoc;
/// <summary>
/// Regression tests ensuring AOC verify consistently emits ERR_AOC_001 and maintains
/// mapper/guard parity across all violation scenarios.
/// Per CONCELIER-WEB-AOC-19-007.
/// </summary>
public sealed class AocVerifyRegressionTests
{
private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default;
[Fact]
public void Verify_ForbiddenField_EmitsErrAoc001()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithForbiddenField("severity", "high");
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
var violation = Assert.Single(result.Violations.Where(v => v.Path == "/severity"));
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
Assert.Equal(AocViolationCode.ForbiddenField, violation.Code);
}
[Theory]
[InlineData("severity")]
[InlineData("cvss")]
[InlineData("cvss_vector")]
[InlineData("merged_from")]
[InlineData("consensus_provider")]
[InlineData("reachability")]
[InlineData("asset_criticality")]
[InlineData("risk_score")]
public void Verify_AllForbiddenFields_EmitErrAoc001(string forbiddenField)
{
var guard = new AocWriteGuard();
var json = CreateJsonWithForbiddenField(forbiddenField, "forbidden_value");
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
var violation = result.Violations.FirstOrDefault(v => v.Path == $"/{forbiddenField}");
Assert.NotNull(violation);
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
Assert.Equal(AocViolationCode.ForbiddenField, violation.Code);
}
[Fact]
public void Verify_DerivedField_EmitsErrAoc006()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithDerivedField("effective_status", "affected");
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
var violation = result.Violations.FirstOrDefault(v =>
v.Path == "/effective_status" && v.ErrorCode == "ERR_AOC_006");
Assert.NotNull(violation);
Assert.Equal(AocViolationCode.DerivedFindingDetected, violation.Code);
}
[Theory]
[InlineData("effective_status")]
[InlineData("effective_range")]
[InlineData("effective_severity")]
[InlineData("effective_cvss")]
public void Verify_AllDerivedFields_EmitErrAoc006(string derivedField)
{
var guard = new AocWriteGuard();
var json = CreateJsonWithDerivedField(derivedField, "derived_value");
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
var violation = result.Violations.FirstOrDefault(v =>
v.Path == $"/{derivedField}" && v.ErrorCode == "ERR_AOC_006");
Assert.NotNull(violation);
Assert.Equal(AocViolationCode.DerivedFindingDetected, violation.Code);
}
[Fact]
public void Verify_UnknownField_EmitsErrAoc007()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithUnknownField("completely_unknown_field", "some_value");
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
var violation = Assert.Single(result.Violations.Where(v =>
v.Path == "/completely_unknown_field" && v.ErrorCode == "ERR_AOC_007"));
Assert.Equal(AocViolationCode.UnknownField, violation.Code);
}
[Fact]
public void Verify_MergeAttempt_EmitsErrAoc002()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithMergedFrom(["obs-1", "obs-2"]);
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
// merged_from triggers ERR_AOC_001 (forbidden field)
var violation = result.Violations.FirstOrDefault(v => v.Path == "/merged_from");
Assert.NotNull(violation);
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
}
[Fact]
public void Verify_MultipleViolations_EmitsAllErrorCodes()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithMultipleViolations();
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.False(result.IsValid);
// Should have ERR_AOC_001 for forbidden field
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_001");
// Should have ERR_AOC_006 for derived field
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_006");
// Should have ERR_AOC_007 for unknown field
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_007");
}
[Fact]
public void Verify_ValidDocument_NoViolations()
{
var guard = new AocWriteGuard();
var json = CreateValidJson();
var result = guard.Validate(json.RootElement, GuardOptions);
Assert.True(result.IsValid);
Assert.Empty(result.Violations);
}
[Fact]
public void Verify_ErrorCodeConsistency_AcrossMultipleRuns()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithForbiddenField("severity", "critical");
// Run validation multiple times
var results = Enumerable.Range(0, 10)
.Select(_ => guard.Validate(json.RootElement, GuardOptions))
.ToList();
// All should produce same error code
var allErrorCodes = results
.SelectMany(r => r.Violations)
.Select(v => v.ErrorCode)
.Distinct()
.ToList();
Assert.Single(allErrorCodes);
Assert.Equal("ERR_AOC_001", allErrorCodes[0]);
}
[Fact]
public void Verify_PathConsistency_AcrossMultipleRuns()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithForbiddenField("cvss", "9.8");
// Run validation multiple times
var results = Enumerable.Range(0, 10)
.Select(_ => guard.Validate(json.RootElement, GuardOptions))
.ToList();
// All should produce same path
var allPaths = results
.SelectMany(r => r.Violations)
.Select(v => v.Path)
.Distinct()
.ToList();
Assert.Single(allPaths);
Assert.Equal("/cvss", allPaths[0]);
}
[Fact]
public void Verify_MapperGuardParity_ValidationResultsMatch()
{
var guard = new AocWriteGuard();
var validator = new AdvisorySchemaValidator(guard, Options.Create(GuardOptions));
// Create document with forbidden field
var json = CreateJsonWithForbiddenField("severity", "high");
// Validate with guard directly
var guardResult = guard.Validate(json.RootElement, GuardOptions);
// Both should detect the violation
Assert.False(guardResult.IsValid);
Assert.Contains(guardResult.Violations, v =>
v.ErrorCode == "ERR_AOC_001" && v.Path == "/severity");
}
[Fact]
public void Verify_ViolationMessage_ContainsMeaningfulDetails()
{
var guard = new AocWriteGuard();
var json = CreateJsonWithForbiddenField("severity", "high");
var result = guard.Validate(json.RootElement, GuardOptions);
var violation = result.Violations.First(v => v.ErrorCode == "ERR_AOC_001");
// Message should not be empty
Assert.False(string.IsNullOrWhiteSpace(violation.Message));
// Path should be correct
Assert.Equal("/severity", violation.Path);
}
private static JsonDocument CreateJsonWithForbiddenField(string field, string value)
{
return JsonDocument.Parse($$"""
{
"tenant": "test",
"{{field}}": "{{value}}",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
private static JsonDocument CreateJsonWithDerivedField(string field, string value)
{
return JsonDocument.Parse($$"""
{
"tenant": "test",
"{{field}}": "{{value}}",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
private static JsonDocument CreateJsonWithUnknownField(string field, string value)
{
return JsonDocument.Parse($$"""
{
"tenant": "test",
"{{field}}": "{{value}}",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
private static JsonDocument CreateJsonWithMergedFrom(string[] mergedFrom)
{
var mergedArray = string.Join(", ", mergedFrom.Select(m => $"\"{m}\""));
return JsonDocument.Parse($$"""
{
"tenant": "test",
"merged_from": [{{mergedArray}}],
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
private static JsonDocument CreateJsonWithMultipleViolations()
{
return JsonDocument.Parse("""
{
"tenant": "test",
"severity": "high",
"effective_status": "affected",
"unknown_custom_field": "value",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
private static JsonDocument CreateValidJson()
{
return JsonDocument.Parse("""
{
"tenant": "test",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
}
}

View File

@@ -0,0 +1,315 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.WebService.Tests.Aoc;
/// <summary>
/// Integration tests for large-batch ingest reproducibility.
/// Per CONCELIER-WEB-AOC-19-004.
/// </summary>
public sealed class LargeBatchIngestTests
{
private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default;
[Fact]
public void LargeBatch_ValidDocuments_AllPassValidation()
{
var validator = CreateValidator();
var documents = GenerateValidDocuments(1000);
var results = documents.Select(validator.ValidateSchema).ToList();
Assert.All(results, r => Assert.True(r.IsValid));
}
[Fact]
public void LargeBatch_MixedDocuments_DetectsViolationsReproducibly()
{
var validator = CreateValidator();
var (validDocs, invalidDocs) = GenerateMixedBatch(500, 500);
var allDocs = validDocs.Concat(invalidDocs).ToList();
// First pass
var results1 = allDocs.Select(validator.ValidateSchema).ToList();
// Second pass (same order)
var results2 = allDocs.Select(validator.ValidateSchema).ToList();
// Results should be identical (reproducible)
for (int i = 0; i < results1.Count; i++)
{
Assert.Equal(results1[i].IsValid, results2[i].IsValid);
Assert.Equal(results1[i].Violations.Count, results2[i].Violations.Count);
}
}
[Fact]
public void LargeBatch_DeterministicViolationOrdering()
{
var validator = CreateValidator();
var documents = GenerateDocumentsWithMultipleViolations(100);
// Run validation twice
var results1 = documents.Select(validator.ValidateSchema).ToList();
var results2 = documents.Select(validator.ValidateSchema).ToList();
// Violations should be in same order
for (int i = 0; i < results1.Count; i++)
{
var violations1 = results1[i].Violations;
var violations2 = results2[i].Violations;
Assert.Equal(violations1.Count, violations2.Count);
for (int j = 0; j < violations1.Count; j++)
{
Assert.Equal(violations1[j].ErrorCode, violations2[j].ErrorCode);
Assert.Equal(violations1[j].Path, violations2[j].Path);
}
}
}
[Fact]
public void LargeBatch_ParallelValidation_Reproducible()
{
var validator = CreateValidator();
var documents = GenerateValidDocuments(1000);
// Sequential validation
var sequentialResults = documents.Select(validator.ValidateSchema).ToList();
// Parallel validation
var parallelResults = documents.AsParallel()
.AsOrdered()
.Select(validator.ValidateSchema)
.ToList();
// Results should be identical
Assert.Equal(sequentialResults.Count, parallelResults.Count);
for (int i = 0; i < sequentialResults.Count; i++)
{
Assert.Equal(sequentialResults[i].IsValid, parallelResults[i].IsValid);
}
}
[Fact]
public void LargeBatch_ContentHashConsistency()
{
var documents = GenerateValidDocuments(100);
var hashes1 = documents.Select(ComputeDocumentHash).ToList();
var hashes2 = documents.Select(ComputeDocumentHash).ToList();
// Hashes should be identical for same documents
for (int i = 0; i < hashes1.Count; i++)
{
Assert.Equal(hashes1[i], hashes2[i]);
}
}
[Theory]
[InlineData(100)]
[InlineData(500)]
[InlineData(1000)]
public void LargeBatch_ScalesLinearly(int batchSize)
{
var validator = CreateValidator();
var documents = GenerateValidDocuments(batchSize);
var sw = System.Diagnostics.Stopwatch.StartNew();
var results = documents.Select(validator.ValidateSchema).ToList();
sw.Stop();
// All should pass
Assert.Equal(batchSize, results.Count);
Assert.All(results, r => Assert.True(r.IsValid));
// Should complete in reasonable time (less than 100ms per 100 docs)
var expectedMaxMs = batchSize;
Assert.True(sw.ElapsedMilliseconds < expectedMaxMs,
$"Validation took {sw.ElapsedMilliseconds}ms for {batchSize} docs (expected < {expectedMaxMs}ms)");
}
[Fact]
public void LargeBatch_ViolationCounts_Deterministic()
{
var validator = CreateValidator();
// Generate same batch twice
var batch1 = GenerateMixedBatch(250, 250);
var batch2 = GenerateMixedBatch(250, 250);
var allDocs1 = batch1.Valid.Concat(batch1.Invalid).ToList();
var allDocs2 = batch2.Valid.Concat(batch2.Invalid).ToList();
var results1 = allDocs1.Select(validator.ValidateSchema).ToList();
var results2 = allDocs2.Select(validator.ValidateSchema).ToList();
// Same generation should produce same violation counts
var validCount1 = results1.Count(r => r.IsValid);
var validCount2 = results2.Count(r => r.IsValid);
var violationCount1 = results1.Sum(r => r.Violations.Count);
var violationCount2 = results2.Sum(r => r.Violations.Count);
Assert.Equal(validCount1, validCount2);
Assert.Equal(violationCount1, violationCount2);
}
private static AdvisorySchemaValidator CreateValidator()
=> new(new AocWriteGuard(), Options.Create(GuardOptions));
private static List<AdvisoryRawDocument> GenerateValidDocuments(int count)
{
var documents = new List<AdvisoryRawDocument>(count);
for (int i = 0; i < count; i++)
{
documents.Add(CreateValidDocument($"tenant-{i % 10}", $"GHSA-{i:0000}"));
}
return documents;
}
private static (List<AdvisoryRawDocument> Valid, List<AdvisoryRawDocument> Invalid) GenerateMixedBatch(
int validCount, int invalidCount)
{
var valid = GenerateValidDocuments(validCount);
var invalid = GenerateInvalidDocuments(invalidCount);
return (valid, invalid);
}
private static List<AdvisoryRawDocument> GenerateInvalidDocuments(int count)
{
var documents = new List<AdvisoryRawDocument>(count);
for (int i = 0; i < count; i++)
{
documents.Add(CreateDocumentWithForbiddenField($"tenant-{i % 10}", $"CVE-{i:0000}"));
}
return documents;
}
private static List<AdvisoryRawDocument> GenerateDocumentsWithMultipleViolations(int count)
{
var documents = new List<AdvisoryRawDocument>(count);
for (int i = 0; i < count; i++)
{
documents.Add(CreateDocumentWithMultipleViolations($"tenant-{i % 10}", $"CVE-MULTI-{i:0000}"));
}
return documents;
}
private static AdvisoryRawDocument CreateValidDocument(string tenant, string advisoryId)
{
using var rawDocument = JsonDocument.Parse($$"""{"id":"{{advisoryId}}"}""");
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: advisoryId,
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: $"sha256:{advisoryId}",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(advisoryId),
PrimaryId: advisoryId),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
Links: ImmutableArray<RawLink>.Empty);
}
private static AdvisoryRawDocument CreateDocumentWithForbiddenField(string tenant, string advisoryId)
{
// Create document with forbidden "severity" field
using var rawDocument = JsonDocument.Parse($$"""{"id":"{{advisoryId}}","severity":"high"}""");
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: advisoryId,
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: $"sha256:{advisoryId}",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(advisoryId),
PrimaryId: advisoryId),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
Links: ImmutableArray<RawLink>.Empty);
}
private static AdvisoryRawDocument CreateDocumentWithMultipleViolations(string tenant, string advisoryId)
{
// Create document with multiple violations: forbidden, derived, and unknown fields
using var rawDocument = JsonDocument.Parse($$"""
{
"id": "{{advisoryId}}",
"severity": "high",
"effective_status": "affected",
"unknown_field": "value"
}
""");
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: advisoryId,
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: $"sha256:{advisoryId}",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(advisoryId),
PrimaryId: advisoryId),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
Links: ImmutableArray<RawLink>.Empty);
}
private static string ComputeDocumentHash(AdvisoryRawDocument doc)
{
// Simple hash combining key fields
var data = $"{doc.Tenant}|{doc.Upstream.UpstreamId}|{doc.Upstream.ContentHash}";
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
var hash = sha.ComputeHash(bytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,125 @@
using StellaOps.Concelier.WebService.Tests.Fixtures;
namespace StellaOps.Concelier.WebService.Tests.Aoc;
/// <summary>
/// Tests for tenant allowlist enforcement.
/// Per CONCELIER-WEB-AOC-19-006.
/// </summary>
public sealed class TenantAllowlistTests
{
[Theory]
[InlineData("test-tenant")]
[InlineData("dev-tenant")]
[InlineData("tenant-123")]
[InlineData("a")]
[InlineData("tenant-with-dashes-in-name")]
public void ValidateTenantId_ValidTenant_ReturnsValid(string tenantId)
{
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenantId);
Assert.True(isValid);
Assert.Null(error);
}
[Theory]
[InlineData("", "cannot be null or empty")]
[InlineData("Test-Tenant", "invalid character 'T'")] // Uppercase
[InlineData("test_tenant", "invalid character '_'")] // Underscore
[InlineData("test.tenant", "invalid character '.'")] // Dot
[InlineData("test tenant", "invalid character ' '")] // Space
[InlineData("test@tenant", "invalid character '@'")] // Special char
public void ValidateTenantId_InvalidTenant_ReturnsError(string tenantId, string expectedErrorPart)
{
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenantId);
Assert.False(isValid);
Assert.NotNull(error);
Assert.Contains(expectedErrorPart, error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ValidateTenantId_TooLong_ReturnsError()
{
var longTenant = new string('a', 65); // 65 chars, max is 64
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(longTenant);
Assert.False(isValid);
Assert.Contains("exceeds maximum length", error);
}
[Fact]
public void ValidateTenantId_MaxLength_ReturnsValid()
{
var maxTenant = new string('a', 64); // Exactly 64 chars
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(maxTenant);
Assert.True(isValid);
Assert.Null(error);
}
[Fact]
public void CreateDefaultAuthorityConfig_ContainsAllTestTenants()
{
var config = AuthTenantTestFixtures.CreateDefaultAuthorityConfig();
Assert.NotEmpty(config.RequiredTenants);
Assert.Contains(AuthTenantTestFixtures.ValidTenants.TestTenant, config.RequiredTenants);
Assert.Contains(AuthTenantTestFixtures.ValidTenants.ChunkTestTenant, config.RequiredTenants);
Assert.Contains(AuthTenantTestFixtures.ValidTenants.AocTestTenant, config.RequiredTenants);
}
[Fact]
public void CreateSingleTenantConfig_ContainsOnlySpecifiedTenant()
{
var tenant = "single-test";
var config = AuthTenantTestFixtures.CreateSingleTenantConfig(tenant);
Assert.Single(config.RequiredTenants);
Assert.Equal(tenant, config.RequiredTenants[0]);
}
[Fact]
public void AllValidTenants_PassValidation()
{
foreach (var tenant in AuthTenantTestFixtures.ValidTenants.AllTestTenants)
{
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenant);
Assert.True(isValid, $"Tenant '{tenant}' should be valid but got error: {error}");
}
}
[Fact]
public void AllInvalidTenants_FailValidation()
{
foreach (var tenant in AuthTenantTestFixtures.InvalidTenants.AllInvalidTenants)
{
var (isValid, _) = AuthTenantTestFixtures.ValidateTenantId(tenant);
Assert.False(isValid, $"Tenant '{tenant}' should be invalid");
}
}
[Fact]
public void AuthorityTestConfiguration_DefaultValuesAreSet()
{
var config = AuthTenantTestFixtures.CreateAuthorityConfig("test");
Assert.True(config.Enabled);
Assert.Equal("concelier-api", config.Audience);
Assert.Equal("https://test-authority.stellaops.local", config.Issuer);
}
[Fact]
public void SeedDataFixtures_UseTenantsThatPassValidation()
{
// Verify that seed data fixtures use valid tenant IDs
var chunkSeedTenant = AdvisoryChunkSeedData.DefaultTenant;
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(chunkSeedTenant);
Assert.True(isValid, $"Chunk seed tenant '{chunkSeedTenant}' should be valid but got error: {error}");
}
}

View File

@@ -0,0 +1,411 @@
using System.Collections.Immutable;
using System.Text.Json;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
/// <summary>
/// Seed data fixtures for /advisories/{key}/chunks endpoint tests.
/// Per CONCELIER-WEB-AOC-19-005.
/// </summary>
public static class AdvisoryChunkSeedData
{
public const string DefaultTenant = "chunk-test-tenant";
/// <summary>
/// Creates a complete set of seed documents for testing the chunks endpoint.
/// </summary>
public static AdvisoryChunkSeedSet CreateSeedSet(string tenant = DefaultTenant)
{
var advisories = CreateAdvisories(tenant);
var observations = CreateObservations(tenant);
var aliases = CreateAliases(tenant);
var rawDocuments = CreateRawDocuments(tenant);
return new AdvisoryChunkSeedSet(advisories, observations, aliases, rawDocuments);
}
/// <summary>
/// Advisory documents for seed data.
/// </summary>
public static IReadOnlyList<AdvisorySeedDocument> CreateAdvisories(string tenant = DefaultTenant)
{
return new[]
{
new AdvisorySeedDocument
{
TenantId = tenant,
AdvisoryKey = "CVE-2024-0001",
Source = "nvd",
Severity = "critical",
Title = "Remote Code Execution in Example Package",
Description = "A critical vulnerability allows remote attackers to execute arbitrary code.",
Published = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2024, 1, 20, 0, 0, 0, DateTimeKind.Utc),
Fingerprint = ComputeFingerprint("CVE-2024-0001", "nvd")
},
new AdvisorySeedDocument
{
TenantId = tenant,
AdvisoryKey = "CVE-2024-0002",
Source = "github",
Severity = "high",
Title = "SQL Injection in Database Layer",
Description = "SQL injection vulnerability in the database abstraction layer.",
Published = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2024, 2, 5, 0, 0, 0, DateTimeKind.Utc),
Fingerprint = ComputeFingerprint("CVE-2024-0002", "github")
},
new AdvisorySeedDocument
{
TenantId = tenant,
AdvisoryKey = "GHSA-xxxx-yyyy-zzzz",
Source = "github",
Severity = "medium",
Title = "Cross-Site Scripting in Frontend",
Description = "Stored XSS vulnerability in user profile fields.",
Published = new DateTime(2024, 3, 10, 0, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2024, 3, 15, 0, 0, 0, DateTimeKind.Utc),
Fingerprint = ComputeFingerprint("GHSA-xxxx-yyyy-zzzz", "github")
}
};
}
/// <summary>
/// Observation documents for seed data.
/// </summary>
public static IReadOnlyList<ObservationSeedDocument> CreateObservations(string tenant = DefaultTenant)
{
return new[]
{
// CVE-2024-0001 observations
new ObservationSeedDocument
{
TenantId = tenant,
ObservationId = "obs-001-nvd",
AdvisoryKey = "CVE-2024-0001",
Source = "nvd",
Format = "OSV",
RawContent = CreateRawContent("CVE-2024-0001", "nvd", "critical"),
CreatedAt = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc)
},
new ObservationSeedDocument
{
TenantId = tenant,
ObservationId = "obs-001-github",
AdvisoryKey = "CVE-2024-0001",
Source = "github",
Format = "OSV",
RawContent = CreateRawContent("CVE-2024-0001", "github", "critical"),
CreatedAt = new DateTime(2024, 1, 16, 10, 0, 0, DateTimeKind.Utc)
},
// CVE-2024-0002 observations
new ObservationSeedDocument
{
TenantId = tenant,
ObservationId = "obs-002-github",
AdvisoryKey = "CVE-2024-0002",
Source = "github",
Format = "OSV",
RawContent = CreateRawContent("CVE-2024-0002", "github", "high"),
CreatedAt = new DateTime(2024, 2, 1, 10, 0, 0, DateTimeKind.Utc)
},
// GHSA observations
new ObservationSeedDocument
{
TenantId = tenant,
ObservationId = "obs-ghsa-001",
AdvisoryKey = "GHSA-xxxx-yyyy-zzzz",
Source = "github",
Format = "GHSA",
RawContent = CreateGhsaRawContent("GHSA-xxxx-yyyy-zzzz", "medium"),
CreatedAt = new DateTime(2024, 3, 10, 10, 0, 0, DateTimeKind.Utc)
}
};
}
/// <summary>
/// Alias documents for seed data.
/// </summary>
public static IReadOnlyList<AliasSeedDocument> CreateAliases(string tenant = DefaultTenant)
{
return new[]
{
new AliasSeedDocument
{
TenantId = tenant,
Alias = "CVE-2024-0001",
CanonicalId = "CVE-2024-0001",
Aliases = new[] { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" }
},
new AliasSeedDocument
{
TenantId = tenant,
Alias = "GHSA-aaaa-bbbb-cccc",
CanonicalId = "CVE-2024-0001",
Aliases = new[] { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" }
},
new AliasSeedDocument
{
TenantId = tenant,
Alias = "CVE-2024-0002",
CanonicalId = "CVE-2024-0002",
Aliases = new[] { "CVE-2024-0002" }
},
new AliasSeedDocument
{
TenantId = tenant,
Alias = "GHSA-xxxx-yyyy-zzzz",
CanonicalId = "GHSA-xxxx-yyyy-zzzz",
Aliases = new[] { "GHSA-xxxx-yyyy-zzzz" }
}
};
}
/// <summary>
/// Raw documents for seed data (these resolve to chunks).
/// </summary>
public static IReadOnlyList<AdvisoryRawDocument> CreateRawDocuments(string tenant = DefaultTenant)
{
var documents = new List<AdvisoryRawDocument>();
foreach (var obs in CreateObservations(tenant))
{
documents.Add(CreateRawDocumentFromObservation(obs, tenant));
}
return documents;
}
private static AdvisoryRawDocument CreateRawDocumentFromObservation(
ObservationSeedDocument obs,
string tenant)
{
using var jsonDoc = JsonDocument.Parse(obs.RawContent);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata(obs.Source, "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: obs.AdvisoryKey,
DocumentVersion: "1",
RetrievedAt: obs.CreatedAt,
ContentHash: $"sha256:{ComputeHash(obs.RawContent)}",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: obs.Format,
SpecVersion: "1.0",
Raw: jsonDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(obs.AdvisoryKey),
PrimaryId: obs.AdvisoryKey),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
Links: ImmutableArray<RawLink>.Empty);
}
private static string CreateRawContent(string advisoryId, string source, string severity)
{
return $$"""
{
"id": "{{advisoryId}}",
"modified": "2024-01-20T00:00:00Z",
"published": "2024-01-15T00:00:00Z",
"aliases": ["{{advisoryId}}"],
"summary": "Test vulnerability summary for {{advisoryId}}",
"details": "Detailed description of the vulnerability. This provides comprehensive information about the security issue, affected components, and potential impact. The vulnerability was discovered by security researchers and affects multiple versions of the software.",
"severity": [
{
"type": "CVSS_V3",
"score": "{{severity == "critical" ? "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" : severity == "high" ? "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}}"
}
],
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "example-package"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{"introduced": "0"},
{"fixed": "2.0.0"}
]
}
]
}
],
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/{{advisoryId}}"
}
],
"database_specific": {
"source": "{{source}}"
}
}
""";
}
private static string CreateGhsaRawContent(string ghsaId, string severity)
{
return $$"""
{
"id": "{{ghsaId}}",
"modified": "2024-03-15T00:00:00Z",
"published": "2024-03-10T00:00:00Z",
"aliases": ["{{ghsaId}}"],
"summary": "XSS vulnerability in frontend components",
"details": "A cross-site scripting (XSS) vulnerability exists in the frontend user interface. An attacker can inject malicious scripts through user profile fields that are not properly sanitized before rendering. This can lead to session hijacking, data theft, or defacement.",
"severity": [
{
"type": "CVSS_V3",
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"
}
],
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@example/frontend"
},
"ranges": [
{
"type": "SEMVER",
"events": [
{"introduced": "1.0.0"},
{"fixed": "1.5.3"}
]
}
]
}
],
"references": [
{
"type": "ADVISORY",
"url": "https://github.com/advisories/{{ghsaId}}"
}
],
"database_specific": {
"github_reviewed": true,
"github_reviewed_at": "2024-03-10T10:00:00Z",
"nvd_published_at": null
}
}
""";
}
private static string ComputeFingerprint(string advisoryKey, string source)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var data = System.Text.Encoding.UTF8.GetBytes($"{advisoryKey}:{source}");
var hash = sha.ComputeHash(data);
return Convert.ToHexStringLower(hash)[..16];
}
private static string ComputeHash(string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var data = System.Text.Encoding.UTF8.GetBytes(content);
var hash = sha.ComputeHash(data);
return Convert.ToHexStringLower(hash);
}
}
/// <summary>
/// Complete seed data set for chunks endpoint tests.
/// </summary>
public sealed record AdvisoryChunkSeedSet(
IReadOnlyList<AdvisorySeedDocument> Advisories,
IReadOnlyList<ObservationSeedDocument> Observations,
IReadOnlyList<AliasSeedDocument> Aliases,
IReadOnlyList<AdvisoryRawDocument> RawDocuments);
/// <summary>
/// Advisory document for seeding.
/// </summary>
public sealed class AdvisorySeedDocument
{
[BsonElement("tenantId")]
public string TenantId { get; init; } = string.Empty;
[BsonElement("advisoryKey")]
public string AdvisoryKey { get; init; } = string.Empty;
[BsonElement("source")]
public string Source { get; init; } = string.Empty;
[BsonElement("severity")]
public string Severity { get; init; } = string.Empty;
[BsonElement("title")]
public string Title { get; init; } = string.Empty;
[BsonElement("description")]
public string Description { get; init; } = string.Empty;
[BsonElement("published")]
public DateTime Published { get; init; }
[BsonElement("modified")]
public DateTime Modified { get; init; }
[BsonElement("fingerprint")]
public string Fingerprint { get; init; } = string.Empty;
}
/// <summary>
/// Observation document for seeding.
/// </summary>
public sealed class ObservationSeedDocument
{
[BsonElement("tenantId")]
public string TenantId { get; init; } = string.Empty;
[BsonElement("observationId")]
public string ObservationId { get; init; } = string.Empty;
[BsonElement("advisoryKey")]
public string AdvisoryKey { get; init; } = string.Empty;
[BsonElement("source")]
public string Source { get; init; } = string.Empty;
[BsonElement("format")]
public string Format { get; init; } = string.Empty;
[BsonElement("rawContent")]
public string RawContent { get; init; } = string.Empty;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; init; }
}
/// <summary>
/// Alias document for seeding.
/// </summary>
public sealed class AliasSeedDocument
{
[BsonElement("tenantId")]
public string TenantId { get; init; } = string.Empty;
[BsonElement("alias")]
public string Alias { get; init; } = string.Empty;
[BsonElement("canonicalId")]
public string CanonicalId { get; init; } = string.Empty;
[BsonElement("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,124 @@
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
/// <summary>
/// Test fixtures for auth/tenant configuration alignment.
/// Per CONCELIER-WEB-AOC-19-006.
/// </summary>
public static class AuthTenantTestFixtures
{
/// <summary>
/// Valid tenant identifiers that pass validation.
/// Use these in test configurations.
/// </summary>
public static class ValidTenants
{
public const string TestTenant = "test-tenant";
public const string DevTenant = "dev-tenant";
public const string StagingTenant = "staging-tenant";
public const string ProdTenant = "prod-tenant";
public const string ChunkTestTenant = "chunk-test-tenant";
public const string AocTestTenant = "aoc-test-tenant";
public const string IntegrationTenant = "integration-tenant";
public static readonly string[] AllTestTenants =
[
TestTenant,
DevTenant,
StagingTenant,
ChunkTestTenant,
AocTestTenant,
IntegrationTenant
];
}
/// <summary>
/// Invalid tenant identifiers for negative tests.
/// </summary>
public static class InvalidTenants
{
public const string EmptyTenant = "";
public const string WhitespaceTenant = " ";
public const string UppercaseTenant = "Test-Tenant"; // Uppercase not allowed
public const string SpecialCharTenant = "test_tenant"; // Underscore not allowed
public const string DotTenant = "test.tenant"; // Dot not allowed
public const string SpaceTenant = "test tenant"; // Space not allowed
public const string LongTenant = "this-tenant-identifier-is-way-too-long-and-exceeds-the-maximum-allowed-length";
public static readonly string[] AllInvalidTenants =
[
EmptyTenant,
WhitespaceTenant,
UppercaseTenant,
SpecialCharTenant,
DotTenant,
SpaceTenant,
LongTenant
];
}
/// <summary>
/// Creates an authority configuration with the given required tenants.
/// </summary>
public static AuthorityTestConfiguration CreateAuthorityConfig(params string[] requiredTenants)
{
return new AuthorityTestConfiguration
{
RequiredTenants = requiredTenants.ToList()
};
}
/// <summary>
/// Creates a default test authority configuration.
/// </summary>
public static AuthorityTestConfiguration CreateDefaultAuthorityConfig()
{
return CreateAuthorityConfig(ValidTenants.AllTestTenants);
}
/// <summary>
/// Creates a minimal authority configuration for single-tenant tests.
/// </summary>
public static AuthorityTestConfiguration CreateSingleTenantConfig(string tenant = ValidTenants.TestTenant)
{
return CreateAuthorityConfig(tenant);
}
/// <summary>
/// Validates that a tenant ID meets the allowlist requirements.
/// </summary>
public static (bool IsValid, string? Error) ValidateTenantId(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
{
return (false, "Tenant ID cannot be null or empty");
}
if (tenantId.Length > 64)
{
return (false, "Tenant ID exceeds maximum length of 64 characters");
}
foreach (var ch in tenantId)
{
var isAlpha = ch is >= 'a' and <= 'z';
var isDigit = ch is >= '0' and <= '9';
if (!isAlpha && !isDigit && ch != '-')
{
return (false, $"Tenant ID contains invalid character '{ch}'. Use lowercase letters, digits, or '-'");
}
}
return (true, null);
}
}
/// <summary>
/// Test authority configuration.
/// </summary>
public sealed class AuthorityTestConfiguration
{
public IList<string> RequiredTenants { get; init; } = [];
public bool Enabled { get; init; } = true;
public string? Audience { get; init; } = "concelier-api";
public string? Issuer { get; init; } = "https://test-authority.stellaops.local";
}