Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls.
This commit is contained in:
@@ -11,12 +11,27 @@ namespace StellaOps.Concelier.Core.Linksets;
|
||||
/// </summary>
|
||||
public sealed partial class AdvisoryLinksetMapper : IAdvisoryLinksetMapper
|
||||
{
|
||||
private static readonly HashSet<string> AliasSchemesOfInterest = new(new[]
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV
|
||||
}, StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly HashSet<string> AliasSchemesOfInterest = new(new[]
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV,
|
||||
AliasSchemes.Rhsa,
|
||||
AliasSchemes.Usn,
|
||||
AliasSchemes.Dsa,
|
||||
AliasSchemes.SuseSu,
|
||||
AliasSchemes.Msrc,
|
||||
AliasSchemes.CiscoSa,
|
||||
AliasSchemes.OracleCpu,
|
||||
AliasSchemes.Vmsa,
|
||||
AliasSchemes.Apsb,
|
||||
AliasSchemes.Apa,
|
||||
AliasSchemes.AppleHt,
|
||||
AliasSchemes.Icsa,
|
||||
AliasSchemes.Jvndb,
|
||||
AliasSchemes.Jvn,
|
||||
AliasSchemes.Bdu
|
||||
}, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public RawLinkset Map(AdvisoryRawDocument document)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Normalization.SemVer;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
@@ -41,13 +42,14 @@ internal static class AdvisoryLinksetNormalization
|
||||
{
|
||||
var normalizedPurls = NormalizePurls(purlValues);
|
||||
var versions = ExtractVersions(normalizedPurls);
|
||||
var ranges = BuildVersionRanges(normalizedPurls);
|
||||
|
||||
if (normalizedPurls.Count == 0 && versions.Count == 0)
|
||||
if (normalizedPurls.Count == 0 && versions.Count == 0 && ranges.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryLinksetNormalized(normalizedPurls, versions, null, null);
|
||||
return new AdvisoryLinksetNormalized(normalizedPurls, versions, ranges, null);
|
||||
}
|
||||
|
||||
private static List<string> NormalizePurls(IEnumerable<string> purls)
|
||||
@@ -89,6 +91,55 @@ internal static class AdvisoryLinksetNormalization
|
||||
return versions.ToList();
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> BuildVersionRanges(IReadOnlyCollection<string> purls)
|
||||
{
|
||||
var ranges = new List<Dictionary<string, object?>>();
|
||||
|
||||
foreach (var purl in purls)
|
||||
{
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
if (atIndex < 0 || atIndex >= purl.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versionSegment = purl[(atIndex + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(versionSegment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!LooksLikeRange(versionSegment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rules = SemVerRangeRuleBuilder.BuildNormalizedRules(versionSegment, provenanceNote: $"purl:{purl}");
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
ranges.Add(new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
{ "scheme", rule.Scheme },
|
||||
{ "type", rule.Type },
|
||||
{ "min", rule.Min },
|
||||
{ "minInclusive", rule.MinInclusive },
|
||||
{ "max", rule.Max },
|
||||
{ "maxInclusive", rule.MaxInclusive },
|
||||
{ "value", rule.Value },
|
||||
{ "notes", rule.Notes }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private static bool LooksLikeRange(string value)
|
||||
{
|
||||
return value.IndexOfAny(new[] { '^', '~', '*', ' ', ',', '|', '>' , '<' }) >= 0 ||
|
||||
value.Contains("||", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static double? CoerceConfidence(double? confidence)
|
||||
{
|
||||
if (!confidence.HasValue)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy.AuthSignals;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
/// <summary>
|
||||
/// Maps advisory linksets into the shared Policy/Auth/Signals contract so policy enrichment tasks can start.
|
||||
/// This is a minimal, fact-only projection (no weighting or merge logic).
|
||||
/// </summary>
|
||||
public static class PolicyAuthSignalFactory
|
||||
{
|
||||
public static PolicyAuthSignal ToPolicyAuthSignal(AdvisoryLinkset linkset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
|
||||
var firstPurl = linkset.Normalized?.Purls?.FirstOrDefault();
|
||||
|
||||
var evidence = new List<EvidenceRef>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "linkset",
|
||||
Uri = $"cas://linksets/{linkset.AdvisoryId}",
|
||||
Digest = "sha256:pending" // real digest filled when CAS manifests are available
|
||||
}
|
||||
};
|
||||
|
||||
return new PolicyAuthSignal
|
||||
{
|
||||
Id = linkset.AdvisoryId,
|
||||
Tenant = linkset.TenantId,
|
||||
Subject = firstPurl ?? $"advisory:{linkset.Source}:{linkset.AdvisoryId}",
|
||||
SignalType = "reachability",
|
||||
Source = linkset.Source,
|
||||
Confidence = linkset.Confidence,
|
||||
Evidence = evidence,
|
||||
Created = linkset.CreatedAt.UtcDateTime
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Observations;
|
||||
|
||||
@@ -251,10 +252,10 @@ public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryS
|
||||
relationshipSet.Add(relationship);
|
||||
}
|
||||
|
||||
var (_, rawConfidence, rawConflicts) = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(observation.RawLinkset);
|
||||
confidence = Math.Min(confidence, rawConfidence ?? 1.0);
|
||||
var linksetProjection = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(observation.RawLinkset);
|
||||
confidence = Math.Min(confidence, linksetProjection.confidence ?? 1.0);
|
||||
|
||||
foreach (var conflict in rawConflicts)
|
||||
foreach (var conflict in linksetProjection.conflicts)
|
||||
{
|
||||
var key = $"{conflict.Field}|{conflict.Reason}|{string.Join('|', conflict.Values ?? Array.Empty<string>())}";
|
||||
if (conflictSet.Add(key))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Policy.AuthSignals;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary bridge to consume the shared Policy/Auth/Signals contract package so downstream POLICY tasks can start.
|
||||
/// </summary>
|
||||
public static class AuthSignalsPackage
|
||||
{
|
||||
public static PolicyAuthSignal CreateSample() => new()
|
||||
{
|
||||
Id = "sample",
|
||||
Tenant = "urn:tenant:sample",
|
||||
Subject = "purl:pkg:maven/org.example/app@1.0.0",
|
||||
SignalType = "reachability",
|
||||
Source = "concelier",
|
||||
Evidence = new List<EvidenceRef>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "linkset",
|
||||
Uri = "cas://linksets/sample",
|
||||
Digest = "sha256:stub"
|
||||
}
|
||||
},
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Cronos" Version="0.10.0" />
|
||||
<PackageReference Include="StellaOps.Policy.AuthSignals" Version="0.1.0-alpha" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
|
||||
|
||||
@@ -108,6 +108,12 @@ public static class ServiceCollectionExtensions
|
||||
return database.GetCollection<AdvisoryObservationDocument>(MongoStorageDefaults.Collections.AdvisoryObservations);
|
||||
});
|
||||
|
||||
services.AddSingleton<IMongoCollection<AdvisoryLinksetDocument>>(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AdvisoryLinksetDocument>(MongoStorageDefaults.Collections.AdvisoryLinksets);
|
||||
});
|
||||
|
||||
services.AddHostedService<RawDocumentRetentionService>();
|
||||
|
||||
services.AddSingleton<MongoMigrationRunner>();
|
||||
|
||||
@@ -79,11 +79,11 @@ public sealed class AdvisoryLinksetMapperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_DeduplicatesValuesButRetainsMultipleOrigins()
|
||||
{
|
||||
using var contentDoc = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
public void Map_DeduplicatesValuesButRetainsMultipleOrigins()
|
||||
{
|
||||
using var contentDoc = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"aliases": ["CVE-2025-0002", "CVE-2025-0002"],
|
||||
"packages": [
|
||||
{ "coordinates": "pkg:npm/package-b@2.0.0" },
|
||||
@@ -119,7 +119,36 @@ public sealed class AdvisoryLinksetMapperTests
|
||||
|
||||
Assert.Contains("/content/raw/aliases/0", result.ReconciledFrom);
|
||||
Assert.Contains("/content/raw/aliases/1", result.ReconciledFrom);
|
||||
Assert.Contains("/content/raw/packages/0/coordinates", result.ReconciledFrom);
|
||||
Assert.Contains("/content/raw/packages/1/coordinates", result.ReconciledFrom);
|
||||
}
|
||||
}
|
||||
Assert.Contains("/content/raw/packages/0/coordinates", result.ReconciledFrom);
|
||||
Assert.Contains("/content/raw/packages/1/coordinates", result.ReconciledFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_IncludesVendorAliasesNeededForPolicyEquivalence()
|
||||
{
|
||||
var document = new AdvisoryRawDocument(
|
||||
Tenant: "tenant-a",
|
||||
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
|
||||
Upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: "RHSA-2025:0010",
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: DateTimeOffset.UtcNow,
|
||||
ContentHash: "sha256:ghi",
|
||||
Signature: new RawSignatureMetadata(false),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
Content: new RawContent(
|
||||
Format: "rhsa",
|
||||
SpecVersion: "1.0",
|
||||
Raw: JsonDocument.Parse("""{"advisory_id":"RHSA-2025:0010"}""").RootElement.Clone()),
|
||||
Identifiers: new RawIdentifiers(
|
||||
Aliases: ImmutableArray.Create("RHSA-2025:0010"),
|
||||
PrimaryId: "RHSA-2025:0010"),
|
||||
Linkset: new RawLinkset());
|
||||
|
||||
var mapper = new AdvisoryLinksetMapper();
|
||||
|
||||
var result = mapper.Map(document);
|
||||
|
||||
Assert.Contains("rhsa-2025:0010", result.Aliases);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Linksets;
|
||||
|
||||
public sealed class AdvisoryLinksetNormalizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromRawLinksetWithConfidence_EmitsSemVerRangesForCaretVersions()
|
||||
{
|
||||
var linkset = new RawLinkset
|
||||
{
|
||||
PackageUrls = ImmutableArray.Create("pkg:npm/example@^1.2.3")
|
||||
};
|
||||
|
||||
var (normalized, _, _) = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(linkset);
|
||||
|
||||
normalized.Should().NotBeNull();
|
||||
normalized!.Ranges.Should().NotBeNull();
|
||||
normalized.Ranges!.Should().ContainSingle();
|
||||
|
||||
var range = normalized.Ranges![0];
|
||||
range["scheme"].Should().Be("semver");
|
||||
range["type"].Should().Be("range");
|
||||
range["min"].Should().Be("1.2.3");
|
||||
range["minInclusive"].Should().Be(true);
|
||||
range["max"].Should().Be("2.0.0");
|
||||
range["maxInclusive"].Should().Be(false);
|
||||
range["notes"].Should().Be("purl:pkg:npm/example@^1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Linksets;
|
||||
|
||||
public class PolicyAuthSignalFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToPolicyAuthSignal_maps_basic_fields()
|
||||
{
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "urn:tenant:demo",
|
||||
Source: "ghsa",
|
||||
AdvisoryId: "GHSA-1234",
|
||||
ObservationIds: ImmutableArray.Create("obs-1"),
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "purl:pkg:maven/org.example/app@1.2.3" },
|
||||
Versions: Array.Empty<string>(),
|
||||
Ranges: null,
|
||||
Severities: null),
|
||||
Provenance: null,
|
||||
Confidence: 0.9,
|
||||
Conflicts: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
BuiltByJobId: null);
|
||||
|
||||
var signal = PolicyAuthSignalFactory.ToPolicyAuthSignal(linkset);
|
||||
|
||||
signal.Id.Should().Be("GHSA-1234");
|
||||
signal.Tenant.Should().Be("urn:tenant:demo");
|
||||
signal.Subject.Should().Be("purl:pkg:maven/org.example/app@1.2.3");
|
||||
signal.Source.Should().Be("ghsa");
|
||||
signal.SignalType.Should().Be("reachability");
|
||||
signal.Evidence.Should().HaveCount(1);
|
||||
signal.Evidence[0].Uri.Should().Contain("GHSA-1234");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Immutable;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
@@ -41,13 +42,16 @@ public sealed class AdvisoryObservationAggregationTests
|
||||
{
|
||||
var linkset = new RawLinkset
|
||||
{
|
||||
Notes = new Dictionary<string, string>
|
||||
Notes = ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
{ "severity", "disagree" }
|
||||
}
|
||||
new KeyValuePair<string, string>("severity", "disagree")
|
||||
})
|
||||
};
|
||||
|
||||
var (normalized, confidence, conflicts) = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(linkset);
|
||||
var result = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(linkset);
|
||||
var normalized = result.normalized;
|
||||
var confidence = result.confidence;
|
||||
var conflicts = result.conflicts;
|
||||
|
||||
Assert.Equal(0.5, confidence);
|
||||
Assert.Single(conflicts);
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Ingestion.Telemetry;
|
||||
@@ -194,37 +195,10 @@ public sealed class AdvisoryRawServiceTests
|
||||
|
||||
private sealed class StubObservationFactory : IAdvisoryObservationFactory
|
||||
{
|
||||
public Models.Observations.AdvisoryObservation Create(Models.Advisory advisory, string tenant, string source, RawModels.AdvisoryRawDocument raw, string advisoryKey, string observationId, DateTimeOffset createdAt)
|
||||
{
|
||||
var upstream = new Models.Observations.AdvisoryObservationUpstream(
|
||||
upstreamId: raw.Upstream.UpstreamId,
|
||||
documentVersion: raw.Upstream.DocumentVersion,
|
||||
fetchedAt: raw.Upstream.RetrievedAt ?? createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: raw.Upstream.ContentHash,
|
||||
signature: new Models.Observations.AdvisoryObservationSignature(raw.Upstream.Signature.Present, raw.Upstream.Signature.Format, raw.Upstream.Signature.KeyId, raw.Upstream.Signature.Signature),
|
||||
metadata: raw.Upstream.Provenance);
|
||||
private readonly AdvisoryObservationFactory _inner = new();
|
||||
|
||||
var content = new Models.Observations.AdvisoryObservationContent(raw.Content.Format, raw.Content.SpecVersion, JsonDocument.Parse(raw.Content.Raw.GetRawText()).RootElement);
|
||||
|
||||
var linkset = new Models.Observations.AdvisoryObservationLinkset(
|
||||
raw.Linkset.Aliases,
|
||||
raw.Linkset.PackageUrls,
|
||||
raw.Linkset.Cpes,
|
||||
ImmutableArray<Models.Observations.AdvisoryObservationReference>.Empty);
|
||||
|
||||
var rawLinkset = raw.Linkset;
|
||||
|
||||
return new Models.Observations.AdvisoryObservation(
|
||||
observationId,
|
||||
tenant,
|
||||
new Models.Observations.AdvisoryObservationSource(source, "stream", "api"),
|
||||
upstream,
|
||||
content,
|
||||
linkset,
|
||||
rawLinkset,
|
||||
createdAt);
|
||||
}
|
||||
public Models.Observations.AdvisoryObservation Create(RawModels.AdvisoryRawDocument rawDocument, DateTimeOffset? observedAt = null)
|
||||
=> _inner.Create(rawDocument, observedAt);
|
||||
}
|
||||
|
||||
private static AdvisoryRawDocument CreateDocument()
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
|
||||
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class AdvisoryChunkBuilderTests
|
||||
{
|
||||
private readonly ICryptoHash _hash = CryptoHashFactory.CreateDefault();
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesJsonPointerFromMaskForObservationPath()
|
||||
{
|
||||
var recordedAt = DateTimeOffset.Parse("2025-11-18T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
var observationId = "obs-1";
|
||||
var observation = BuildObservation(observationId);
|
||||
var advisory = BuildAdvisory(recordedAt, observationId, new[] { "/references/0" });
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
fingerprint: "fp",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
sectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
formatFilter: ImmutableHashSet<string>.Empty,
|
||||
minimumLength: 1);
|
||||
|
||||
var builder = new AdvisoryChunkBuilder(_hash);
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0", entry.Provenance.ObservationPath);
|
||||
Assert.Contains("/references/0", entry.Provenance.FieldMask);
|
||||
|
||||
var expectedChunkId = ComputeChunkId(observationId, "/references/0");
|
||||
Assert.Equal(expectedChunkId, entry.ChunkId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FallsBackToFieldPathWhenMaskMissing()
|
||||
{
|
||||
var recordedAt = DateTimeOffset.Parse("2025-11-18T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
var observationId = "obs-2";
|
||||
var observation = BuildObservation(observationId);
|
||||
var advisory = BuildAdvisory(recordedAt, observationId, fieldMask: null);
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
fingerprint: "fp",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
sectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
formatFilter: ImmutableHashSet<string>.Empty,
|
||||
minimumLength: 1);
|
||||
|
||||
var builder = new AdvisoryChunkBuilder(_hash);
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0", entry.Provenance.ObservationPath);
|
||||
Assert.Contains("/references/0", entry.Provenance.FieldMask);
|
||||
|
||||
var expectedChunkId = ComputeChunkId(observationId, "/references/0");
|
||||
Assert.Equal(expectedChunkId, entry.ChunkId);
|
||||
}
|
||||
|
||||
private Advisory BuildAdvisory(DateTimeOffset recordedAt, string observationId, IEnumerable<string>? fieldMask)
|
||||
{
|
||||
var provenance = fieldMask is null
|
||||
? new AdvisoryProvenance("nvd", "workaround", observationId, recordedAt)
|
||||
: new AdvisoryProvenance("nvd", "workaround", observationId, recordedAt, fieldMask);
|
||||
|
||||
var reference = new AdvisoryReference(
|
||||
url: "https://example.test/workaround",
|
||||
kind: "workaround",
|
||||
sourceTag: "Vendor guidance",
|
||||
summary: "Apply the workaround.",
|
||||
provenance);
|
||||
|
||||
return new Advisory(
|
||||
advisoryKey: "CVE-2025-0001",
|
||||
title: "Test advisory",
|
||||
summary: "",
|
||||
language: "en",
|
||||
published: recordedAt,
|
||||
modified: recordedAt,
|
||||
severity: "high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-0001" },
|
||||
references: new[] { reference },
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { new AdvisoryProvenance("nvd", "advisory", observationId, recordedAt) });
|
||||
}
|
||||
|
||||
private static AdvisoryObservation BuildObservation(string observationId)
|
||||
{
|
||||
var timestamp = DateTimeOffset.Parse("2025-11-18T00:00:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
return new AdvisoryObservation(
|
||||
observationId,
|
||||
tenant: "tenant-a",
|
||||
source: new AdvisoryObservationSource("nvd", "stream", "api"),
|
||||
upstream: new AdvisoryObservationUpstream(
|
||||
upstreamId: observationId,
|
||||
documentVersion: "1",
|
||||
fetchedAt: timestamp,
|
||||
receivedAt: timestamp,
|
||||
contentHash: "sha256:deadbeef",
|
||||
signature: new AdvisoryObservationSignature(present: false)),
|
||||
content: new AdvisoryObservationContent("csaf", "2.0", JsonNode.Parse("{}")!),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
rawLinkset: new RawLinkset(),
|
||||
createdAt: timestamp);
|
||||
}
|
||||
|
||||
private string ComputeChunkId(string documentId, string observationPath)
|
||||
{
|
||||
var input = string.Concat(documentId, '|', observationPath);
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256);
|
||||
return Convert.ToHexString(digest.AsSpan(0, 8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class AdvisoryChunkCacheKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_NormalizesObservationOrdering()
|
||||
{
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
AdvisoryKey: "CVE-2025-0001",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 10,
|
||||
ObservationLimit: 10,
|
||||
SectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 8);
|
||||
|
||||
var first = BuildObservation("obs-1", "sha256:one", "2025-11-18T00:00:00Z");
|
||||
var second = BuildObservation("obs-2", "sha256:two", "2025-11-18T00:05:00Z");
|
||||
|
||||
var ordered = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0001", options, new[] { first, second }, "fp");
|
||||
var reversed = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0001", options, new[] { second, first }, "fp");
|
||||
|
||||
Assert.Equal(ordered.Value, reversed.Value);
|
||||
Assert.Equal(ordered.ComputeHash(), reversed.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_NormalizesFilterCasing()
|
||||
{
|
||||
var optionsLower = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0002",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet.Create("workaround", "fix"),
|
||||
FormatFilter: ImmutableHashSet.Create("ndjson"),
|
||||
MinimumLength: 1);
|
||||
|
||||
var optionsUpper = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0002",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet.Create("WorkAround", "FIX"),
|
||||
FormatFilter: ImmutableHashSet.Create("NDJSON"),
|
||||
MinimumLength: 1);
|
||||
|
||||
var observation = BuildObservation("obs-3", "sha256:three", "2025-11-18T00:10:00Z");
|
||||
|
||||
var lower = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0002", optionsLower, new[] { observation }, "fp");
|
||||
var upper = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0002", optionsUpper, new[] { observation }, "fp");
|
||||
|
||||
Assert.Equal(lower.Value, upper.Value);
|
||||
Assert.Equal(lower.ComputeHash(), upper.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ChangesWhenContentHashDiffers()
|
||||
{
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0003",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 1);
|
||||
|
||||
var original = BuildObservation("obs-4", "sha256:orig", "2025-11-18T00:15:00Z");
|
||||
var mutated = BuildObservation("obs-4", "sha256:mut", "2025-11-18T00:15:00Z");
|
||||
|
||||
var originalKey = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0003", options, new[] { original }, "fp");
|
||||
var mutatedKey = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0003", options, new[] { mutated }, "fp");
|
||||
|
||||
Assert.NotEqual(originalKey.Value, mutatedKey.Value);
|
||||
Assert.NotEqual(originalKey.ComputeHash(), mutatedKey.ComputeHash());
|
||||
}
|
||||
|
||||
private static AdvisoryObservation BuildObservation(string id, string contentHash, string timestamp)
|
||||
{
|
||||
var createdAt = DateTimeOffset.Parse(timestamp, CultureInfo.InvariantCulture);
|
||||
|
||||
return new AdvisoryObservation(
|
||||
id,
|
||||
tenant: "tenant-a",
|
||||
source: new AdvisoryObservationSource("nvd", "stream", "api"),
|
||||
upstream: new AdvisoryObservationUpstream(
|
||||
upstreamId: id,
|
||||
documentVersion: "1",
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: contentHash,
|
||||
signature: new AdvisoryObservationSignature(false)),
|
||||
content: new AdvisoryObservationContent("csaf", "2.0", JsonNode.Parse("{}")!),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
rawLinkset: new RawLinkset(),
|
||||
createdAt: createdAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Services;
|
||||
|
||||
public sealed class AdvisoryChunkBuilderTests
|
||||
{
|
||||
private static readonly DateTimeOffset RecordedAt = DateTimeOffset.Parse("2025-11-18T00:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesJsonPointerFromFieldMaskForObservationPath()
|
||||
{
|
||||
var observation = CreateObservation("tenant-a:obs-1", "sha256:abc123");
|
||||
var provenance = new AdvisoryProvenance(
|
||||
"nvd",
|
||||
"workaround",
|
||||
observation.ObservationId,
|
||||
RecordedAt,
|
||||
new[] { " /references/0/title " });
|
||||
|
||||
var advisory = CreateAdvisory("CVE-2025-0001", provenance);
|
||||
var builder = new AdvisoryChunkBuilder(new TestCryptoHash());
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
"fingerprint-1",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 0);
|
||||
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0/title", entry.Provenance.ObservationPath);
|
||||
Assert.Equal(observation.ObservationId, entry.Provenance.DocumentId);
|
||||
Assert.Equal(observation.Upstream.ContentHash, entry.Provenance.ContentHash);
|
||||
Assert.Equal(new[] { "/references/0/title" }, entry.Provenance.FieldMask);
|
||||
Assert.Equal(ComputeChunkId(observation.ObservationId, "/references/0/title"), entry.ChunkId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FallsBackToFieldPathWhenMaskIsEmpty()
|
||||
{
|
||||
var observation = CreateObservation("tenant-b:obs-2", "sha256:def456");
|
||||
var provenance = new AdvisoryProvenance(
|
||||
"nvd",
|
||||
"workaround",
|
||||
observation.ObservationId,
|
||||
RecordedAt);
|
||||
|
||||
var advisory = CreateAdvisory("CVE-2025-0002", provenance);
|
||||
var builder = new AdvisoryChunkBuilder(new TestCryptoHash());
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
"fingerprint-2",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 0);
|
||||
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0", entry.Provenance.ObservationPath);
|
||||
Assert.Equal(observation.ObservationId, entry.Provenance.DocumentId);
|
||||
Assert.Equal(observation.Upstream.ContentHash, entry.Provenance.ContentHash);
|
||||
Assert.Equal(new[] { "/references/0" }, entry.Provenance.FieldMask);
|
||||
Assert.Equal(ComputeChunkId(observation.ObservationId, "/references/0"), entry.ChunkId);
|
||||
}
|
||||
|
||||
private static Advisory CreateAdvisory(string advisoryKey, AdvisoryProvenance provenance)
|
||||
{
|
||||
var reference = new AdvisoryReference(
|
||||
"https://vendor.example/workaround",
|
||||
kind: "workaround",
|
||||
sourceTag: "Vendor guidance",
|
||||
summary: "Apply configuration change",
|
||||
provenance: provenance);
|
||||
|
||||
return new Advisory(
|
||||
advisoryKey,
|
||||
title: "Fixture advisory",
|
||||
summary: "Structured payload",
|
||||
language: "en",
|
||||
published: RecordedAt,
|
||||
modified: RecordedAt,
|
||||
severity: "critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { advisoryKey },
|
||||
references: new[] { reference },
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static AdvisoryObservation CreateObservation(string observationId, string contentHash)
|
||||
{
|
||||
var source = new AdvisoryObservationSource("nvd", "default", "v1");
|
||||
var upstream = new AdvisoryObservationUpstream(
|
||||
"upstream-id",
|
||||
documentVersion: "1",
|
||||
fetchedAt: DateTimeOffset.Parse("2025-11-17T00:00:00Z"),
|
||||
receivedAt: DateTimeOffset.Parse("2025-11-17T00:01:00Z"),
|
||||
contentHash: contentHash,
|
||||
signature: new AdvisoryObservationSignature(present: false, format: null, keyId: null, signature: null));
|
||||
|
||||
var content = new AdvisoryObservationContent(
|
||||
format: "json",
|
||||
specVersion: "1.0",
|
||||
raw: JsonNode.Parse("{}")!);
|
||||
|
||||
var linkset = new AdvisoryObservationLinkset(
|
||||
aliases: new[] { "CVE-2025-0001" },
|
||||
purls: Array.Empty<string>(),
|
||||
cpes: Array.Empty<string>(),
|
||||
references: Enumerable.Empty<AdvisoryObservationReference>());
|
||||
|
||||
return new AdvisoryObservation(
|
||||
observationId,
|
||||
tenant: "tenant-a",
|
||||
source,
|
||||
upstream,
|
||||
content,
|
||||
linkset,
|
||||
rawLinkset: new RawLinkset(),
|
||||
createdAt: DateTimeOffset.Parse("2025-11-17T01:00:00Z"));
|
||||
}
|
||||
|
||||
private static string ComputeChunkId(string documentId, string observationPath)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(string.Concat(documentId, '|', observationPath));
|
||||
var digest = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(digest.AsSpan(0, 8));
|
||||
}
|
||||
|
||||
private sealed class TestCryptoHash : ICryptoHash
|
||||
{
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
=> SHA256.HashData(data.ToArray());
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
|
||||
|
||||
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
|
||||
|
||||
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||
return ComputeHash(memory.ToArray(), algorithmId);
|
||||
}
|
||||
|
||||
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user