Add MergeUsageAnalyzer to detect legacy merge service usage
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented MergeUsageAnalyzer to flag usage of AdvisoryMergeService and AddMergeModule.
- Created AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md for release documentation.
- Added tests for MergeUsageAnalyzer to ensure correct diagnostics for various scenarios.
- Updated project files for analyzers and tests to include necessary dependencies and configurations.
- Introduced a sample report structure for scanner output.
This commit is contained in:
master
2025-11-06 15:03:39 +02:00
parent 5a923d968c
commit 950f238a93
45 changed files with 1291 additions and 623 deletions

View File

@@ -42,13 +42,17 @@ public sealed class AdvisoryObservationFactoryTests
Assert.Equal(
new[] { "cpe:/a:Example:Product:1.0", "cpe:/a:example:product:1.0" },
observation.Linkset.Cpes);
Assert.Equal(2, observation.Linkset.References.Length);
Assert.All(
Assert.Collection(
observation.Linkset.References,
reference =>
first =>
{
Assert.Equal("advisory", reference.Type);
Assert.Equal("https://example.test/advisory", reference.Url);
Assert.Equal("Advisory", first.Type);
Assert.Equal("https://example.test/advisory", first.Url);
},
second =>
{
Assert.Equal("ADVISORY", second.Type);
Assert.Equal("https://example.test/advisory", second.Url);
});
Assert.Equal(

View File

@@ -311,7 +311,7 @@ public sealed class AdvisoryObservationQueryServiceTests
}
var observationIdSet = observationIds.ToImmutableHashSet(StringComparer.Ordinal);
var aliasSet = aliases.ToImmutableHashSet(StringComparer.Ordinal);
var aliasSet = aliases.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
var purlSet = purls.ToImmutableHashSet(StringComparer.Ordinal);
var cpeSet = cpes.ToImmutableHashSet(StringComparer.Ordinal);
var filtered = observations

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
namespace StellaOps.Concelier.Merge.Analyzers.Tests;
public sealed class MergeUsageAnalyzerTests
{
[Fact]
public async Task ReportsDiagnostic_ForAdvisoryMergeServiceInstantiation()
{
const string source = """
using StellaOps.Concelier.Merge.Services;
namespace Sample.App;
public sealed class Demo
{
public void Run()
{
var merge = new AdvisoryMergeService();
}
}
""";
var diagnostics = await AnalyzeAsync(source, "Sample.App");
Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId && d.GetMessage().Contains("AdvisoryMergeService", StringComparison.Ordinal));
}
[Fact]
public async Task ReportsDiagnostic_ForAddMergeModuleInvocation()
{
const string source = """
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Merge;
namespace Sample.Services;
public static class Installer
{
public static void Configure(IServiceCollection services, IConfiguration configuration)
{
services.AddMergeModule(configuration);
}
}
""";
var diagnostics = await AnalyzeAsync(source, "Sample.Services");
Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId && d.GetMessage().Contains("AddMergeModule", StringComparison.Ordinal));
}
[Fact]
public async Task ReportsDiagnostic_ForFieldDeclaration()
{
const string source = """
using StellaOps.Concelier.Merge.Services;
namespace Sample.Library;
public sealed class Demo
{
private AdvisoryMergeService? _mergeService;
}
""";
var diagnostics = await AnalyzeAsync(source, "Sample.Library");
Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId);
}
[Fact]
public async Task DoesNotReportDiagnostic_InsideMergeAssembly()
{
const string source = """
using StellaOps.Concelier.Merge.Services;
namespace StellaOps.Concelier.Merge.Internal;
internal static class MergeDiagnostics
{
public static AdvisoryMergeService Create() => new AdvisoryMergeService();
}
""";
var diagnostics = await AnalyzeAsync(source, "StellaOps.Concelier.Merge");
Assert.DoesNotContain(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId);
}
[Fact]
public async Task ReportsDiagnostic_ForTypeOfUsage()
{
const string source = """
using System;
using StellaOps.Concelier.Merge.Services;
namespace Sample.TypeOf;
public static class Demo
{
public static Type TargetType => typeof(AdvisoryMergeService);
}
""";
var diagnostics = await AnalyzeAsync(source, "Sample.TypeOf");
Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId);
}
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
{
var compilation = CSharpCompilation.Create(
assemblyName,
new[]
{
CSharpSyntaxTree.ParseText(source),
CSharpSyntaxTree.ParseText(Stubs)
},
CreateMetadataReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var analyzer = new MergeUsageAnalyzer();
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
}
private static IEnumerable<MetadataReference> CreateMetadataReferences()
{
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
}
private const string Stubs = """
namespace Microsoft.Extensions.DependencyInjection
{
public interface IServiceCollection { }
}
namespace Microsoft.Extensions.Configuration
{
public interface IConfiguration { }
}
namespace StellaOps.Concelier.Merge.Services
{
public sealed class AdvisoryMergeService { }
}
namespace StellaOps.Concelier.Merge
{
public static class MergeServiceCollectionExtensions
{
public static void AddMergeModule(
this Microsoft.Extensions.DependencyInjection.IServiceCollection services,
Microsoft.Extensions.Configuration.IConfiguration configuration)
{
}
}
}
""";
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\__Analyzers\\StellaOps.Concelier.Merge.Analyzers\\StellaOps.Concelier.Merge.Analyzers.csproj" />
</ItemGroup>
</Project>

View File

@@ -63,11 +63,14 @@ public sealed class AdvisoryObservationTests
Assert.Equal("tenant-a:CVE-2025-1234:1", observation.ObservationId);
Assert.Equal("tenant-a", observation.Tenant);
Assert.Equal("Vendor", observation.Source.Vendor);
Assert.Equal(new[] { "cpe:/a:vendor:product:1" }, observation.Linkset.Cpes);
Assert.Single(observation.Linkset.References);
Assert.Equal("https://example.com/advisory", observation.Linkset.References[0].Url);
Assert.Equal(DateTimeOffset.Parse("2025-10-01T01:00:06Z"), observation.CreatedAt);
Assert.Equal("emea", observation.Attributes["region"]);
}
}
Assert.Equal("Vendor", observation.Source.Vendor);
Assert.Equal(new[] { " Cve-2025-1234 ", "cve-2025-1234" }, observation.Linkset.Aliases.ToArray());
Assert.Equal(new[] { "cpe:/a:vendor:product:1" }, observation.Linkset.Cpes);
Assert.Equal(2, observation.Linkset.References.Length);
Assert.Equal("ADVISORY", observation.Linkset.References[0].Type);
Assert.Equal("https://example.com/advisory", observation.Linkset.References[0].Url);
Assert.Equal(rawLinkset.Aliases, observation.RawLinkset.Aliases);
Assert.Equal(DateTimeOffset.Parse("2025-10-01T01:00:06Z"), observation.CreatedAt);
Assert.Equal("emea", observation.Attributes["region"]);
}
}

