Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -70,11 +70,11 @@ public class IcsCisaConnectorMappingTests
}
[Fact]
public void BuildAffectedPackages_EmitsProductRangesWithSemVer()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-456-02",
public void BuildAffectedPackages_EmitsProductRangesWithSemVer()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-456-02",
Title = "Vendor Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-456-02",
DescriptionHtml = "",
@@ -89,13 +89,54 @@ public class IcsCisaConnectorMappingTests
var productPackage = Assert.Single(packages);
Assert.Equal(AffectedPackageTypes.IcsVendor, productPackage.Type);
Assert.Equal("ControlSuite", productPackage.Identifier);
var range = Assert.Single(productPackage.VersionRanges);
Assert.Equal("product", range.RangeKind);
Assert.Equal("4.2", range.RangeExpression);
Assert.NotNull(range.Primitives);
Assert.Equal("Example Corp", range.Primitives!.VendorExtensions!["ics.vendors"]);
Assert.Equal("ControlSuite", range.Primitives.VendorExtensions!["ics.product"]);
Assert.NotNull(range.Primitives.SemVer);
Assert.Equal("4.2.0", range.Primitives.SemVer!.ExactValue);
}
}
var range = Assert.Single(productPackage.VersionRanges);
Assert.Equal("product", range.RangeKind);
Assert.Equal("4.2.0", range.RangeExpression);
Assert.NotNull(range.Primitives);
Assert.Equal("Example Corp", range.Primitives!.VendorExtensions!["ics.vendors"]);
Assert.Equal("ControlSuite", range.Primitives.VendorExtensions!["ics.product"]);
Assert.True(range.Primitives.VendorExtensions!.ContainsKey("ics.range.expression"));
Assert.NotNull(range.Primitives.SemVer);
Assert.Equal("4.2.0", range.Primitives.SemVer!.ExactValue);
Assert.Equal("ics-cisa:ICSA-25-456-02:controlsuite", range.Provenance.Value);
var normalizedRule = Assert.Single(productPackage.NormalizedVersions);
Assert.Equal("semver", normalizedRule.Scheme);
Assert.Equal("exact", normalizedRule.Type);
Assert.Equal("4.2.0", normalizedRule.Value);
Assert.Equal("ics-cisa:ICSA-25-456-02:controlsuite", normalizedRule.Notes);
var packageProvenance = Assert.Single(productPackage.Provenance);
Assert.Contains(ProvenanceFieldMasks.AffectedPackages, packageProvenance.FieldMask);
Assert.Contains(ProvenanceFieldMasks.VersionRanges, packageProvenance.FieldMask);
Assert.Contains(ProvenanceFieldMasks.NormalizedVersions, packageProvenance.FieldMask);
}
[Fact]
public void BuildAffectedPackages_NormalizesRangeExpressions()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-789-03",
Title = "Range Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-789-03",
DescriptionHtml = "",
Published = RecordedAt,
Vendors = new[] { "Range Corp" },
Products = new[] { "Control Suite Firmware 1.0 - 2.0" }
};
var packages = IcsCisaConnector.BuildAffectedPackages(dto, RecordedAt);
var productPackage = Assert.Single(packages);
Assert.Equal("Control Suite Firmware", productPackage.Identifier);
var range = Assert.Single(productPackage.VersionRanges);
Assert.Equal("1.0.0 - 2.0.0", range.RangeExpression);
Assert.NotNull(range.Primitives);
Assert.Equal("ics-cisa:ICSA-25-789-03:control-suite-firmware", range.Provenance.Value);
var rule = Assert.Single(productPackage.NormalizedVersions);
Assert.Equal("semver", rule.Scheme);
Assert.Equal("range", rule.Type);
Assert.Equal("1.0.0", rule.Min);
Assert.Equal("2.0.0", rule.Max);
Assert.Equal("ics-cisa:ICSA-25-789-03:control-suite-firmware", rule.Notes);
}
}

View File

