Add MergeUsageAnalyzer to detect legacy merge service usage
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user