View File

@@ -71,11 +71,12 @@ public sealed class AdvisoryObservationDocumentFactoryTests
Assert.Equal("tenant-a:obs-1", observation.ObservationId);
Assert.Equal("tenant-a", observation.Tenant);
Assert.Equal("CVE-2025-1234", observation.Upstream.UpstreamId);
Assert.Equal("CVE-2025-1234", observation.Upstream.UpstreamId);
Assert.Equal(new[] { "CVE-2025-1234" }, observation.Linkset.Aliases.ToArray());
Assert.Contains("pkg:generic/foo@1.0.0", observation.Linkset.Purls);
Assert.Equal("CSAF", observation.Content.Format);
Assert.True(observation.Content.Raw?["example"]?.GetValue<bool>());
Assert.Equal("advisory", observation.Linkset.References[0].Type);
Assert.Equal(document.Linkset.References![0].Type, observation.Linkset.References[0].Type);
Assert.Equal(new[] { "CVE-2025-1234", "cve-2025-1234" }, observation.RawLinkset.Aliases);
Assert.Equal("Advisory", observation.RawLinkset.References[0].Type);
Assert.Equal("vendor", observation.RawLinkset.References[0].Source);

View File

@@ -31,22 +31,22 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture<MongoIntegrati
var collection = _fixture.Database.GetCollection<AdvisoryObservationDocument>(MongoStorageDefaults.Collections.AdvisoryObservations);
await collection.InsertManyAsync(new[]
{
CreateDocument(
id: "tenant-a:nvd:alpha:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@1.0.0" }),
CreateDocument(
id: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "ghsa-xyz0", "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@1.1.0" }),
CreateDocument(
id: "tenant-b:nvd:alpha:1",
tenant: "tenant-b",
createdAt: new DateTime(2025, 1, 3, 0, 0, 0, DateTimeKind.Utc),
CreateDocument(
id: "tenant-a:nvd:alpha:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "CvE-2025-0001 " },
purls: new[] { "pkg:npm/demo@1.0.0" }),
CreateDocument(
id: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
createdAt: new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { " ghsa-xyz0", "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@1.1.0" }),
CreateDocument(
id: "tenant-b:nvd:alpha:1",
tenant: "tenant-b",
createdAt: new DateTime(2025, 1, 3, 0, 0, 0, DateTimeKind.Utc),
aliases: new[] { "cve-2025-0001" },
purls: new[] { "pkg:npm/demo@2.0.0" })
});
@@ -62,11 +62,15 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture<MongoIntegrati
limit: 5,
CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.Equal("tenant-a:ghsa:beta:1", result[0].ObservationId);
Assert.Equal("tenant-a:nvd:alpha:1", result[1].ObservationId);
Assert.All(result, observation => Assert.Equal("tenant-a", observation.Tenant));
}
Assert.Equal(2, result.Count);
Assert.Equal("tenant-a:ghsa:beta:1", result[0].ObservationId);
Assert.Equal("tenant-a:nvd:alpha:1", result[1].ObservationId);
Assert.All(result, observation => Assert.Equal("tenant-a", observation.Tenant));
Assert.Equal("ghsa-xyz0", result[0].Linkset.Aliases[0]);
Assert.Equal("CvE-2025-0001", result[1].Linkset.Aliases[0]);
Assert.Equal(" ghsa-xyz0", result[0].RawLinkset.Aliases[0]);
Assert.Equal("CvE-2025-0001 ", result[1].RawLinkset.Aliases[0]);
}
[Fact]
public async Task FindByFiltersAsync_RespectsObservationIdsAndPurls()
@@ -166,12 +170,39 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture<MongoIntegrati
IEnumerable<string>? purls = null,
IEnumerable<string>? cpes = null)
{
return new AdvisoryObservationDocument
{
Id = id,
Tenant = tenant.ToLowerInvariant(),
CreatedAt = createdAt,
Source = new AdvisoryObservationSourceDocument
var canonicalAliases = aliases?
.Where(value => value is not null)
.Select(value => value.Trim())
.ToList();
var canonicalPurls = purls?
.Where(value => value is not null)
.Select(value => value.Trim())
.ToList();
var canonicalCpes = cpes?
.Where(value => value is not null)
.Select(value => value.Trim())
.ToList();
var rawAliases = aliases?
.Where(value => value is not null)
.ToList();
var rawPurls = purls?
.Where(value => value is not null)
.ToList();
var rawCpes = cpes?
.Where(value => value is not null)
.ToList();
return new AdvisoryObservationDocument
{
Id = id,
Tenant = tenant.ToLowerInvariant(),
CreatedAt = createdAt,
Source = new AdvisoryObservationSourceDocument
{
Vendor = "nvd",
Stream = "feed",
@@ -189,24 +220,31 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture<MongoIntegrati
Present = false
},
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
},
Content = new AdvisoryObservationContentDocument
{
Format = "csaf",
SpecVersion = "2.0",
Raw = BsonDocument.Parse("""{"id": "%ID%"}""".Replace("%ID%", id)),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
},
Linkset = new AdvisoryObservationLinksetDocument
{
Aliases = aliases?.Select(value => value.Trim()).ToList(),
Purls = purls?.Select(value => value.Trim()).ToList(),
Cpes = cpes?.Select(value => value.Trim()).ToList(),
References = new List<AdvisoryObservationReferenceDocument>()
},
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
};
}
},
Content = new AdvisoryObservationContentDocument
{
Format = "csaf",
SpecVersion = "2.0",
Raw = BsonDocument.Parse("""{"id": "%ID%"}""".Replace("%ID%", id)),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
},
Linkset = new AdvisoryObservationLinksetDocument
{
Aliases = canonicalAliases,
Purls = canonicalPurls,
Cpes = canonicalCpes,
References = new List<AdvisoryObservationReferenceDocument>()
},
RawLinkset = new AdvisoryObservationRawLinksetDocument
{
Aliases = rawAliases,
PackageUrls = rawPurls,
Cpes = rawCpes,
References = new List<AdvisoryObservationRawReferenceDocument>()
},
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
};
}
private async Task ResetCollectionAsync()
{

View File

@@ -10,7 +10,7 @@
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Analyzers/StellaOps.Concelier.Analyzers/StellaOps.Concelier.Analyzers.csproj"
<ProjectReference Include="../../__Analyzers/StellaOps.Concelier.Merge.Analyzers/StellaOps.Concelier.Merge.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

View File

@@ -1183,19 +1183,19 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
},
Linkset = new AdvisoryObservationLinksetDocument
{
Aliases = aliases?.Select(value => value.Trim().ToLowerInvariant()).ToList(),
Purls = purls?.Select(value => value.Trim()).ToList(),
Cpes = cpes?.Select(value => value.Trim()).ToList(),
References = references is null
? new List<AdvisoryObservationReferenceDocument>()
: references
.Select(reference => new AdvisoryObservationReferenceDocument
{
Type = reference.Type.Trim().ToLowerInvariant(),
Url = reference.Url.Trim()
})
.ToList()
},
Aliases = aliases?.Where(value => value is not null).ToList(),
Purls = purls?.Where(value => value is not null).ToList(),
Cpes = cpes?.Where(value => value is not null).ToList(),
References = references is null
? new List<AdvisoryObservationReferenceDocument>()
: references
.Select(reference => new AdvisoryObservationReferenceDocument
{
Type = reference.Type,
Url = reference.Url
})
.ToList()
},
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
};
}