@@ -50,8 +50,7 @@ public sealed class IcsCisaConnectorTests : IAsyncLifetime
Assert.Equal(2, advisories.Count);
var icsa = Assert.Single(advisories, advisory => advisory.AdvisoryKey == "ICSA-25-123-01");
Console.WriteLine("ProductsRaw:" + string.Join("|", icsa.AffectedPackages.SelectMany(p => p.Provenance).Select(p => p.Value ?? "<null>")));
var icsa = Assert.Single(advisories, advisory => advisory.AdvisoryKey == "ICSA-25-123-01");
Assert.Contains("CVE-2024-12345", icsa.Aliases);
Assert.Contains(icsa.References, reference => reference.Url == "https://example.com/security/icsa-25-123-01");
Assert.Contains(icsa.References, reference => reference.Url == "https://files.cisa.gov/docs/icsa-25-123-01.pdf" && reference.Kind == "attachment");
@@ -88,7 +87,7 @@ public sealed class IcsCisaConnectorTests : IAsyncLifetime
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton(_handler);
services.AddMongoStorage(options =>

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>국내 취약점 정보</title>
</head>
<body>
<div class="domestic_contents">
<table class="basicView">
<tbody>
<tr>
<td class="bg_tht" colspan="2">
CVE-2025-29866 | 태그프리 제품 부적절한 권한 검증 취약점
<span class="date">2025.07.31</span>
</td>
</tr>
<tr>
<td class="cont" colspan="2">
<p>
<span>□ 개요</span><br />
<span> o 태그프리社의 X-Free Uploader에서 발생하는 부적절한 권한 검증 취약점</span>
</p>
<table class="severity">
<tbody>
<tr>
<td>취약점 종류</td>
<td>영향</td>
<td>심각도</td>
<td>CVSS</td>
<td>CVE ID</td>
</tr>
<tr>
<td>부적절한 권한 검증</td>
<td>데이터 변조</td>
<td>High</td>
<td>8.8</td>
<td>CVE-2025-29866</td>
</tr>
</tbody>
</table>
<p>
<span>□ 영향받는 제품 및 해결 방안</span>
</p>
<table class="product">
<tbody>
<tr>
<td>제품</td>
<td>영향받는 버전</td>
<td>해결 버전</td>
</tr>
<tr>
<td rowspan="2">TAGFREE X-Free Uploader</td>
<td>{{PRIMARY_VERSION}}</td>
<td>XFU 1.0.1.0085</td>
</tr>
<tr>
<td>{{SECONDARY_VERSION}}</td>
<td>XFU 2.0.1.0035</td>
</tr>
</tbody>
</table>
<p>
<span>□ 참고사이트</span>
</p>
<p>
<a href="https://www.tagfree.com/bbs/board.php?bo_table=wb_xfu_update">
https://www.tagfree.com/bbs/board.php?bo_table=wb_xfu_update
</a>
</p>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -1,213 +1,497 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Kisa.Configuration;
using StellaOps.Concelier.Connector.Kisa.Internal;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Testing;
using Xunit;
using System.Linq;
namespace StellaOps.Concelier.Connector.Kisa.Tests;
[Collection("mongo-fixture")]
public sealed class KisaConnectorTests : IAsyncLifetime
{
private static readonly Uri FeedUri = new("https://test.local/rss/securityInfo.do");
private static readonly Uri DetailApiUri = new("https://test.local/rssDetailData.do?IDX=5868");
private static readonly Uri DetailPageUri = new("https://test.local/detailDos.do?IDX=5868");
private readonly MongoIntegrationFixture _fixture;
private readonly CannedHttpMessageHandler _handler;
public KisaConnectorTests(MongoIntegrationFixture fixture)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("5868");
advisory.Language.Should().Be("ko");
advisory.Aliases.Should().Contain("CVE-2025-29866");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("태그프리"));
advisory.References.Should().Contain(reference => reference.Url == DetailPageUri.ToString());
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KisaConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsBsonArray.Should().BeEmpty();
}
[Fact]
public async Task Telemetry_RecordsMetrics()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
using var metrics = new KisaMetricCollector();
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
Sum(metrics.Measurements, "kisa.feed.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.feed.items").Should().BeGreaterThan(0);
Sum(metrics.Measurements, "kisa.detail.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.detail.failures").Should().Be(0);
Sum(metrics.Measurements, "kisa.parse.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.parse.failures").Should().Be(0);
Sum(metrics.Measurements, "kisa.map.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.map.failures").Should().Be(0);
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton(_handler);
services.AddMongoStorage(options =>
{
options.ConnectionString = _fixture.Runner.ConnectionString;
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
options.CommandTimeout = TimeSpan.FromSeconds(5);
});
services.AddSourceCommon();
services.AddKisaConnector(options =>
{
options.FeedUri = FeedUri;
options.DetailApiUri = new Uri("https://test.local/rssDetailData.do");
options.DetailPageUri = new Uri("https://test.local/detailDos.do");
options.RequestDelay = TimeSpan.Zero;
options.MaxAdvisoriesPerFetch = 10;
options.MaxKnownAdvisories = 32;
});
services.Configure<HttpClientFactoryOptions>(KisaOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
await bootstrapper.InitializeAsync(CancellationToken.None);
return provider;
}
private void SeedResponses()
{
AddXmlResponse(FeedUri, ReadFixture("kisa-feed.xml"));
AddJsonResponse(DetailApiUri, ReadFixture("kisa-detail.json"));
}
private void AddXmlResponse(Uri uri, string xml)
{
_handler.AddResponse(uri, () => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(xml, Encoding.UTF8, "application/rss+xml"),
});
}
private void AddJsonResponse(Uri uri, string json)
{
_handler.AddResponse(uri, () => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
}
private static string ReadFixture(string fileName)
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
private static long Sum(IEnumerable<KisaMetricCollector.MetricMeasurement> measurements, string name)
=> measurements.Where(m => m.Name == name).Sum(m => m.Value);
private sealed class KisaMetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly ConcurrentBag<MetricMeasurement> _measurements = new();
public KisaMetricCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == KisaDiagnostics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
},
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagList = new List<KeyValuePair<string, object?>>(tags.Length);
foreach (var tag in tags)
{
tagList.Add(tag);
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagList));
});
_listener.Start();
}
public IReadOnlyCollection<MetricMeasurement> Measurements => _measurements;
public void Dispose() => _listener.Dispose();
internal sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Kisa.Configuration;
using StellaOps.Concelier.Connector.Kisa.Internal;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Kisa.Tests;
[Collection("mongo-fixture")]
public sealed class KisaConnectorTests : IAsyncLifetime
{
private static readonly Uri FeedUri = new("https://test.local/rss/securityInfo.do");
private static readonly Uri DetailPageUri = new("https://test.local/detailDos.do?IDX=5868");
private readonly MongoIntegrationFixture _fixture;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
public KisaConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("5868");
advisory.Language.Should().Be("ko");
advisory.Aliases.Should().Contain("CVE-2025-29866");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("태그프리"));
advisory.References.Should().Contain(reference => reference.Url == DetailPageUri.ToString());
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Scheme.Should().Be(NormalizedVersionSchemes.SemVer);
normalized.Type.Should().Be(NormalizedVersionRuleTypes.Range);
normalized.Min.Should().Be("1.0.1-fw.84");
normalized.MinInclusive.Should().BeTrue();
normalized.Max.Should().Be("2.0.1-fw.34");
normalized.MaxInclusive.Should().BeTrue();
package.VersionRanges.Should().ContainSingle();
var range = package.VersionRanges.Single();
range.RangeKind.Should().Be("product");
range.RangeExpression.Should().Be("XFU 1.0.1.0084 ~ 2.0.1.0034");
var semVer = GetSemVer(range.Primitives);
semVer.Introduced.Should().Be("1.0.1-fw.84");
semVer.IntroducedInclusive.Should().BeTrue();
semVer.Fixed.Should().Be("2.0.1-fw.34");
semVer.FixedInclusive.Should().BeTrue();
semVer.ConstraintExpression.Should().Be(">= 1.0.1-fw.84 <= 2.0.1-fw.34");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions.Should().ContainKey("kisa.range.raw").WhoseValue.Should().Be("XFU 1.0.1.0084 ~ 2.0.1.0034");
vendorExtensions.Should().ContainKey("kisa.range.prefix").WhoseValue.Should().Be("XFU");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KisaConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsBsonArray.Should().BeEmpty();
}
[Fact]
public async Task FetchParseMap_ExclusiveUpperBound_ProducesExclusiveNormalizedRule()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 3.2 이상 4.0 미만");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Min.Should().Be("3.2.0");
normalized.MinInclusive.Should().BeTrue();
normalized.Max.Should().Be("4.0.0");
normalized.MaxInclusive.Should().BeFalse();
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.FixedInclusive.Should().BeFalse();
semVer.ConstraintExpression.Should().Be(">= 3.2.0 < 4.0.0");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be(">= 3.2.0 < 4.0.0");
}
[Fact]
public async Task FetchParseMap_ExclusiveLowerBound_ProducesExclusiveNormalizedRule()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 1.2.0 초과 2.4.0 이하");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Min.Should().Be("1.2.0");
normalized.MinInclusive.Should().BeFalse();
normalized.Max.Should().Be("2.4.0");
normalized.MaxInclusive.Should().BeTrue();
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.IntroducedInclusive.Should().BeFalse();
semVer.FixedInclusive.Should().BeTrue();
semVer.ConstraintExpression.Should().Be("> 1.2.0 <= 2.4.0");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be("> 1.2.0 <= 2.4.0");
}
[Fact]
public async Task FetchParseMap_SingleBound_ProducesMinimumOnlyConstraint()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 5.0 이상");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Min.Should().Be("5.0.0");
normalized.MinInclusive.Should().BeTrue();
normalized.Type.Should().Be(NormalizedVersionRuleTypes.GreaterThanOrEqual);
normalized.Max.Should().BeNull();
normalized.MaxInclusive.Should().BeNull();
_output.WriteLine($"normalized: scheme={normalized.Scheme}, type={normalized.Type}, min={normalized.Min}, minInclusive={normalized.MinInclusive}, max={normalized.Max}, maxInclusive={normalized.MaxInclusive}, notes={normalized.Notes}");
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.Introduced.Should().Be("5.0.0");
semVer.Fixed.Should().BeNull();
semVer.LastAffected.Should().BeNull();
semVer.ConstraintExpression.Should().Be(">= 5.0.0");
_output.WriteLine($"semver: introduced={semVer.Introduced}, introducedInclusive={semVer.IntroducedInclusive}, fixed={semVer.Fixed}, fixedInclusive={semVer.FixedInclusive}, lastAffected={semVer.LastAffected}, lastAffectedInclusive={semVer.LastAffectedInclusive}, constraint={semVer.ConstraintExpression}");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be(">= 5.0.0");
}
[Fact]
public async Task FetchParseMap_UpperBoundOnlyExclusive_ProducesLessThanRule()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 3.5 미만");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Type.Should().Be(NormalizedVersionRuleTypes.LessThan);
normalized.Min.Should().BeNull();
normalized.Max.Should().Be("3.5.0");
normalized.MaxInclusive.Should().BeFalse();
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.Fixed.Should().Be("3.5.0");
semVer.FixedInclusive.Should().BeFalse();
semVer.ConstraintExpression.Should().Be("< 3.5.0");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be("< 3.5.0");
}
[Fact]
public async Task FetchParseMap_UpperBoundOnlyInclusive_ProducesLessThanOrEqualRule()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 4.2 이하");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Type.Should().Be(NormalizedVersionRuleTypes.LessThanOrEqual);
normalized.Max.Should().Be("4.2.0");
normalized.MaxInclusive.Should().BeTrue();
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.Fixed.Should().Be("4.2.0");
semVer.FixedInclusive.Should().BeTrue();
semVer.ConstraintExpression.Should().Be("<= 4.2.0");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be("<= 4.2.0");
}
[Fact]
public async Task FetchParseMap_LowerBoundOnlyExclusive_ProducesGreaterThanRule()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("XFU 1.9 초과");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
var normalized = GetSingleNormalizedVersion(package);
normalized.Type.Should().Be(NormalizedVersionRuleTypes.GreaterThan);
normalized.Min.Should().Be("1.9.0");
normalized.MinInclusive.Should().BeFalse();
normalized.Max.Should().BeNull();
var range = package.VersionRanges.Single();
var semVer = GetSemVer(range.Primitives);
semVer.Introduced.Should().Be("1.9.0");
semVer.IntroducedInclusive.Should().BeFalse();
semVer.ConstraintExpression.Should().Be("> 1.9.0");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.normalized")
.WhoseValue.Should().Be("> 1.9.0");
}
[Fact]
public async Task FetchParseMap_InvalidSegment_ProducesFallbackRange()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses("지원 버전: 최신 업데이트 적용");
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(1, CancellationToken.None)).Single();
var package = advisory.AffectedPackages.Single();
package.NormalizedVersions.Should().BeEmpty();
var range = package.VersionRanges.Single();
range.RangeKind.Should().Be("string");
range.RangeExpression.Should().Be("지원 버전: 최신 업데이트 적용");
var vendorExtensions = GetVendorExtensions(range.Primitives);
vendorExtensions
.Should().ContainKey("kisa.range.raw")
.WhoseValue.Should().Be("지원 버전: 최신 업데이트 적용");
}
[Fact]
public async Task Telemetry_RecordsMetrics()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
using var metrics = new KisaMetricCollector();
var connector = provider.GetRequiredService<KisaConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
Sum(metrics.Measurements, "kisa.feed.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.feed.items").Should().BeGreaterThan(0);
Sum(metrics.Measurements, "kisa.detail.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.detail.failures").Should().Be(0);
Sum(metrics.Measurements, "kisa.parse.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.parse.failures").Should().Be(0);
Sum(metrics.Measurements, "kisa.map.success").Should().Be(1);
Sum(metrics.Measurements, "kisa.map.failures").Should().Be(0);
}
private static NormalizedVersionRule GetSingleNormalizedVersion(AffectedPackage package)
{
var normalizedVersions = package.NormalizedVersions;
if (normalizedVersions.IsDefaultOrEmpty)
{
throw new InvalidOperationException("Expected normalized version rule.");
}
return normalizedVersions.Single();
}
private static SemVerPrimitive GetSemVer(RangePrimitives? primitives)
=> primitives?.SemVer ?? throw new InvalidOperationException("Expected semver primitive.");
private static IReadOnlyDictionary<string, string> GetVendorExtensions(RangePrimitives? primitives)
=> primitives?.VendorExtensions ?? throw new InvalidOperationException("Expected vendor extensions.");
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton(_handler);
services.AddMongoStorage(options =>
{
options.ConnectionString = _fixture.Runner.ConnectionString;
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
options.CommandTimeout = TimeSpan.FromSeconds(5);
});
services.AddSourceCommon();
services.AddKisaConnector(options =>
{
options.FeedUri = FeedUri;
options.DetailApiUri = new Uri("https://test.local/rssDetailData.do");
options.DetailPageUri = new Uri("https://test.local/detailDos.do");
options.RequestDelay = TimeSpan.Zero;
options.MaxAdvisoriesPerFetch = 10;
options.MaxKnownAdvisories = 32;
});
services.Configure<HttpClientFactoryOptions>(KisaOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
await bootstrapper.InitializeAsync(CancellationToken.None);
return provider;
}
private void SeedResponses(string? versionOverride = null)
{
AddXmlResponse(FeedUri, ReadFixture("kisa-feed.xml"));
var detailPayload = BuildDetailHtml(versionOverride);
AddHtmlResponse(DetailPageUri, detailPayload);
}
private void AddXmlResponse(Uri uri, string xml)
{
_handler.AddResponse(uri, () => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(xml, Encoding.UTF8, "application/rss+xml"),
});
}
private void AddHtmlResponse(Uri uri, string html)
{
_handler.AddResponse(uri, () => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(html, Encoding.UTF8, "text/html"),
});
}
private static string ReadFixture(string fileName)
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
private static string BuildDetailHtml(string? versions)
{
var template = ReadFixture("kisa-detail.html");
var primary = versions ?? "XFU 1.0.1.0084";
var secondary = versions is null ? "XFU 2.0.1.0034" : string.Empty;
return template
.Replace("{{PRIMARY_VERSION}}", primary, StringComparison.Ordinal)
.Replace("{{SECONDARY_VERSION}}", secondary, StringComparison.Ordinal);
}
private static long Sum(IEnumerable<KisaMetricCollector.MetricMeasurement> measurements, string name)
=> measurements.Where(m => m.Name == name).Sum(m => m.Value);
private sealed class KisaMetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly ConcurrentBag<MetricMeasurement> _measurements = new();
public KisaMetricCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == KisaDiagnostics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
},
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagList = new List<KeyValuePair<string, object?>>(tags.Length);
foreach (var tag in tags)
{
tagList.Add(tag);
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagList));
});
_listener.Start();
}
public IReadOnlyCollection<MetricMeasurement> Measurements => _measurements;
public void Dispose() => _listener.Dispose();
internal sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using FluentAssertions;
using StellaOps.Concelier.Connector.Common.Html;
using StellaOps.Concelier.Connector.Kisa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Kisa.Tests;
public sealed class KisaDetailParserTests
{
private static readonly Uri DetailApiUri = new("https://test.local/rssDetailData.do?IDX=5868");
private static readonly Uri DetailPageUri = new("https://test.local/detailDos.do?IDX=5868");
[Fact]
public void ParseHtmlPayload_ProducesExpectedModels()
{
var parser = new KisaDetailParser(new HtmlContentSanitizer());
var payload = ReadFixtureBytes("kisa-detail.html", "XFU 1.0.1.0084", "XFU 2.0.1.0034");
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["kisa.idx"] = "5868",
["kisa.title"] = "태그프리 제품 부적절한 권한 검증 취약점",
["kisa.published"] = "2025-07-31T06:30:23Z",
};
var parsed = parser.Parse(DetailApiUri, DetailPageUri, payload, metadata);
parsed.AdvisoryId.Should().Be("5868");
parsed.Title.Should().Contain("태그프리");
parsed.Summary.Should().NotBeNullOrWhiteSpace();
parsed.ContentHtml.Should().Contain("TAGFREE");
parsed.Severity.Should().Be("High");
parsed.CveIds.Should().Contain("CVE-2025-29866");
parsed.Products.Should().ContainSingle();
var product = parsed.Products.Single();
product.Vendor.Should().Be("태그프리");
product.Name.Should().Be("X-Free Uploader");
product.Versions.Should().Be("XFU 1.0.1.0084 ~ 2.0.1.0034");
}
private static byte[] ReadFixtureBytes(string fileName, string primaryVersion, string secondaryVersion)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
var template = File.ReadAllText(path);
var html = template
.Replace("{{PRIMARY_VERSION}}", primaryVersion, StringComparison.Ordinal)
.Replace("{{SECONDARY_VERSION}}", secondaryVersion, StringComparison.Ordinal);
return Encoding.UTF8.GetBytes(html);
}
}

View File

@@ -13,6 +13,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.1" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures/*.json">
@@ -21,5 +22,8 @@
<None Update="Fixtures/*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Fixtures/*.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>