Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled
- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle. - Added PqSoftProviderOptions and PqSoftKeyOptions for configuration. - Created unit tests for Dilithium3 and Falcon512 signing and verification. - Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists. - Added KcmvpHashOnlyProvider for KCMVP baseline compliance. - Updated project files and dependencies for new libraries and testing frameworks.
435 lines
17 KiB
C#
435 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Serialization;
|
|
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests verifying Notifier service can ingest scanner events per orchestrator-envelope.schema.json.
|
|
/// </summary>
|
|
public sealed class NotifierIngestionTests
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
[Fact]
|
|
public void NotifierMetadata_SerializesCorrectly()
|
|
{
|
|
var metadata = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { "email", "slack" },
|
|
DigestEligible = false,
|
|
ImmediateDispatch = true,
|
|
Priority = "critical"
|
|
};
|
|
|
|
var orchestratorEvent = CreateTestEvent(metadata);
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.NotNull(node["notifier"]);
|
|
|
|
var notifierNode = node["notifier"]!.AsObject();
|
|
Assert.True(notifierNode["severityThresholdMet"]?.GetValue<bool>());
|
|
Assert.False(notifierNode["digestEligible"]?.GetValue<bool>());
|
|
Assert.True(notifierNode["immediateDispatch"]?.GetValue<bool>());
|
|
Assert.Equal("critical", notifierNode["priority"]?.GetValue<string>());
|
|
|
|
var channels = notifierNode["notificationChannels"]?.AsArray();
|
|
Assert.NotNull(channels);
|
|
Assert.Equal(2, channels.Count);
|
|
Assert.Contains("email", channels.Select(c => c?.GetValue<string>()));
|
|
Assert.Contains("slack", channels.Select(c => c?.GetValue<string>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotifierMetadata_OmittedWhenNull()
|
|
{
|
|
var orchestratorEvent = new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerReportReady,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "test-key",
|
|
Payload = new ReportReadyEventPayload
|
|
{
|
|
ReportId = "report-123",
|
|
ImageDigest = "sha256:abc123",
|
|
GeneratedAt = DateTimeOffset.UtcNow,
|
|
Verdict = "pass",
|
|
Summary = new ReportSummaryDto(),
|
|
Policy = new ReportPolicyDto(),
|
|
Links = new ReportLinksPayload(),
|
|
Report = new ReportDocumentDto()
|
|
},
|
|
Notifier = null // Explicitly null
|
|
};
|
|
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.Null(node["notifier"]); // Should be omitted when null
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("critical", true, true)]
|
|
[InlineData("high", true, false)]
|
|
[InlineData("medium", false, false)]
|
|
[InlineData("low", false, false)]
|
|
public void NotifierMetadata_SeverityThresholdCalculation(string severity, bool expectedThresholdMet, bool expectedImmediate)
|
|
{
|
|
var metadata = CreateNotifierMetadataForSeverity(severity);
|
|
|
|
Assert.Equal(expectedThresholdMet, metadata.SeverityThresholdMet);
|
|
Assert.Equal(expectedImmediate, metadata.ImmediateDispatch);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScanStartedEvent_SerializesForNotifier()
|
|
{
|
|
var orchestratorEvent = new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerScanStarted,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "scanner.event.scan.started:test-tenant:scan-001",
|
|
Payload = new ScanStartedEventPayload
|
|
{
|
|
ScanId = "scan-001",
|
|
JobId = "job-001",
|
|
Target = new ScanTargetPayload
|
|
{
|
|
Type = "container_image",
|
|
Identifier = "registry.example/app:v1.0.0",
|
|
Digest = "sha256:abc123def456"
|
|
},
|
|
StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
Status = "started"
|
|
},
|
|
Notifier = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = false,
|
|
DigestEligible = true,
|
|
ImmediateDispatch = false
|
|
}
|
|
};
|
|
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.Equal(OrchestratorEventKinds.ScannerScanStarted, node["kind"]?.GetValue<string>());
|
|
|
|
var payload = node["payload"]?.AsObject();
|
|
Assert.NotNull(payload);
|
|
Assert.Equal("scan-001", payload["scanId"]?.GetValue<string>());
|
|
Assert.Equal("started", payload["status"]?.GetValue<string>());
|
|
|
|
var target = payload["target"]?.AsObject();
|
|
Assert.NotNull(target);
|
|
Assert.Equal("container_image", target["type"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public void ScanFailedEvent_SerializesWithErrorDetails()
|
|
{
|
|
var orchestratorEvent = new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerScanFailed,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"),
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "scanner.event.scan.failed:test-tenant:scan-002",
|
|
Payload = new ScanFailedEventPayload
|
|
{
|
|
ScanId = "scan-002",
|
|
Target = new ScanTargetPayload
|
|
{
|
|
Type = "container_image",
|
|
Identifier = "registry.example/broken:latest"
|
|
},
|
|
StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
FailedAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"),
|
|
DurationMs = 300000,
|
|
Status = "failed",
|
|
Error = new ScanErrorPayload
|
|
{
|
|
Code = "IMAGE_PULL_FAILED",
|
|
Message = "Unable to pull image: authentication required",
|
|
Details = ImmutableDictionary.CreateRange(new[]
|
|
{
|
|
KeyValuePair.Create("registry", "registry.example"),
|
|
KeyValuePair.Create("httpStatus", "401")
|
|
}),
|
|
Recoverable = true
|
|
}
|
|
},
|
|
Notifier = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { "email", "slack", "pagerduty" },
|
|
DigestEligible = false,
|
|
ImmediateDispatch = true,
|
|
Priority = "high"
|
|
}
|
|
};
|
|
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.Equal(OrchestratorEventKinds.ScannerScanFailed, node["kind"]?.GetValue<string>());
|
|
|
|
var payload = node["payload"]?.AsObject();
|
|
Assert.NotNull(payload);
|
|
Assert.Equal("failed", payload["status"]?.GetValue<string>());
|
|
Assert.Equal(300000, payload["durationMs"]?.GetValue<long>());
|
|
|
|
var error = payload["error"]?.AsObject();
|
|
Assert.NotNull(error);
|
|
Assert.Equal("IMAGE_PULL_FAILED", error["code"]?.GetValue<string>());
|
|
Assert.True(error["recoverable"]?.GetValue<bool>());
|
|
|
|
var notifier = node["notifier"]?.AsObject();
|
|
Assert.NotNull(notifier);
|
|
Assert.True(notifier["immediateDispatch"]?.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public void VulnerabilityDetectedEvent_SerializesForNotifier()
|
|
{
|
|
var orchestratorEvent = new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerVulnerabilityDetected,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "scanner.event.vulnerability.detected:test-tenant:CVE-2024-9999:pkg:npm/lodash@4.17.20",
|
|
Payload = new VulnerabilityDetectedEventPayload
|
|
{
|
|
ScanId = "scan-001",
|
|
Vulnerability = new VulnerabilityInfoPayload
|
|
{
|
|
Id = "CVE-2024-9999",
|
|
Severity = "critical",
|
|
CvssScore = 9.8,
|
|
CvssVector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
Title = "Remote Code Execution in lodash",
|
|
FixAvailable = true,
|
|
FixedVersion = "4.17.21",
|
|
KevListed = true,
|
|
EpssScore = 0.95
|
|
},
|
|
AffectedComponent = new ComponentInfoPayload
|
|
{
|
|
Purl = "pkg:npm/lodash@4.17.20",
|
|
Name = "lodash",
|
|
Version = "4.17.20",
|
|
Ecosystem = "npm",
|
|
Location = "/app/node_modules/lodash"
|
|
},
|
|
Reachability = "reachable",
|
|
DetectedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z")
|
|
},
|
|
Notifier = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { "email", "slack", "pagerduty" },
|
|
DigestEligible = false,
|
|
ImmediateDispatch = true,
|
|
Priority = "critical"
|
|
}
|
|
};
|
|
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.Equal(OrchestratorEventKinds.ScannerVulnerabilityDetected, node["kind"]?.GetValue<string>());
|
|
|
|
var payload = node["payload"]?.AsObject();
|
|
Assert.NotNull(payload);
|
|
|
|
var vuln = payload["vulnerability"]?.AsObject();
|
|
Assert.NotNull(vuln);
|
|
Assert.Equal("CVE-2024-9999", vuln["id"]?.GetValue<string>());
|
|
Assert.Equal("critical", vuln["severity"]?.GetValue<string>());
|
|
Assert.Equal(9.8, vuln["cvssScore"]?.GetValue<double>());
|
|
Assert.True(vuln["kevListed"]?.GetValue<bool>());
|
|
|
|
var component = payload["affectedComponent"]?.AsObject();
|
|
Assert.NotNull(component);
|
|
Assert.Equal("pkg:npm/lodash@4.17.20", component["purl"]?.GetValue<string>());
|
|
|
|
Assert.Equal("reachable", payload["reachability"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public void SbomGeneratedEvent_SerializesForNotifier()
|
|
{
|
|
var orchestratorEvent = new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerSbomGenerated,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "scanner.event.sbom.generated:test-tenant:sbom-001",
|
|
Payload = new SbomGeneratedEventPayload
|
|
{
|
|
ScanId = "scan-001",
|
|
SbomId = "sbom-001",
|
|
Target = new ScanTargetPayload
|
|
{
|
|
Type = "container_image",
|
|
Identifier = "registry.example/app:v1.0.0",
|
|
Digest = "sha256:abc123def456"
|
|
},
|
|
GeneratedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
|
Format = "cyclonedx",
|
|
SpecVersion = "1.6",
|
|
ComponentCount = 127,
|
|
SbomRef = "s3://sboms/sbom-001.json",
|
|
Digest = "sha256:sbom-digest-789"
|
|
},
|
|
Notifier = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = false,
|
|
DigestEligible = true,
|
|
ImmediateDispatch = false
|
|
}
|
|
};
|
|
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
Assert.Equal(OrchestratorEventKinds.ScannerSbomGenerated, node["kind"]?.GetValue<string>());
|
|
|
|
var payload = node["payload"]?.AsObject();
|
|
Assert.NotNull(payload);
|
|
Assert.Equal("sbom-001", payload["sbomId"]?.GetValue<string>());
|
|
Assert.Equal("cyclonedx", payload["format"]?.GetValue<string>());
|
|
Assert.Equal("1.6", payload["specVersion"]?.GetValue<string>());
|
|
Assert.Equal(127, payload["componentCount"]?.GetValue<int>());
|
|
}
|
|
|
|
[Fact]
|
|
public void AllEventKinds_HaveCorrectFormat()
|
|
{
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerReportReady);
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanCompleted);
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanStarted);
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanFailed);
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerSbomGenerated);
|
|
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerVulnerabilityDetected);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotifierChannels_SupportAllChannelTypes()
|
|
{
|
|
var validChannels = new[] { "email", "slack", "teams", "webhook", "pagerduty" };
|
|
|
|
foreach (var channel in validChannels)
|
|
{
|
|
var metadata = new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { channel },
|
|
DigestEligible = true,
|
|
ImmediateDispatch = false
|
|
};
|
|
|
|
var orchestratorEvent = CreateTestEvent(metadata);
|
|
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
|
var node = JsonNode.Parse(json)?.AsObject();
|
|
|
|
Assert.NotNull(node);
|
|
var notifier = node["notifier"]?.AsObject();
|
|
Assert.NotNull(notifier);
|
|
var channels = notifier["notificationChannels"]?.AsArray();
|
|
Assert.NotNull(channels);
|
|
Assert.Contains(channel, channels.Select(c => c?.GetValue<string>()));
|
|
}
|
|
}
|
|
|
|
private static OrchestratorEvent CreateTestEvent(NotifierIngestionMetadata? notifier)
|
|
{
|
|
return new OrchestratorEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerReportReady,
|
|
Version = 1,
|
|
Tenant = "test-tenant",
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
Source = "scanner.webservice",
|
|
IdempotencyKey = "test-key",
|
|
Payload = new ReportReadyEventPayload
|
|
{
|
|
ReportId = "report-123",
|
|
ImageDigest = "sha256:abc123",
|
|
GeneratedAt = DateTimeOffset.UtcNow,
|
|
Verdict = "pass",
|
|
Summary = new ReportSummaryDto(),
|
|
Policy = new ReportPolicyDto(),
|
|
Links = new ReportLinksPayload(),
|
|
Report = new ReportDocumentDto()
|
|
},
|
|
Notifier = notifier
|
|
};
|
|
}
|
|
|
|
private static NotifierIngestionMetadata CreateNotifierMetadataForSeverity(string severity)
|
|
{
|
|
return severity.ToLowerInvariant() switch
|
|
{
|
|
"critical" => new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { "email", "slack", "pagerduty" },
|
|
DigestEligible = false,
|
|
ImmediateDispatch = true,
|
|
Priority = "critical"
|
|
},
|
|
"high" => new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = true,
|
|
NotificationChannels = new[] { "email", "slack" },
|
|
DigestEligible = false,
|
|
ImmediateDispatch = false,
|
|
Priority = "high"
|
|
},
|
|
_ => new NotifierIngestionMetadata
|
|
{
|
|
SeverityThresholdMet = false,
|
|
DigestEligible = true,
|
|
ImmediateDispatch = false,
|
|
Priority = "normal"
|
|
}
|
|
};
|
|
}
|
|
}
|