Add scripts for resolving and verifying Chromium binary paths
	
		
			
	
		
	
	
		
	
		
			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 `chrome-path.js` to define functions for locating Chromium binaries across different platforms and nested directories. - Added `verify-chromium.js` to check for the presence of the Chromium binary and log the results, including candidate paths checked. - The scripts support Linux, Windows, and macOS environments, enhancing the flexibility of Chromium binary detection.
This commit is contained in:
		
							
								
								
									
										416
									
								
								src/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										416
									
								
								src/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,416 @@ | ||||
| using System; | ||||
| using System.CommandLine; | ||||
| using System.Threading; | ||||
| using StellaOps.Cli.Commands; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Plugins; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins.NonCore; | ||||
|  | ||||
| public sealed class NonCoreCliCommandModule : ICliCommandModule | ||||
| { | ||||
|     public string Name => "stellaops.cli.plugins.noncore"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => true; | ||||
|  | ||||
|     public void RegisterCommands( | ||||
|         RootCommand root, | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         Option<bool> verboseOption, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(root); | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         ArgumentNullException.ThrowIfNull(verboseOption); | ||||
|  | ||||
|         root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken)); | ||||
|     } | ||||
|  | ||||
|     private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows."); | ||||
|  | ||||
|         var init = new Command("init", "Initialize Excititor ingest state."); | ||||
|         var initProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to initialize.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var resumeOption = new Option<bool>("--resume") | ||||
|         { | ||||
|             Description = "Resume ingest from the last persisted checkpoint instead of starting fresh." | ||||
|         }; | ||||
|         init.Add(initProviders); | ||||
|         init.Add(resumeOption); | ||||
|         init.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>(); | ||||
|             var resume = parseResult.GetValue(resumeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var pull = new Command("pull", "Trigger Excititor ingest for configured providers."); | ||||
|         var pullProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to ingest.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var sinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to begin the ingest window." | ||||
|         }; | ||||
|         var windowOption = new Option<TimeSpan?>("--window") | ||||
|         { | ||||
|             Description = "Optional window duration (e.g. 24:00:00)." | ||||
|         }; | ||||
|         var forceOption = new Option<bool>("--force") | ||||
|         { | ||||
|             Description = "Force ingestion even if the backend reports no pending work." | ||||
|         }; | ||||
|         pull.Add(pullProviders); | ||||
|         pull.Add(sinceOption); | ||||
|         pull.Add(windowOption); | ||||
|         pull.Add(forceOption); | ||||
|         pull.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>(); | ||||
|             var since = parseResult.GetValue(sinceOption); | ||||
|             var window = parseResult.GetValue(windowOption); | ||||
|             var force = parseResult.GetValue(forceOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token."); | ||||
|         var resumeProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to resume.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var checkpointOption = new Option<string?>("--checkpoint") | ||||
|         { | ||||
|             Description = "Optional checkpoint identifier to resume from." | ||||
|         }; | ||||
|         resume.Add(resumeProviders); | ||||
|         resume.Add(checkpointOption); | ||||
|         resume.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>(); | ||||
|             var checkpoint = parseResult.GetValue(checkpointOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var list = new Command("list-providers", "List Excititor providers and their ingest status."); | ||||
|         var includeDisabledOption = new Option<bool>("--include-disabled") | ||||
|         { | ||||
|             Description = "Include disabled providers in the listing." | ||||
|         }; | ||||
|         list.Add(includeDisabledOption); | ||||
|         list.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var includeDisabled = parseResult.GetValue(includeDisabledOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var export = new Command("export", "Trigger Excititor export generation."); | ||||
|         var formatOption = new Option<string>("--format") | ||||
|         { | ||||
|             Description = "Export format (e.g. openvex, json)." | ||||
|         }; | ||||
|         var exportDeltaOption = new Option<bool>("--delta") | ||||
|         { | ||||
|             Description = "Request a delta export when supported." | ||||
|         }; | ||||
|         var exportScopeOption = new Option<string?>("--scope") | ||||
|         { | ||||
|             Description = "Optional policy scope or tenant identifier." | ||||
|         }; | ||||
|         var exportSinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to restrict export contents." | ||||
|         }; | ||||
|         var exportProviderOption = new Option<string?>("--provider") | ||||
|         { | ||||
|             Description = "Optional provider identifier when requesting targeted exports." | ||||
|         }; | ||||
|         var exportOutputOption = new Option<string?>("--output") | ||||
|         { | ||||
|             Description = "Optional path to download the export artifact." | ||||
|         }; | ||||
|         export.Add(formatOption); | ||||
|         export.Add(exportDeltaOption); | ||||
|         export.Add(exportScopeOption); | ||||
|         export.Add(exportSinceOption); | ||||
|         export.Add(exportProviderOption); | ||||
|         export.Add(exportOutputOption); | ||||
|         export.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var format = parseResult.GetValue(formatOption) ?? "openvex"; | ||||
|             var delta = parseResult.GetValue(exportDeltaOption); | ||||
|             var scope = parseResult.GetValue(exportScopeOption); | ||||
|             var since = parseResult.GetValue(exportSinceOption); | ||||
|             var provider = parseResult.GetValue(exportProviderOption); | ||||
|             var output = parseResult.GetValue(exportOutputOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements."); | ||||
|         var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since") | ||||
|         { | ||||
|             Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp." | ||||
|         }; | ||||
|         var backfillForceOption = new Option<bool>("--force") | ||||
|         { | ||||
|             Description = "Reprocess documents even if statements already exist." | ||||
|         }; | ||||
|         var backfillBatchSizeOption = new Option<int>("--batch-size") | ||||
|         { | ||||
|             Description = "Number of raw documents to fetch per batch (default 100)." | ||||
|         }; | ||||
|         var backfillMaxDocumentsOption = new Option<int?>("--max-documents") | ||||
|         { | ||||
|             Description = "Optional maximum number of raw documents to process." | ||||
|         }; | ||||
|         backfill.Add(backfillRetrievedSinceOption); | ||||
|         backfill.Add(backfillForceOption); | ||||
|         backfill.Add(backfillBatchSizeOption); | ||||
|         backfill.Add(backfillMaxDocumentsOption); | ||||
|         backfill.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption); | ||||
|             var force = parseResult.GetValue(backfillForceOption); | ||||
|             var batchSize = parseResult.GetValue(backfillBatchSizeOption); | ||||
|             if (batchSize <= 0) | ||||
|             { | ||||
|                 batchSize = 100; | ||||
|             } | ||||
|             var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorBackfillStatementsAsync( | ||||
|                 services, | ||||
|                 retrievedSince, | ||||
|                 force, | ||||
|                 batchSize, | ||||
|                 maxDocuments, | ||||
|                 verbose, | ||||
|                 cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var verify = new Command("verify", "Verify Excititor exports or attestations."); | ||||
|         var exportIdOption = new Option<string?>("--export-id") | ||||
|         { | ||||
|             Description = "Export identifier to verify." | ||||
|         }; | ||||
|         var digestOption = new Option<string?>("--digest") | ||||
|         { | ||||
|             Description = "Expected digest for the export or attestation." | ||||
|         }; | ||||
|         var attestationOption = new Option<string?>("--attestation") | ||||
|         { | ||||
|             Description = "Path to a local attestation file to verify (base64 content will be uploaded)." | ||||
|         }; | ||||
|         verify.Add(exportIdOption); | ||||
|         verify.Add(digestOption); | ||||
|         verify.Add(attestationOption); | ||||
|         verify.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var exportId = parseResult.GetValue(exportIdOption); | ||||
|             var digest = parseResult.GetValue(digestOption); | ||||
|             var attestation = parseResult.GetValue(attestationOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories."); | ||||
|         var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to reconcile.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var maxAgeOption = new Option<TimeSpan?>("--max-age") | ||||
|         { | ||||
|             Description = "Optional maximum age window (e.g. 7.00:00:00)." | ||||
|         }; | ||||
|         reconcile.Add(reconcileProviders); | ||||
|         reconcile.Add(maxAgeOption); | ||||
|         reconcile.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>(); | ||||
|             var maxAge = parseResult.GetValue(maxAgeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         excititor.Add(init); | ||||
|         excititor.Add(pull); | ||||
|         excititor.Add(resume); | ||||
|         excititor.Add(list); | ||||
|         excititor.Add(export); | ||||
|         excititor.Add(backfill); | ||||
|         excititor.Add(verify); | ||||
|         excititor.Add(reconcile); | ||||
|         return excititor; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var runtime = new Command("runtime", "Interact with runtime admission policy APIs."); | ||||
|         var policy = new Command("policy", "Runtime policy operations."); | ||||
|  | ||||
|         var test = new Command("test", "Evaluate runtime policy decisions for image digests."); | ||||
|         var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" }) | ||||
|         { | ||||
|             Description = "Namespace or logical scope for the evaluation." | ||||
|         }; | ||||
|  | ||||
|         var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" }) | ||||
|         { | ||||
|             Description = "Image digests to evaluate (repeatable).", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|  | ||||
|         var fileOption = new Option<string?>("--file", new[] { "-f" }) | ||||
|         { | ||||
|             Description = "Path to a file containing image digests (one per line)." | ||||
|         }; | ||||
|  | ||||
|         var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" }) | ||||
|         { | ||||
|             Description = "Pod labels in key=value format (repeatable).", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|  | ||||
|         var jsonOption = new Option<bool>("--json") | ||||
|         { | ||||
|             Description = "Emit the raw JSON response." | ||||
|         }; | ||||
|  | ||||
|         test.Add(namespaceOption); | ||||
|         test.Add(imageOption); | ||||
|         test.Add(fileOption); | ||||
|         test.Add(labelOption); | ||||
|         test.Add(jsonOption); | ||||
|  | ||||
|         test.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var nsValue = parseResult.GetValue(namespaceOption); | ||||
|             var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>(); | ||||
|             var file = parseResult.GetValue(fileOption); | ||||
|             var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>(); | ||||
|             var outputJson = parseResult.GetValue(jsonOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|  | ||||
|             return CommandHandlers.HandleRuntimePolicyTestAsync( | ||||
|                 services, | ||||
|                 nsValue, | ||||
|                 images, | ||||
|                 file, | ||||
|                 labels, | ||||
|                 outputJson, | ||||
|                 verbose, | ||||
|                 cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         policy.Add(test); | ||||
|         runtime.Add(policy); | ||||
|         return runtime; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var offline = new Command("offline", "Offline kit workflows and utilities."); | ||||
|  | ||||
|         var kit = new Command("kit", "Manage offline kit bundles."); | ||||
|  | ||||
|         var pull = new Command("pull", "Download the latest offline kit bundle."); | ||||
|         var bundleIdOption = new Option<string?>("--bundle-id") | ||||
|         { | ||||
|             Description = "Optional bundle identifier. Defaults to the latest available." | ||||
|         }; | ||||
|         var destinationOption = new Option<string?>("--destination") | ||||
|         { | ||||
|             Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)." | ||||
|         }; | ||||
|         var overwriteOption = new Option<bool>("--overwrite") | ||||
|         { | ||||
|             Description = "Overwrite existing files even if checksums match." | ||||
|         }; | ||||
|         var noResumeOption = new Option<bool>("--no-resume") | ||||
|         { | ||||
|             Description = "Disable resuming partial downloads." | ||||
|         }; | ||||
|  | ||||
|         pull.Add(bundleIdOption); | ||||
|         pull.Add(destinationOption); | ||||
|         pull.Add(overwriteOption); | ||||
|         pull.Add(noResumeOption); | ||||
|         pull.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var bundleId = parseResult.GetValue(bundleIdOption); | ||||
|             var destination = parseResult.GetValue(destinationOption); | ||||
|             var overwrite = parseResult.GetValue(overwriteOption); | ||||
|             var resume = !parseResult.GetValue(noResumeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var import = new Command("import", "Upload an offline kit bundle to the backend."); | ||||
|         var bundleArgument = new Argument<string>("bundle") | ||||
|         { | ||||
|             Description = "Path to the offline kit tarball (.tgz)." | ||||
|         }; | ||||
|         var manifestOption = new Option<string?>("--manifest") | ||||
|         { | ||||
|             Description = "Offline manifest JSON path (defaults to metadata or sibling file)." | ||||
|         }; | ||||
|         var bundleSignatureOption = new Option<string?>("--bundle-signature") | ||||
|         { | ||||
|             Description = "Detached signature for the offline bundle (e.g. .sig)." | ||||
|         }; | ||||
|         var manifestSignatureOption = new Option<string?>("--manifest-signature") | ||||
|         { | ||||
|             Description = "Detached signature for the offline manifest (e.g. .jws)." | ||||
|         }; | ||||
|  | ||||
|         import.Add(bundleArgument); | ||||
|         import.Add(manifestOption); | ||||
|         import.Add(bundleSignatureOption); | ||||
|         import.Add(manifestSignatureOption); | ||||
|         import.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty; | ||||
|             var manifest = parseResult.GetValue(manifestOption); | ||||
|             var bundleSignature = parseResult.GetValue(bundleSignatureOption); | ||||
|             var manifestSignature = parseResult.GetValue(manifestSignatureOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var status = new Command("status", "Display offline kit installation status."); | ||||
|         var jsonOption = new Option<bool>("--json") | ||||
|         { | ||||
|             Description = "Emit status as JSON." | ||||
|         }; | ||||
|         status.Add(jsonOption); | ||||
|         status.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var asJson = parseResult.GetValue(jsonOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         kit.Add(pull); | ||||
|         kit.Add(import); | ||||
|         kit.Add(status); | ||||
|  | ||||
|         offline.Add(kit); | ||||
|         return offline; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\\..\\plugins\\cli\\StellaOps.Cli.Plugins.NonCore\\'))</PluginOutputDirectory> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <Target Name="CopyPluginBinaries" AfterTargets="Build"> | ||||
|     <MakeDir Directories="$(PluginOutputDirectory)" /> | ||||
|     <Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" /> | ||||
|     <Copy SourceFiles="$(TargetDir)$(TargetName).pdb" | ||||
|           DestinationFolder="$(PluginOutputDirectory)" | ||||
|           Condition="Exists('$(TargetDir)$(TargetName).pdb')" /> | ||||
|   </Target> | ||||
| </Project> | ||||
| @@ -0,0 +1,41 @@ | ||||
| using System; | ||||
| using System.CommandLine; | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Plugins; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Plugins; | ||||
|  | ||||
| public sealed class CliCommandModuleLoaderTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void RegisterModules_LoadsNonCoreCommandsFromPlugin() | ||||
|     { | ||||
|         var options = new StellaOpsCliOptions(); | ||||
|         var repoRoot = Path.GetFullPath( | ||||
|             Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); | ||||
|  | ||||
|         options.Plugins.BaseDirectory = repoRoot; | ||||
|         options.Plugins.Directory = "plugins/cli"; | ||||
|  | ||||
|         var services = new ServiceCollection() | ||||
|             .AddSingleton(options) | ||||
|             .BuildServiceProvider(); | ||||
|  | ||||
|         var logger = NullLoggerFactory.Instance.CreateLogger<CliCommandModuleLoader>(); | ||||
|         var loader = new CliCommandModuleLoader(services, options, logger); | ||||
|  | ||||
|         var root = new RootCommand(); | ||||
|         var verbose = new Option<bool>("--verbose"); | ||||
|  | ||||
|         loader.RegisterModules(root, verbose, CancellationToken.None); | ||||
|  | ||||
|         Assert.Contains(root.Children, command => string.Equals(command.Name, "excititor", StringComparison.Ordinal)); | ||||
|         Assert.Contains(root.Children, command => string.Equals(command.Name, "runtime", StringComparison.Ordinal)); | ||||
|         Assert.Contains(root.Children, command => string.Equals(command.Name, "offline", StringComparison.Ordinal)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| using StellaOps.Cli.Plugins; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Plugins; | ||||
|  | ||||
| public sealed class RestartOnlyCliPluginGuardTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void EnsureRegistrationAllowed_AllowsDuringStartup() | ||||
|     { | ||||
|         var guard = new RestartOnlyCliPluginGuard(); | ||||
|         guard.EnsureRegistrationAllowed("./plugins/sample.dll"); | ||||
|         guard.Seal(); | ||||
|  | ||||
|         // Re-registering known plug-ins after sealing should succeed. | ||||
|         guard.EnsureRegistrationAllowed("./plugins/sample.dll"); | ||||
|         Assert.True(guard.IsSealed); | ||||
|         Assert.Single(guard.KnownPlugins); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void EnsureRegistrationAllowed_ThrowsForUnknownAfterSeal() | ||||
|     { | ||||
|         var guard = new RestartOnlyCliPluginGuard(); | ||||
|         guard.Seal(); | ||||
|  | ||||
|         Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll")); | ||||
|     } | ||||
| } | ||||
| @@ -1,19 +1,27 @@ | ||||
| using System; | ||||
| using System.CommandLine; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Plugins; | ||||
|  | ||||
| namespace StellaOps.Cli.Commands; | ||||
|  | ||||
| internal static class CommandFactory | ||||
| { | ||||
|     public static RootCommand Create(IServiceProvider services, StellaOpsCliOptions options, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var verboseOption = new Option<bool>("--verbose", new[] { "-v" }) | ||||
|         { | ||||
|             Description = "Enable verbose logging output." | ||||
|         }; | ||||
| internal static class CommandFactory | ||||
| { | ||||
|     public static RootCommand Create( | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         CancellationToken cancellationToken, | ||||
|         ILoggerFactory loggerFactory) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(loggerFactory); | ||||
|  | ||||
|         var verboseOption = new Option<bool>("--verbose", new[] { "-v" }) | ||||
|         { | ||||
|             Description = "Enable verbose logging output." | ||||
|         }; | ||||
|  | ||||
|         var root = new RootCommand("StellaOps command-line interface") | ||||
|         { | ||||
| @@ -24,12 +32,13 @@ internal static class CommandFactory | ||||
|         root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken)); | ||||
|         root.Add(BuildConfigCommand(options)); | ||||
|  | ||||
|         var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>(); | ||||
|         var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger); | ||||
|         pluginLoader.RegisterModules(root, verboseOption, cancellationToken); | ||||
|  | ||||
|         return root; | ||||
|     } | ||||
|  | ||||
| @@ -227,300 +236,6 @@ internal static class CommandFactory | ||||
|         return db; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows."); | ||||
|  | ||||
|         var init = new Command("init", "Initialize Excititor ingest state."); | ||||
|         var initProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to initialize.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var resumeOption = new Option<bool>("--resume") | ||||
|         { | ||||
|             Description = "Resume ingest from the last persisted checkpoint instead of starting fresh." | ||||
|         }; | ||||
|         init.Add(initProviders); | ||||
|         init.Add(resumeOption); | ||||
|         init.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>(); | ||||
|             var resume = parseResult.GetValue(resumeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var pull = new Command("pull", "Trigger Excititor ingest for configured providers."); | ||||
|         var pullProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to ingest.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var sinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to begin the ingest window." | ||||
|         }; | ||||
|         var windowOption = new Option<TimeSpan?>("--window") | ||||
|         { | ||||
|             Description = "Optional window duration (e.g. 24:00:00)." | ||||
|         }; | ||||
|         var forceOption = new Option<bool>("--force") | ||||
|         { | ||||
|             Description = "Force ingestion even if the backend reports no pending work." | ||||
|         }; | ||||
|         pull.Add(pullProviders); | ||||
|         pull.Add(sinceOption); | ||||
|         pull.Add(windowOption); | ||||
|         pull.Add(forceOption); | ||||
|         pull.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>(); | ||||
|             var since = parseResult.GetValue(sinceOption); | ||||
|             var window = parseResult.GetValue(windowOption); | ||||
|             var force = parseResult.GetValue(forceOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token."); | ||||
|         var resumeProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to resume.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var checkpointOption = new Option<string?>("--checkpoint") | ||||
|         { | ||||
|             Description = "Optional checkpoint identifier to resume from." | ||||
|         }; | ||||
|         resume.Add(resumeProviders); | ||||
|         resume.Add(checkpointOption); | ||||
|         resume.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>(); | ||||
|             var checkpoint = parseResult.GetValue(checkpointOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var list = new Command("list-providers", "List Excititor providers and their ingest status."); | ||||
|         var includeDisabledOption = new Option<bool>("--include-disabled") | ||||
|         { | ||||
|             Description = "Include disabled providers in the listing." | ||||
|         }; | ||||
|         list.Add(includeDisabledOption); | ||||
|         list.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var includeDisabled = parseResult.GetValue(includeDisabledOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var export = new Command("export", "Trigger Excititor export generation."); | ||||
|         var formatOption = new Option<string>("--format") | ||||
|         { | ||||
|             Description = "Export format (e.g. openvex, json)." | ||||
|         }; | ||||
|         var exportDeltaOption = new Option<bool>("--delta") | ||||
|         { | ||||
|             Description = "Request a delta export when supported." | ||||
|         }; | ||||
|         var exportScopeOption = new Option<string?>("--scope") | ||||
|         { | ||||
|             Description = "Optional policy scope or tenant identifier." | ||||
|         }; | ||||
|         var exportSinceOption = new Option<DateTimeOffset?>("--since") | ||||
|         { | ||||
|             Description = "Optional ISO-8601 timestamp to restrict export contents." | ||||
|         }; | ||||
|         var exportProviderOption = new Option<string?>("--provider") | ||||
|         { | ||||
|             Description = "Optional provider identifier when requesting targeted exports." | ||||
|         }; | ||||
|         var exportOutputOption = new Option<string?>("--output") | ||||
|         { | ||||
|             Description = "Optional path to download the export artifact." | ||||
|         }; | ||||
|         export.Add(formatOption); | ||||
|         export.Add(exportDeltaOption); | ||||
|         export.Add(exportScopeOption); | ||||
|         export.Add(exportSinceOption); | ||||
|         export.Add(exportProviderOption); | ||||
|         export.Add(exportOutputOption); | ||||
|         export.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var format = parseResult.GetValue(formatOption) ?? "openvex"; | ||||
|             var delta = parseResult.GetValue(exportDeltaOption); | ||||
|             var scope = parseResult.GetValue(exportScopeOption); | ||||
|             var since = parseResult.GetValue(exportSinceOption); | ||||
|             var provider = parseResult.GetValue(exportProviderOption); | ||||
|             var output = parseResult.GetValue(exportOutputOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements."); | ||||
|         var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since") | ||||
|         { | ||||
|             Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp." | ||||
|         }; | ||||
|         var backfillForceOption = new Option<bool>("--force") | ||||
|         { | ||||
|             Description = "Reprocess documents even if statements already exist." | ||||
|         }; | ||||
|         var backfillBatchSizeOption = new Option<int>("--batch-size") | ||||
|         { | ||||
|             Description = "Number of raw documents to fetch per batch (default 100)." | ||||
|         }; | ||||
|         var backfillMaxDocumentsOption = new Option<int?>("--max-documents") | ||||
|         { | ||||
|             Description = "Optional maximum number of raw documents to process." | ||||
|         }; | ||||
|         backfill.Add(backfillRetrievedSinceOption); | ||||
|         backfill.Add(backfillForceOption); | ||||
|         backfill.Add(backfillBatchSizeOption); | ||||
|         backfill.Add(backfillMaxDocumentsOption); | ||||
|         backfill.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption); | ||||
|             var force = parseResult.GetValue(backfillForceOption); | ||||
|             var batchSize = parseResult.GetValue(backfillBatchSizeOption); | ||||
|             if (batchSize <= 0) | ||||
|             { | ||||
|                 batchSize = 100; | ||||
|             } | ||||
|             var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorBackfillStatementsAsync( | ||||
|                 services, | ||||
|                 retrievedSince, | ||||
|                 force, | ||||
|                 batchSize, | ||||
|                 maxDocuments, | ||||
|                 verbose, | ||||
|                 cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var verify = new Command("verify", "Verify Excititor exports or attestations."); | ||||
|         var exportIdOption = new Option<string?>("--export-id") | ||||
|         { | ||||
|             Description = "Export identifier to verify." | ||||
|         }; | ||||
|         var digestOption = new Option<string?>("--digest") | ||||
|         { | ||||
|             Description = "Expected digest for the export or attestation." | ||||
|         }; | ||||
|         var attestationOption = new Option<string?>("--attestation") | ||||
|         { | ||||
|             Description = "Path to a local attestation file to verify (base64 content will be uploaded)." | ||||
|         }; | ||||
|         verify.Add(exportIdOption); | ||||
|         verify.Add(digestOption); | ||||
|         verify.Add(attestationOption); | ||||
|         verify.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var exportId = parseResult.GetValue(exportIdOption); | ||||
|             var digest = parseResult.GetValue(digestOption); | ||||
|             var attestation = parseResult.GetValue(attestationOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories."); | ||||
|         var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" }) | ||||
|         { | ||||
|             Description = "Optional provider identifier(s) to reconcile.", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|         var maxAgeOption = new Option<TimeSpan?>("--max-age") | ||||
|         { | ||||
|             Description = "Optional maximum age window (e.g. 7.00:00:00)." | ||||
|         }; | ||||
|         reconcile.Add(reconcileProviders); | ||||
|         reconcile.Add(maxAgeOption); | ||||
|         reconcile.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>(); | ||||
|             var maxAge = parseResult.GetValue(maxAgeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         excititor.Add(init); | ||||
|         excititor.Add(pull); | ||||
|         excititor.Add(resume); | ||||
|         excititor.Add(list); | ||||
|         excititor.Add(export); | ||||
|         excititor.Add(backfill); | ||||
|         excititor.Add(verify); | ||||
|         excititor.Add(reconcile); | ||||
|         return excititor; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var runtime = new Command("runtime", "Interact with runtime admission policy APIs."); | ||||
|         var policy = new Command("policy", "Runtime policy operations."); | ||||
|  | ||||
|         var test = new Command("test", "Evaluate runtime policy decisions for image digests."); | ||||
|         var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" }) | ||||
|         { | ||||
|             Description = "Namespace or logical scope for the evaluation." | ||||
|         }; | ||||
|  | ||||
|         var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" }) | ||||
|         { | ||||
|             Description = "Image digests to evaluate (repeatable).", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|  | ||||
|         var fileOption = new Option<string?>("--file", new[] { "-f" }) | ||||
|         { | ||||
|             Description = "Path to a file containing image digests (one per line)." | ||||
|         }; | ||||
|  | ||||
|         var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" }) | ||||
|         { | ||||
|             Description = "Pod labels in key=value format (repeatable).", | ||||
|             Arity = ArgumentArity.ZeroOrMore | ||||
|         }; | ||||
|  | ||||
|         var jsonOption = new Option<bool>("--json") | ||||
|         { | ||||
|             Description = "Emit the raw JSON response." | ||||
|         }; | ||||
|  | ||||
|         test.Add(namespaceOption); | ||||
|         test.Add(imageOption); | ||||
|         test.Add(fileOption); | ||||
|         test.Add(labelOption); | ||||
|         test.Add(jsonOption); | ||||
|  | ||||
|         test.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var nsValue = parseResult.GetValue(namespaceOption); | ||||
|             var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>(); | ||||
|             var file = parseResult.GetValue(fileOption); | ||||
|             var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>(); | ||||
|             var outputJson = parseResult.GetValue(jsonOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|  | ||||
|             return CommandHandlers.HandleRuntimePolicyTestAsync( | ||||
|                 services, | ||||
|                 nsValue, | ||||
|                 images, | ||||
|                 file, | ||||
|                 labels, | ||||
|                 outputJson, | ||||
|                 verbose, | ||||
|                 cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         policy.Add(test); | ||||
|         runtime.Add(policy); | ||||
|         return runtime; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var auth = new Command("auth", "Manage authentication with StellaOps Authority."); | ||||
| @@ -607,97 +322,6 @@ internal static class CommandFactory | ||||
|         return auth; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var offline = new Command("offline", "Offline kit workflows and utilities."); | ||||
|  | ||||
|         var kit = new Command("kit", "Manage offline kit bundles."); | ||||
|  | ||||
|         var pull = new Command("pull", "Download the latest offline kit bundle."); | ||||
|         var bundleIdOption = new Option<string?>("--bundle-id") | ||||
|         { | ||||
|             Description = "Optional bundle identifier. Defaults to the latest available." | ||||
|         }; | ||||
|         var destinationOption = new Option<string?>("--destination") | ||||
|         { | ||||
|             Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)." | ||||
|         }; | ||||
|         var overwriteOption = new Option<bool>("--overwrite") | ||||
|         { | ||||
|             Description = "Overwrite existing files even if checksums match." | ||||
|         }; | ||||
|         var noResumeOption = new Option<bool>("--no-resume") | ||||
|         { | ||||
|             Description = "Disable resuming partial downloads." | ||||
|         }; | ||||
|  | ||||
|         pull.Add(bundleIdOption); | ||||
|         pull.Add(destinationOption); | ||||
|         pull.Add(overwriteOption); | ||||
|         pull.Add(noResumeOption); | ||||
|         pull.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var bundleId = parseResult.GetValue(bundleIdOption); | ||||
|             var destination = parseResult.GetValue(destinationOption); | ||||
|             var overwrite = parseResult.GetValue(overwriteOption); | ||||
|             var resume = !parseResult.GetValue(noResumeOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var import = new Command("import", "Upload an offline kit bundle to the backend."); | ||||
|         var bundleArgument = new Argument<string>("bundle") | ||||
|         { | ||||
|             Description = "Path to the offline kit tarball (.tgz)." | ||||
|         }; | ||||
|         var manifestOption = new Option<string?>("--manifest") | ||||
|         { | ||||
|             Description = "Offline manifest JSON path (defaults to metadata or sibling file)." | ||||
|         }; | ||||
|         var bundleSignatureOption = new Option<string?>("--bundle-signature") | ||||
|         { | ||||
|             Description = "Detached signature for the offline bundle (e.g. .sig)." | ||||
|         }; | ||||
|         var manifestSignatureOption = new Option<string?>("--manifest-signature") | ||||
|         { | ||||
|             Description = "Detached signature for the offline manifest (e.g. .jws)." | ||||
|         }; | ||||
|  | ||||
|         import.Add(bundleArgument); | ||||
|         import.Add(manifestOption); | ||||
|         import.Add(bundleSignatureOption); | ||||
|         import.Add(manifestSignatureOption); | ||||
|         import.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty; | ||||
|             var manifest = parseResult.GetValue(manifestOption); | ||||
|             var bundleSignature = parseResult.GetValue(bundleSignatureOption); | ||||
|             var manifestSignature = parseResult.GetValue(manifestSignatureOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var status = new Command("status", "Display offline kit installation status."); | ||||
|         var jsonOption = new Option<bool>("--json") | ||||
|         { | ||||
|             Description = "Emit status as JSON." | ||||
|         }; | ||||
|         status.Add(jsonOption); | ||||
|         status.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var asJson = parseResult.GetValue(jsonOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         kit.Add(pull); | ||||
|         kit.Add(import); | ||||
|         kit.Add(status); | ||||
|  | ||||
|         offline.Add(kit); | ||||
|         return offline; | ||||
|     } | ||||
|  | ||||
|     private static Command BuildConfigCommand(StellaOpsCliOptions options) | ||||
|     { | ||||
|         var config = new Command("config", "Inspect CLI configuration state."); | ||||
|   | ||||
| @@ -2,6 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| @@ -234,6 +235,93 @@ public static class CliBootstrapper | ||||
|                     "Offline:MirrorUrl"); | ||||
|  | ||||
|                 offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim(); | ||||
|  | ||||
|                 cliOptions.Plugins ??= new StellaOpsCliPluginOptions(); | ||||
|                 var pluginOptions = cliOptions.Plugins; | ||||
|  | ||||
|                 pluginOptions.BaseDirectory = ResolveWithFallback( | ||||
|                     pluginOptions.BaseDirectory, | ||||
|                     configuration, | ||||
|                     "STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY", | ||||
|                     "StellaOps:Plugins:BaseDirectory", | ||||
|                     "Plugins:BaseDirectory"); | ||||
|  | ||||
|                 pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim(); | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory)) | ||||
|                 { | ||||
|                     pluginOptions.BaseDirectory = AppContext.BaseDirectory; | ||||
|                 } | ||||
|  | ||||
|                 pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory); | ||||
|  | ||||
|                 pluginOptions.Directory = ResolveWithFallback( | ||||
|                     pluginOptions.Directory, | ||||
|                     configuration, | ||||
|                     "STELLAOPS_CLI_PLUGIN_DIRECTORY", | ||||
|                     "StellaOps:Plugins:Directory", | ||||
|                     "Plugins:Directory"); | ||||
|  | ||||
|                 pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim(); | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(pluginOptions.Directory)) | ||||
|                 { | ||||
|                     pluginOptions.Directory = Path.Combine("plugins", "cli"); | ||||
|                 } | ||||
|  | ||||
|                 if (!Path.IsPathRooted(pluginOptions.Directory)) | ||||
|                 { | ||||
|                     pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory)); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory); | ||||
|                 } | ||||
|  | ||||
|                 pluginOptions.ManifestSearchPattern = ResolveWithFallback( | ||||
|                     pluginOptions.ManifestSearchPattern, | ||||
|                     configuration, | ||||
|                     "STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN", | ||||
|                     "StellaOps:Plugins:ManifestSearchPattern", | ||||
|                     "Plugins:ManifestSearchPattern"); | ||||
|  | ||||
|                 pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim(); | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)) | ||||
|                 { | ||||
|                     pluginOptions.ManifestSearchPattern = "*.manifest.json"; | ||||
|                 } | ||||
|  | ||||
|                 if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0) | ||||
|                 { | ||||
|                     pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" }; | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     pluginOptions.SearchPatterns = pluginOptions.SearchPatterns | ||||
|                         .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) | ||||
|                         .Select(pattern => pattern.Trim()) | ||||
|                         .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                         .ToList(); | ||||
|  | ||||
|                     if (pluginOptions.SearchPatterns.Count == 0) | ||||
|                     { | ||||
|                         pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll"); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if (pluginOptions.PluginOrder is null) | ||||
|                 { | ||||
|                     pluginOptions.PluginOrder = new List<string>(); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     pluginOptions.PluginOrder = pluginOptions.PluginOrder | ||||
|                         .Where(name => !string.IsNullOrWhiteSpace(name)) | ||||
|                         .Select(name => name.Trim()) | ||||
|                         .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                         .ToList(); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using System.IO; | ||||
|  | ||||
| namespace StellaOps.Cli.Configuration; | ||||
|  | ||||
| @@ -25,6 +26,8 @@ public sealed class StellaOpsCliOptions | ||||
|     public StellaOpsCliAuthorityOptions Authority { get; set; } = new(); | ||||
|  | ||||
|     public StellaOpsCliOfflineOptions Offline { get; set; } = new(); | ||||
|  | ||||
|     public StellaOpsCliPluginOptions Plugins { get; set; } = new(); | ||||
| } | ||||
|  | ||||
| public sealed class StellaOpsCliAuthorityOptions | ||||
| @@ -63,3 +66,16 @@ public sealed class StellaOpsCliOfflineOptions | ||||
|  | ||||
|     public string? MirrorUrl { get; set; } | ||||
| } | ||||
|  | ||||
| public sealed class StellaOpsCliPluginOptions | ||||
| { | ||||
|     public string BaseDirectory { get; set; } = string.Empty; | ||||
|  | ||||
|     public string Directory { get; set; } = "plugins/cli"; | ||||
|  | ||||
|     public IList<string> SearchPatterns { get; set; } = new List<string>(); | ||||
|  | ||||
|     public IList<string> PluginOrder { get; set; } = new List<string>(); | ||||
|  | ||||
|     public string ManifestSearchPattern { get; set; } = "*.manifest.json"; | ||||
| } | ||||
|   | ||||
							
								
								
									
										278
									
								
								src/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								src/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,278 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.CommandLine; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Plugin.Hosting; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins; | ||||
|  | ||||
| internal sealed class CliCommandModuleLoader | ||||
| { | ||||
|     private readonly IServiceProvider _services; | ||||
|     private readonly StellaOpsCliOptions _options; | ||||
|     private readonly ILogger<CliCommandModuleLoader> _logger; | ||||
|     private readonly RestartOnlyCliPluginGuard _guard = new(); | ||||
|  | ||||
|     private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>(); | ||||
|     private bool _loaded; | ||||
|  | ||||
|     public CliCommandModuleLoader( | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         ILogger<CliCommandModuleLoader> logger) | ||||
|     { | ||||
|         _services = services ?? throw new ArgumentNullException(nameof(services)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyList<ICliCommandModule> LoadModules() | ||||
|     { | ||||
|         if (_loaded) | ||||
|         { | ||||
|             return _modules; | ||||
|         } | ||||
|  | ||||
|         var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions(); | ||||
|  | ||||
|         var baseDirectory = ResolveBaseDirectory(pluginOptions); | ||||
|         var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory); | ||||
|         var searchPatterns = ResolveSearchPatterns(pluginOptions); | ||||
|         var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern) | ||||
|             ? "*.manifest.json" | ||||
|             : pluginOptions.ManifestSearchPattern; | ||||
|  | ||||
|         _logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory); | ||||
|  | ||||
|         var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern); | ||||
|         IReadOnlyList<CliPluginManifest> manifests; | ||||
|         try | ||||
|         { | ||||
|             manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory); | ||||
|             manifests = Array.Empty<CliPluginManifest>(); | ||||
|         } | ||||
|  | ||||
|         if (manifests.Count == 0) | ||||
|         { | ||||
|             _logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory); | ||||
|             _loaded = true; | ||||
|             _guard.Seal(); | ||||
|             _modules = Array.Empty<ICliCommandModule>(); | ||||
|             return _modules; | ||||
|         } | ||||
|  | ||||
|         var hostOptions = new PluginHostOptions | ||||
|         { | ||||
|             BaseDirectory = baseDirectory, | ||||
|             PluginsDirectory = pluginsDirectory, | ||||
|             EnsureDirectoryExists = false, | ||||
|             RecursiveSearch = true, | ||||
|             PrimaryPrefix = "StellaOps.Cli" | ||||
|         }; | ||||
|  | ||||
|         foreach (var pattern in searchPatterns) | ||||
|         { | ||||
|             hostOptions.SearchPatterns.Add(pattern); | ||||
|         } | ||||
|  | ||||
|         foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>()) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(ordered)) | ||||
|             { | ||||
|                 hostOptions.PluginOrder.Add(ordered); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var loadResult = PluginHost.LoadPlugins(hostOptions, _logger); | ||||
|  | ||||
|         var assemblies = loadResult.Plugins.ToDictionary( | ||||
|             descriptor => Normalize(descriptor.AssemblyPath), | ||||
|             descriptor => descriptor.Assembly, | ||||
|             StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         var modules = new List<ICliCommandModule>(manifests.Count); | ||||
|  | ||||
|         foreach (var manifest in manifests) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var assemblyPath = ResolveAssemblyPath(manifest); | ||||
|                 _guard.EnsureRegistrationAllowed(assemblyPath); | ||||
|  | ||||
|                 if (!assemblies.TryGetValue(assemblyPath, out var assembly)) | ||||
|                 { | ||||
|                     if (!File.Exists(assemblyPath)) | ||||
|                     { | ||||
|                         throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found."); | ||||
|                     } | ||||
|  | ||||
|                     assembly = Assembly.LoadFrom(assemblyPath); | ||||
|                     assemblies[assemblyPath] = assembly; | ||||
|                 } | ||||
|  | ||||
|                 var module = CreateModule(assembly, manifest); | ||||
|                 if (module is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 modules.Add(module); | ||||
|                 _logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         _modules = modules; | ||||
|         _loaded = true; | ||||
|         _guard.Seal(); | ||||
|         return _modules; | ||||
|     } | ||||
|  | ||||
|     public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (root is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(root)); | ||||
|         } | ||||
|         if (verboseOption is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(verboseOption)); | ||||
|         } | ||||
|  | ||||
|         var modules = LoadModules(); | ||||
|         if (modules.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var module in modules) | ||||
|         { | ||||
|             if (!module.IsAvailable(_services)) | ||||
|             { | ||||
|                 _logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken); | ||||
|                 _logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string ResolveAssemblyPath(CliPluginManifest manifest) | ||||
|     { | ||||
|         if (manifest.EntryPoint is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point."); | ||||
|         } | ||||
|  | ||||
|         var assemblyPath = manifest.EntryPoint.Assembly; | ||||
|         if (string.IsNullOrWhiteSpace(assemblyPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path."); | ||||
|         } | ||||
|  | ||||
|         if (!Path.IsPathRooted(assemblyPath)) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(manifest.SourceDirectory)) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata."); | ||||
|             } | ||||
|  | ||||
|             assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath); | ||||
|         } | ||||
|  | ||||
|         return Normalize(assemblyPath); | ||||
|     } | ||||
|  | ||||
|     private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest) | ||||
|     { | ||||
|         if (manifest.EntryPoint is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true); | ||||
|         if (type is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'."); | ||||
|         } | ||||
|  | ||||
|         var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule; | ||||
|         if (module is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}."); | ||||
|         } | ||||
|  | ||||
|         return module; | ||||
|     } | ||||
|  | ||||
|     private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options) | ||||
|     { | ||||
|         var baseDirectory = options.BaseDirectory; | ||||
|         if (string.IsNullOrWhiteSpace(baseDirectory)) | ||||
|         { | ||||
|             baseDirectory = AppContext.BaseDirectory; | ||||
|         } | ||||
|  | ||||
|         return Path.GetFullPath(baseDirectory); | ||||
|     } | ||||
|  | ||||
|     private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory) | ||||
|     { | ||||
|         var directory = options.Directory; | ||||
|         if (string.IsNullOrWhiteSpace(directory)) | ||||
|         { | ||||
|             directory = Path.Combine("plugins", "cli"); | ||||
|         } | ||||
|  | ||||
|         directory = directory.Trim(); | ||||
|  | ||||
|         if (!Path.IsPathRooted(directory)) | ||||
|         { | ||||
|             directory = Path.Combine(baseDirectory, directory); | ||||
|         } | ||||
|  | ||||
|         return Path.GetFullPath(directory); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options) | ||||
|     { | ||||
|         if (options.SearchPatterns is null || options.SearchPatterns.Count == 0) | ||||
|         { | ||||
|             return new[] { "StellaOps.Cli.Plugin.*.dll" }; | ||||
|         } | ||||
|  | ||||
|         return options.SearchPatterns | ||||
|             .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) | ||||
|             .Select(pattern => pattern.Trim()) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static string Normalize(string path) | ||||
|     { | ||||
|         var full = Path.GetFullPath(path); | ||||
|         return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										39
									
								
								src/StellaOps.Cli/Plugins/CliPluginManifest.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/StellaOps.Cli/Plugins/CliPluginManifest.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins; | ||||
|  | ||||
| public sealed record CliPluginManifest | ||||
| { | ||||
|     public const string CurrentSchemaVersion = "1.0"; | ||||
|  | ||||
|     public string SchemaVersion { get; init; } = CurrentSchemaVersion; | ||||
|  | ||||
|     public string Id { get; init; } = string.Empty; | ||||
|  | ||||
|     public string DisplayName { get; init; } = string.Empty; | ||||
|  | ||||
|     public string Version { get; init; } = "0.0.0"; | ||||
|  | ||||
|     public bool RequiresRestart { get; init; } = true; | ||||
|  | ||||
|     public CliPluginEntryPoint? EntryPoint { get; init; } | ||||
|  | ||||
|     public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string> Metadata { get; init; } = | ||||
|         new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     public string? SourcePath { get; init; } | ||||
|  | ||||
|     public string? SourceDirectory { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record CliPluginEntryPoint | ||||
| { | ||||
|     public string Type { get; init; } = "dotnet"; | ||||
|  | ||||
|     public string Assembly { get; init; } = string.Empty; | ||||
|  | ||||
|     public string TypeName { get; init; } = string.Empty; | ||||
| } | ||||
							
								
								
									
										150
									
								
								src/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins; | ||||
|  | ||||
| internal sealed class CliPluginManifestLoader | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         AllowTrailingCommas = true, | ||||
|         ReadCommentHandling = JsonCommentHandling.Skip, | ||||
|         PropertyNameCaseInsensitive = true | ||||
|     }; | ||||
|  | ||||
|     private readonly string _directory; | ||||
|     private readonly string _searchPattern; | ||||
|  | ||||
|     public CliPluginManifestLoader(string directory, string searchPattern) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(directory)) | ||||
|         { | ||||
|             throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(searchPattern)) | ||||
|         { | ||||
|             throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern)); | ||||
|         } | ||||
|  | ||||
|         _directory = Path.GetFullPath(directory); | ||||
|         _searchPattern = searchPattern; | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!Directory.Exists(_directory)) | ||||
|         { | ||||
|             return Array.Empty<CliPluginManifest>(); | ||||
|         } | ||||
|  | ||||
|         var manifests = new List<CliPluginManifest>(); | ||||
|  | ||||
|         foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories)) | ||||
|         { | ||||
|             if (IsHidden(file)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false); | ||||
|             manifests.Add(manifest); | ||||
|         } | ||||
|  | ||||
|         return manifests | ||||
|             .OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase) | ||||
|             .ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static bool IsHidden(string path) | ||||
|     { | ||||
|         var directory = Path.GetDirectoryName(path); | ||||
|         while (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             var name = Path.GetFileName(directory); | ||||
|             if (name.StartsWith(".", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             directory = Path.GetDirectoryName(directory); | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous); | ||||
|         CliPluginManifest? manifest; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken) | ||||
|                 .ConfigureAwait(false); | ||||
|         } | ||||
|         catch (JsonException ex) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex); | ||||
|         } | ||||
|  | ||||
|         if (manifest is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid."); | ||||
|         } | ||||
|  | ||||
|         ValidateManifest(manifest, file); | ||||
|  | ||||
|         var directory = Path.GetDirectoryName(file); | ||||
|         return manifest with | ||||
|         { | ||||
|             SourcePath = file, | ||||
|             SourceDirectory = directory | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static void ValidateManifest(CliPluginManifest manifest, string file) | ||||
|     { | ||||
|         if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException( | ||||
|                 $"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(manifest.Id)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'."); | ||||
|         } | ||||
|  | ||||
|         if (manifest.EntryPoint is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'."); | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type."); | ||||
|         } | ||||
|  | ||||
|         if (!manifest.RequiresRestart) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								src/StellaOps.Cli/Plugins/ICliCommandModule.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/StellaOps.Cli/Plugins/ICliCommandModule.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| using System; | ||||
| using System.CommandLine; | ||||
| using System.Threading; | ||||
| using StellaOps.Cli.Configuration; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins; | ||||
|  | ||||
| public interface ICliCommandModule | ||||
| { | ||||
|     string Name { get; } | ||||
|  | ||||
|     bool IsAvailable(IServiceProvider services); | ||||
|  | ||||
|     void RegisterCommands( | ||||
|         RootCommand root, | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         Option<bool> verboseOption, | ||||
|         CancellationToken cancellationToken); | ||||
| } | ||||
							
								
								
									
										41
									
								
								src/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
|  | ||||
| namespace StellaOps.Cli.Plugins; | ||||
|  | ||||
| internal sealed class RestartOnlyCliPluginGuard | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private bool _sealed; | ||||
|  | ||||
|     public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray(); | ||||
|  | ||||
|     public bool IsSealed => Volatile.Read(ref _sealed); | ||||
|  | ||||
|     public void EnsureRegistrationAllowed(string pluginPath) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath); | ||||
|  | ||||
|         var normalized = Normalize(pluginPath); | ||||
|         if (IsSealed && !_plugins.ContainsKey(normalized)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required."); | ||||
|         } | ||||
|  | ||||
|         _plugins.TryAdd(normalized, 0); | ||||
|     } | ||||
|  | ||||
|     public void Seal() | ||||
|     { | ||||
|         Volatile.Write(ref _sealed, true); | ||||
|     } | ||||
|  | ||||
|     private static string Normalize(string path) | ||||
|     { | ||||
|         var full = Path.GetFullPath(path); | ||||
|         return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); | ||||
|     } | ||||
| } | ||||
| @@ -116,7 +116,7 @@ internal static class Program | ||||
|             cts.Cancel(); | ||||
|         }; | ||||
|  | ||||
|         var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token); | ||||
|         var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory); | ||||
|         var commandConfiguration = new CommandLineConfiguration(rootCommand); | ||||
|         var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false); | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using System.Runtime.CompilerServices; | ||||
|  | ||||
| [assembly: InternalsVisibleTo("StellaOps.Cli.Tests")] | ||||
| [assembly: InternalsVisibleTo("StellaOps.Cli.Tests")] | ||||
| [assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")] | ||||
|   | ||||
| @@ -13,6 +13,7 @@ | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" /> | ||||
|     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> | ||||
| @@ -39,6 +40,7 @@ | ||||
|     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -19,6 +19,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| |EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|**DOING (2025-10-19)** – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.| | ||||
| |CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** – Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).| | ||||
| |CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|**DONE (2025-10-21)** – Added `offline kit pull/import/status` commands with resumable downloads, digest/metadata validation, metrics, docs updates, and regression coverage (`dotnet test src/StellaOps.Cli.Tests`).| | ||||
| |CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).| | ||||
| |CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|DONE (2025-10-22) – Packaged non-core verbs as restart-time plug-ins with manifest + loader updates and tests ensuring no hot reload.| | ||||
| |CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** – CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.| | ||||
| |CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** – Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.| | ||||
|   | ||||
| @@ -0,0 +1,127 @@ | ||||
| { | ||||
|   "advisoryKey": "GHSA-aaaa-bbbb-cccc", | ||||
|   "affectedPackages": [ | ||||
|     { | ||||
|       "type": "semver", | ||||
|       "identifier": "pkg:npm/example-widget", | ||||
|       "platform": null, | ||||
|       "versionRanges": [ | ||||
|         { | ||||
|           "fixedVersion": "2.5.1", | ||||
|           "introducedVersion": null, | ||||
|           "lastAffectedVersion": null, | ||||
|           "primitives": null, | ||||
|           "provenance": { | ||||
|             "source": "ghsa", | ||||
|             "kind": "map", | ||||
|             "value": "ghsa-aaaa-bbbb-cccc", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "rangeExpression": ">=0.0.0 <2.5.1", | ||||
|           "rangeKind": "semver" | ||||
|         }, | ||||
|         { | ||||
|           "fixedVersion": "3.2.4", | ||||
|           "introducedVersion": "3.0.0", | ||||
|           "lastAffectedVersion": null, | ||||
|           "primitives": null, | ||||
|           "provenance": { | ||||
|             "source": "ghsa", | ||||
|             "kind": "map", | ||||
|             "value": "ghsa-aaaa-bbbb-cccc", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "rangeExpression": null, | ||||
|           "rangeKind": "semver" | ||||
|         } | ||||
|       ], | ||||
|       "normalizedVersions": [], | ||||
|       "statuses": [], | ||||
|       "provenance": [ | ||||
|         { | ||||
|           "source": "ghsa", | ||||
|           "kind": "map", | ||||
|           "value": "ghsa-aaaa-bbbb-cccc", | ||||
|           "decisionReason": null, | ||||
|           "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|           "fieldMask": [] | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "aliases": [ | ||||
|     "CVE-2024-2222", | ||||
|     "GHSA-aaaa-bbbb-cccc" | ||||
|   ], | ||||
|   "canonicalMetricId": null, | ||||
|   "credits": [], | ||||
|   "cvssMetrics": [ | ||||
|     { | ||||
|       "baseScore": 8.8, | ||||
|       "baseSeverity": "high", | ||||
|       "provenance": { | ||||
|         "source": "ghsa", | ||||
|         "kind": "map", | ||||
|         "value": "ghsa-aaaa-bbbb-cccc", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H", | ||||
|       "version": "3.1" | ||||
|     } | ||||
|   ], | ||||
|   "cwes": [], | ||||
|   "description": null, | ||||
|   "exploitKnown": false, | ||||
|   "language": "en", | ||||
|   "modified": "2024-03-04T12:00:00+00:00", | ||||
|   "provenance": [ | ||||
|     { | ||||
|       "source": "ghsa", | ||||
|       "kind": "map", | ||||
|       "value": "ghsa-aaaa-bbbb-cccc", | ||||
|       "decisionReason": null, | ||||
|       "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|       "fieldMask": [] | ||||
|     } | ||||
|   ], | ||||
|   "published": "2024-03-04T00:00:00+00:00", | ||||
|   "references": [ | ||||
|     { | ||||
|       "kind": "patch", | ||||
|       "provenance": { | ||||
|         "source": "ghsa", | ||||
|         "kind": "map", | ||||
|         "value": "ghsa-aaaa-bbbb-cccc", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "ghsa", | ||||
|       "summary": "Patch commit", | ||||
|       "url": "https://github.com/example/widget/commit/abcd1234" | ||||
|     }, | ||||
|     { | ||||
|       "kind": "advisory", | ||||
|       "provenance": { | ||||
|         "source": "ghsa", | ||||
|         "kind": "map", | ||||
|         "value": "ghsa-aaaa-bbbb-cccc", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-03-05T10:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "ghsa", | ||||
|       "summary": "GitHub Security Advisory", | ||||
|       "url": "https://github.com/example/widget/security/advisories/GHSA-aaaa-bbbb-cccc" | ||||
|     } | ||||
|   ], | ||||
|   "severity": "high", | ||||
|   "summary": "A crafted payload can pollute Object.prototype leading to RCE.", | ||||
|   "title": "Prototype pollution in widget.js" | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| { | ||||
|   "advisoryKey": "CVE-2023-9999", | ||||
|   "affectedPackages": [], | ||||
|   "aliases": [ | ||||
|     "CVE-2023-9999" | ||||
|   ], | ||||
|   "canonicalMetricId": null, | ||||
|   "credits": [], | ||||
|   "cvssMetrics": [], | ||||
|   "cwes": [], | ||||
|   "description": null, | ||||
|   "exploitKnown": true, | ||||
|   "language": "en", | ||||
|   "modified": "2024-02-09T16:22:00+00:00", | ||||
|   "provenance": [ | ||||
|     { | ||||
|       "source": "cisa-kev", | ||||
|       "kind": "annotate", | ||||
|       "value": "kev", | ||||
|       "decisionReason": null, | ||||
|       "recordedAt": "2024-02-10T09:30:00+00:00", | ||||
|       "fieldMask": [] | ||||
|     } | ||||
|   ], | ||||
|   "published": "2023-11-20T00:00:00+00:00", | ||||
|   "references": [ | ||||
|     { | ||||
|       "kind": "kev", | ||||
|       "provenance": { | ||||
|         "source": "cisa-kev", | ||||
|         "kind": "annotate", | ||||
|         "value": "kev", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-02-10T09:30:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "cisa", | ||||
|       "summary": "CISA KEV entry", | ||||
|       "url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog" | ||||
|     } | ||||
|   ], | ||||
|   "severity": "critical", | ||||
|   "summary": "Unauthenticated RCE due to unsafe deserialization.", | ||||
|   "title": "Remote code execution in LegacyServer" | ||||
| } | ||||
| @@ -0,0 +1,122 @@ | ||||
| { | ||||
|   "advisoryKey": "CVE-2024-1234", | ||||
|   "affectedPackages": [ | ||||
|     { | ||||
|       "type": "cpe", | ||||
|       "identifier": "cpe:/a:examplecms:examplecms:1.0", | ||||
|       "platform": null, | ||||
|       "versionRanges": [ | ||||
|         { | ||||
|           "fixedVersion": "1.0.5", | ||||
|           "introducedVersion": "1.0", | ||||
|           "lastAffectedVersion": null, | ||||
|           "primitives": null, | ||||
|           "provenance": { | ||||
|             "source": "nvd", | ||||
|             "kind": "map", | ||||
|             "value": "cve-2024-1234", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "rangeExpression": null, | ||||
|           "rangeKind": "version" | ||||
|         } | ||||
|       ], | ||||
|       "normalizedVersions": [], | ||||
|       "statuses": [ | ||||
|         { | ||||
|           "provenance": { | ||||
|             "source": "nvd", | ||||
|             "kind": "map", | ||||
|             "value": "cve-2024-1234", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "status": "affected" | ||||
|         } | ||||
|       ], | ||||
|       "provenance": [ | ||||
|         { | ||||
|           "source": "nvd", | ||||
|           "kind": "map", | ||||
|           "value": "cve-2024-1234", | ||||
|           "decisionReason": null, | ||||
|           "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|           "fieldMask": [] | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "aliases": [ | ||||
|     "CVE-2024-1234" | ||||
|   ], | ||||
|   "canonicalMetricId": null, | ||||
|   "credits": [], | ||||
|   "cvssMetrics": [ | ||||
|     { | ||||
|       "baseScore": 9.8, | ||||
|       "baseSeverity": "critical", | ||||
|       "provenance": { | ||||
|         "source": "nvd", | ||||
|         "kind": "map", | ||||
|         "value": "cve-2024-1234", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", | ||||
|       "version": "3.1" | ||||
|     } | ||||
|   ], | ||||
|   "cwes": [], | ||||
|   "description": null, | ||||
|   "exploitKnown": false, | ||||
|   "language": "en", | ||||
|   "modified": "2024-07-16T10:35:00+00:00", | ||||
|   "provenance": [ | ||||
|     { | ||||
|       "source": "nvd", | ||||
|       "kind": "map", | ||||
|       "value": "cve-2024-1234", | ||||
|       "decisionReason": null, | ||||
|       "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|       "fieldMask": [] | ||||
|     } | ||||
|   ], | ||||
|   "published": "2024-07-15T00:00:00+00:00", | ||||
|   "references": [ | ||||
|     { | ||||
|       "kind": "advisory", | ||||
|       "provenance": { | ||||
|         "source": "example", | ||||
|         "kind": "fetch", | ||||
|         "value": "bulletin", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-07-14T15:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "vendor", | ||||
|       "summary": "Vendor bulletin", | ||||
|       "url": "https://example.org/security/CVE-2024-1234" | ||||
|     }, | ||||
|     { | ||||
|       "kind": "advisory", | ||||
|       "provenance": { | ||||
|         "source": "nvd", | ||||
|         "kind": "map", | ||||
|         "value": "cve-2024-1234", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-08-01T12:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "nvd", | ||||
|       "summary": "NVD entry", | ||||
|       "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-1234" | ||||
|     } | ||||
|   ], | ||||
|   "severity": "high", | ||||
|   "summary": "An integer overflow in ExampleCMS allows remote attackers to escalate privileges.", | ||||
|   "title": "Integer overflow in ExampleCMS" | ||||
| } | ||||
| @@ -0,0 +1,125 @@ | ||||
| { | ||||
|   "advisoryKey": "RHSA-2024:0252", | ||||
|   "affectedPackages": [ | ||||
|     { | ||||
|       "type": "rpm", | ||||
|       "identifier": "kernel-0:4.18.0-553.el8.x86_64", | ||||
|       "platform": "rhel-8", | ||||
|       "versionRanges": [ | ||||
|         { | ||||
|           "fixedVersion": null, | ||||
|           "introducedVersion": "0:4.18.0-553.el8", | ||||
|           "lastAffectedVersion": null, | ||||
|           "primitives": null, | ||||
|           "provenance": { | ||||
|             "source": "redhat", | ||||
|             "kind": "map", | ||||
|             "value": "rhsa-2024:0252", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "rangeExpression": null, | ||||
|           "rangeKind": "nevra" | ||||
|         } | ||||
|       ], | ||||
|       "normalizedVersions": [], | ||||
|       "statuses": [ | ||||
|         { | ||||
|           "provenance": { | ||||
|             "source": "redhat", | ||||
|             "kind": "map", | ||||
|             "value": "rhsa-2024:0252", | ||||
|             "decisionReason": null, | ||||
|             "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|             "fieldMask": [] | ||||
|           }, | ||||
|           "status": "fixed" | ||||
|         } | ||||
|       ], | ||||
|       "provenance": [ | ||||
|         { | ||||
|           "source": "redhat", | ||||
|           "kind": "enrich", | ||||
|           "value": "cve-2024-5678", | ||||
|           "decisionReason": null, | ||||
|           "recordedAt": "2024-05-11T09:05:00+00:00", | ||||
|           "fieldMask": [] | ||||
|         }, | ||||
|         { | ||||
|           "source": "redhat", | ||||
|           "kind": "map", | ||||
|           "value": "rhsa-2024:0252", | ||||
|           "decisionReason": null, | ||||
|           "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|           "fieldMask": [] | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "aliases": [ | ||||
|     "CVE-2024-5678", | ||||
|     "RHSA-2024:0252" | ||||
|   ], | ||||
|   "canonicalMetricId": null, | ||||
|   "credits": [], | ||||
|   "cvssMetrics": [ | ||||
|     { | ||||
|       "baseScore": 6.7, | ||||
|       "baseSeverity": "medium", | ||||
|       "provenance": { | ||||
|         "source": "redhat", | ||||
|         "kind": "map", | ||||
|         "value": "rhsa-2024:0252", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "vector": "CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H", | ||||
|       "version": "3.1" | ||||
|     } | ||||
|   ], | ||||
|   "cwes": [], | ||||
|   "description": null, | ||||
|   "exploitKnown": false, | ||||
|   "language": "en", | ||||
|   "modified": "2024-05-11T08:15:00+00:00", | ||||
|   "provenance": [ | ||||
|     { | ||||
|       "source": "redhat", | ||||
|       "kind": "enrich", | ||||
|       "value": "cve-2024-5678", | ||||
|       "decisionReason": null, | ||||
|       "recordedAt": "2024-05-11T09:05:00+00:00", | ||||
|       "fieldMask": [] | ||||
|     }, | ||||
|     { | ||||
|       "source": "redhat", | ||||
|       "kind": "map", | ||||
|       "value": "rhsa-2024:0252", | ||||
|       "decisionReason": null, | ||||
|       "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|       "fieldMask": [] | ||||
|     } | ||||
|   ], | ||||
|   "published": "2024-05-10T19:28:00+00:00", | ||||
|   "references": [ | ||||
|     { | ||||
|       "kind": "advisory", | ||||
|       "provenance": { | ||||
|         "source": "redhat", | ||||
|         "kind": "map", | ||||
|         "value": "rhsa-2024:0252", | ||||
|         "decisionReason": null, | ||||
|         "recordedAt": "2024-05-11T09:00:00+00:00", | ||||
|         "fieldMask": [] | ||||
|       }, | ||||
|       "sourceTag": "redhat", | ||||
|       "summary": "Red Hat security advisory", | ||||
|       "url": "https://access.redhat.com/errata/RHSA-2024:0252" | ||||
|     } | ||||
|   ], | ||||
|   "severity": "critical", | ||||
|   "summary": "Updates the Red Hat Enterprise Linux kernel to address CVE-2024-5678.", | ||||
|   "title": "Important: kernel security update" | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
|  | ||||
| public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return new DotNetLanguageAnalyzer(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
|  | ||||
| public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer | ||||
| { | ||||
|     public string Id => "dotnet"; | ||||
|  | ||||
|     public string DisplayName => ".NET Analyzer (preview)"; | ||||
|  | ||||
|     public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         ArgumentNullException.ThrowIfNull(writer); | ||||
|  | ||||
|         var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false); | ||||
|         if (packages.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var package in packages) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             writer.AddFromPurl( | ||||
|                 analyzerId: Id, | ||||
|                 purl: package.Purl, | ||||
|                 name: package.Name, | ||||
|                 version: package.Version, | ||||
|                 type: "nuget", | ||||
|                 metadata: package.Metadata, | ||||
|                 evidence: package.Evidence, | ||||
|                 usedByEntrypoint: package.UsedByEntrypoint); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,416 @@ | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||
|  | ||||
| internal static class DotNetDependencyCollector | ||||
| { | ||||
|     private static readonly EnumerationOptions Enumeration = new() | ||||
|     { | ||||
|         RecurseSubdirectories = true, | ||||
|         IgnoreInaccessible = true, | ||||
|         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint | ||||
|     }; | ||||
|  | ||||
|     public static ValueTask<IReadOnlyList<DotNetPackage>> CollectAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         var depsFiles = Directory | ||||
|             .EnumerateFiles(context.RootPath, "*.deps.json", Enumeration) | ||||
|             .OrderBy(static path => path, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         if (depsFiles.Length == 0) | ||||
|         { | ||||
|             return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(Array.Empty<DotNetPackage>()); | ||||
|         } | ||||
|  | ||||
|         var aggregator = new DotNetPackageAggregator(); | ||||
|  | ||||
|         foreach (var depsPath in depsFiles) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var relativeDepsPath = NormalizeRelative(context.GetRelativePath(depsPath)); | ||||
|                 var depsFile = DotNetDepsFile.Load(depsPath, relativeDepsPath, cancellationToken); | ||||
|                 if (depsFile is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 DotNetRuntimeConfig? runtimeConfig = null; | ||||
|                 var runtimeConfigPath = Path.ChangeExtension(depsPath, ".runtimeconfig.json"); | ||||
|                 if (!string.IsNullOrEmpty(runtimeConfigPath) && File.Exists(runtimeConfigPath)) | ||||
|                 { | ||||
|                     var relativeRuntimePath = NormalizeRelative(context.GetRelativePath(runtimeConfigPath)); | ||||
|                     runtimeConfig = DotNetRuntimeConfig.Load(runtimeConfigPath, relativeRuntimePath, cancellationToken); | ||||
|                 } | ||||
|  | ||||
|                 aggregator.Add(depsFile, runtimeConfig); | ||||
|             } | ||||
|             catch (IOException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|             catch (JsonException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|             catch (UnauthorizedAccessException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var packages = aggregator.Build(); | ||||
|         return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(packages); | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeRelative(string path) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path) || path == ".") | ||||
|         { | ||||
|             return "."; | ||||
|         } | ||||
|  | ||||
|         var normalized = path.Replace('\\', '/'); | ||||
|         return string.IsNullOrWhiteSpace(normalized) ? "." : normalized; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class DotNetPackageAggregator | ||||
| { | ||||
|     private readonly Dictionary<string, DotNetPackageBuilder> _packages = new(StringComparer.Ordinal); | ||||
|  | ||||
|     public void Add(DotNetDepsFile depsFile, DotNetRuntimeConfig? runtimeConfig) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(depsFile); | ||||
|  | ||||
|         foreach (var library in depsFile.Libraries.Values) | ||||
|         { | ||||
|             if (!library.IsPackage) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var normalizedId = DotNetPackageBuilder.NormalizeId(library.Id); | ||||
|             var key = DotNetPackageBuilder.BuildKey(normalizedId, library.Version); | ||||
|  | ||||
|             if (!_packages.TryGetValue(key, out var builder)) | ||||
|             { | ||||
|                 builder = new DotNetPackageBuilder(library.Id, normalizedId, library.Version); | ||||
|                 _packages[key] = builder; | ||||
|             } | ||||
|  | ||||
|             builder.AddLibrary(library, depsFile.RelativePath, runtimeConfig); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyList<DotNetPackage> Build() | ||||
|     { | ||||
|         if (_packages.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<DotNetPackage>(); | ||||
|         } | ||||
|  | ||||
|         var items = new List<DotNetPackage>(_packages.Count); | ||||
|         foreach (var builder in _packages.Values) | ||||
|         { | ||||
|             items.Add(builder.Build()); | ||||
|         } | ||||
|  | ||||
|         items.Sort(static (left, right) => string.CompareOrdinal(left.ComponentKey, right.ComponentKey)); | ||||
|         return items; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class DotNetPackageBuilder | ||||
| { | ||||
|     private readonly string _originalId; | ||||
|     private readonly string _normalizedId; | ||||
|     private readonly string _version; | ||||
|  | ||||
|     private bool? _serviceable; | ||||
|  | ||||
|     private readonly SortedSet<string> _sha512 = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _hashPaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _depsPaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _targetFrameworks = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly SortedSet<string> _runtimeConfigPaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _runtimeConfigTfms = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly SortedSet<string> _runtimeConfigFrameworks = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly SortedSet<string> _runtimeConfigGraph = new(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||
|  | ||||
|     public DotNetPackageBuilder(string originalId, string normalizedId, string version) | ||||
|     { | ||||
|         _originalId = string.IsNullOrWhiteSpace(originalId) ? normalizedId : originalId.Trim(); | ||||
|         _normalizedId = normalizedId; | ||||
|         _version = version ?? string.Empty; | ||||
|     } | ||||
|  | ||||
|     public static string BuildKey(string normalizedId, string version) | ||||
|         => $"{normalizedId}::{version}"; | ||||
|  | ||||
|     public static string NormalizeId(string id) | ||||
|         => string.IsNullOrWhiteSpace(id) ? string.Empty : id.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     public void AddLibrary(DotNetLibrary library, string relativeDepsPath, DotNetRuntimeConfig? runtimeConfig) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(library); | ||||
|  | ||||
|         if (library.Serviceable is bool serviceable) | ||||
|         { | ||||
|             _serviceable = _serviceable.HasValue | ||||
|                 ? _serviceable.Value || serviceable | ||||
|                 : serviceable; | ||||
|         } | ||||
|  | ||||
|         AddIfPresent(_sha512, library.Sha512); | ||||
|         AddIfPresent(_packagePaths, library.PackagePath); | ||||
|         AddIfPresent(_hashPaths, library.HashPath); | ||||
|         AddIfPresent(_depsPaths, NormalizeRelativePath(relativeDepsPath)); | ||||
|  | ||||
|         foreach (var dependency in library.Dependencies) | ||||
|         { | ||||
|             AddIfPresent(_dependencies, dependency, normalizeLower: true); | ||||
|         } | ||||
|  | ||||
|         foreach (var tfm in library.TargetFrameworks) | ||||
|         { | ||||
|             AddIfPresent(_targetFrameworks, tfm); | ||||
|         } | ||||
|  | ||||
|         foreach (var rid in library.RuntimeIdentifiers) | ||||
|         { | ||||
|             AddIfPresent(_runtimeIdentifiers, rid); | ||||
|         } | ||||
|  | ||||
|         _evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             "deps.json", | ||||
|             NormalizeRelativePath(relativeDepsPath), | ||||
|             library.Key, | ||||
|             Sha256: null)); | ||||
|  | ||||
|         if (runtimeConfig is not null) | ||||
|         { | ||||
|             AddIfPresent(_runtimeConfigPaths, runtimeConfig.RelativePath); | ||||
|  | ||||
|             foreach (var tfm in runtimeConfig.Tfms) | ||||
|             { | ||||
|                 AddIfPresent(_runtimeConfigTfms, tfm); | ||||
|             } | ||||
|  | ||||
|             foreach (var framework in runtimeConfig.Frameworks) | ||||
|             { | ||||
|                 AddIfPresent(_runtimeConfigFrameworks, framework); | ||||
|             } | ||||
|  | ||||
|             foreach (var entry in runtimeConfig.RuntimeGraph) | ||||
|             { | ||||
|                 var value = BuildRuntimeGraphValue(entry.Rid, entry.Fallbacks); | ||||
|                 AddIfPresent(_runtimeConfigGraph, value); | ||||
|             } | ||||
|  | ||||
|             _evidence.Add(new LanguageComponentEvidence( | ||||
|                 LanguageEvidenceKind.File, | ||||
|                 "runtimeconfig.json", | ||||
|                 runtimeConfig.RelativePath, | ||||
|                 Value: null, | ||||
|                 Sha256: null)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public DotNetPackage Build() | ||||
|     { | ||||
|         var metadata = new List<KeyValuePair<string, string?>>(32) | ||||
|         { | ||||
|             new("package.id", _originalId), | ||||
|             new("package.id.normalized", _normalizedId), | ||||
|             new("package.version", _version) | ||||
|         }; | ||||
|  | ||||
|         if (_serviceable.HasValue) | ||||
|         { | ||||
|             metadata.Add(new KeyValuePair<string, string?>("package.serviceable", _serviceable.Value ? "true" : "false")); | ||||
|         } | ||||
|  | ||||
|         AddIndexed(metadata, "package.sha512", _sha512); | ||||
|         AddIndexed(metadata, "package.path", _packagePaths); | ||||
|         AddIndexed(metadata, "package.hashPath", _hashPaths); | ||||
|         AddIndexed(metadata, "deps.path", _depsPaths); | ||||
|         AddIndexed(metadata, "deps.dependency", _dependencies); | ||||
|         AddIndexed(metadata, "deps.tfm", _targetFrameworks); | ||||
|         AddIndexed(metadata, "deps.rid", _runtimeIdentifiers); | ||||
|         AddIndexed(metadata, "runtimeconfig.path", _runtimeConfigPaths); | ||||
|         AddIndexed(metadata, "runtimeconfig.tfm", _runtimeConfigTfms); | ||||
|         AddIndexed(metadata, "runtimeconfig.framework", _runtimeConfigFrameworks); | ||||
|         AddIndexed(metadata, "runtimeconfig.graph", _runtimeConfigGraph); | ||||
|  | ||||
|         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|  | ||||
|         var evidence = _evidence | ||||
|             .OrderBy(static item => item.Source, StringComparer.Ordinal) | ||||
|             .ThenBy(static item => item.Locator, StringComparer.Ordinal) | ||||
|             .ThenBy(static item => item.Value, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return new DotNetPackage( | ||||
|             name: _originalId, | ||||
|             normalizedId: _normalizedId, | ||||
|             version: _version, | ||||
|             metadata: metadata, | ||||
|             evidence: evidence, | ||||
|             usedByEntrypoint: false); | ||||
|     } | ||||
|  | ||||
|     private static void AddIfPresent(ISet<string> set, string? value, bool normalizeLower = false) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var normalized = value.Trim(); | ||||
|         if (normalizeLower) | ||||
|         { | ||||
|             normalized = normalized.ToLowerInvariant(); | ||||
|         } | ||||
|  | ||||
|         set.Add(normalized); | ||||
|     } | ||||
|  | ||||
|     private static void AddIndexed(ICollection<KeyValuePair<string, string?>> metadata, string prefix, IEnumerable<string> values) | ||||
|     { | ||||
|         if (metadata is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(metadata)); | ||||
|         } | ||||
|  | ||||
|         if (values is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var index = 0; | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             metadata.Add(new KeyValuePair<string, string?>($"{prefix}[{index++}]", value)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeRelativePath(string path) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path) || path == ".") | ||||
|         { | ||||
|             return "."; | ||||
|         } | ||||
|  | ||||
|         return path.Replace('\\', '/'); | ||||
|     } | ||||
|  | ||||
|     private static string BuildRuntimeGraphValue(string rid, IReadOnlyList<string> fallbacks) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(rid)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         if (fallbacks.Count == 0) | ||||
|         { | ||||
|             return rid.Trim(); | ||||
|         } | ||||
|  | ||||
|         var ordered = fallbacks | ||||
|             .Where(static fallback => !string.IsNullOrWhiteSpace(fallback)) | ||||
|             .Select(static fallback => fallback.Trim()) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static fallback => fallback, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return ordered.Length == 0 | ||||
|             ? rid.Trim() | ||||
|             : $"{rid.Trim()}=>{string.Join(';', ordered)}"; | ||||
|     } | ||||
|  | ||||
|     private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence> | ||||
|     { | ||||
|         public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y) | ||||
|         { | ||||
|             if (ReferenceEquals(x, y)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (x is null || y is null) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             return x.Kind == y.Kind && | ||||
|                    string.Equals(x.Source, y.Source, StringComparison.Ordinal) && | ||||
|                    string.Equals(x.Locator, y.Locator, StringComparison.Ordinal) && | ||||
|                    string.Equals(x.Value, y.Value, StringComparison.Ordinal) && | ||||
|                    string.Equals(x.Sha256, y.Sha256, StringComparison.Ordinal); | ||||
|         } | ||||
|  | ||||
|         public int GetHashCode(LanguageComponentEvidence obj) | ||||
|         { | ||||
|             var hash = new HashCode(); | ||||
|             hash.Add(obj.Kind); | ||||
|             hash.Add(obj.Source, StringComparer.Ordinal); | ||||
|             hash.Add(obj.Locator, StringComparer.Ordinal); | ||||
|             hash.Add(obj.Value, StringComparer.Ordinal); | ||||
|             hash.Add(obj.Sha256, StringComparer.Ordinal); | ||||
|             return hash.ToHashCode(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class DotNetPackage | ||||
| { | ||||
|     public DotNetPackage( | ||||
|         string name, | ||||
|         string normalizedId, | ||||
|         string version, | ||||
|         IReadOnlyList<KeyValuePair<string, string?>> metadata, | ||||
|         IReadOnlyCollection<LanguageComponentEvidence> evidence, | ||||
|         bool usedByEntrypoint) | ||||
|     { | ||||
|         Name = string.IsNullOrWhiteSpace(name) ? normalizedId : name.Trim(); | ||||
|         NormalizedId = normalizedId; | ||||
|         Version = version ?? string.Empty; | ||||
|         Metadata = metadata ?? Array.Empty<KeyValuePair<string, string?>>(); | ||||
|         Evidence = evidence ?? Array.Empty<LanguageComponentEvidence>(); | ||||
|         UsedByEntrypoint = usedByEntrypoint; | ||||
|     } | ||||
|  | ||||
|     public string Name { get; } | ||||
|  | ||||
|     public string NormalizedId { get; } | ||||
|  | ||||
|     public string Version { get; } | ||||
|  | ||||
|     public IReadOnlyList<KeyValuePair<string, string?>> Metadata { get; } | ||||
|  | ||||
|     public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; } | ||||
|  | ||||
|     public bool UsedByEntrypoint { get; } | ||||
|  | ||||
|     public string Purl => $"pkg:nuget/{NormalizedId}@{Version}"; | ||||
|  | ||||
|     public string ComponentKey => $"purl::{Purl}"; | ||||
| } | ||||
| @@ -0,0 +1,318 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||
|  | ||||
| internal sealed class DotNetDepsFile | ||||
| { | ||||
|     private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries) | ||||
|     { | ||||
|         RelativePath = relativePath; | ||||
|         Libraries = libraries; | ||||
|     } | ||||
|  | ||||
|     public string RelativePath { get; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; } | ||||
|  | ||||
|     public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken) | ||||
|     { | ||||
|         using var stream = File.OpenRead(absolutePath); | ||||
|         using var document = JsonDocument.Parse(stream, new JsonDocumentOptions | ||||
|         { | ||||
|             AllowTrailingCommas = true, | ||||
|             CommentHandling = JsonCommentHandling.Skip | ||||
|         }); | ||||
|  | ||||
|         var root = document.RootElement; | ||||
|         if (root.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var libraries = ParseLibraries(root, cancellationToken); | ||||
|         if (libraries.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         PopulateTargets(root, libraries, cancellationToken); | ||||
|         return new DotNetDepsFile(relativePath, libraries); | ||||
|     } | ||||
|  | ||||
|     private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal); | ||||
|  | ||||
|         if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return result; | ||||
|         } | ||||
|  | ||||
|         foreach (var property in librariesElement.EnumerateObject()) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library)) | ||||
|             { | ||||
|                 result[property.Name] = library; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var targetProperty in targetsElement.EnumerateObject()) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var (tfm, rid) = ParseTargetKey(targetProperty.Name); | ||||
|             if (targetProperty.Value.ValueKind is not JsonValueKind.Object) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var libraryProperty in targetProperty.Value.EnumerateObject()) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 if (!libraries.TryGetValue(libraryProperty.Name, out var library)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!string.IsNullOrEmpty(tfm)) | ||||
|                 { | ||||
|                     library.AddTargetFramework(tfm); | ||||
|                 } | ||||
|  | ||||
|                 if (!string.IsNullOrEmpty(rid)) | ||||
|                 { | ||||
|                     library.AddRuntimeIdentifier(rid); | ||||
|                 } | ||||
|  | ||||
|                 library.MergeTargetMetadata(libraryProperty.Value); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static (string tfm, string? rid) ParseTargetKey(string value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return (string.Empty, null); | ||||
|         } | ||||
|  | ||||
|         var separatorIndex = value.IndexOf('/'); | ||||
|         if (separatorIndex < 0) | ||||
|         { | ||||
|             return (value.Trim(), null); | ||||
|         } | ||||
|  | ||||
|         var tfm = value[..separatorIndex].Trim(); | ||||
|         var rid = value[(separatorIndex + 1)..].Trim(); | ||||
|         return (tfm, string.IsNullOrEmpty(rid) ? null : rid); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class DotNetLibrary | ||||
| { | ||||
|     private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal); | ||||
|     private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal); | ||||
|  | ||||
|     private DotNetLibrary( | ||||
|         string key, | ||||
|         string id, | ||||
|         string version, | ||||
|         string type, | ||||
|         bool? serviceable, | ||||
|         string? sha512, | ||||
|         string? path, | ||||
|         string? hashPath) | ||||
|     { | ||||
|         Key = key; | ||||
|         Id = id; | ||||
|         Version = version; | ||||
|         Type = type; | ||||
|         Serviceable = serviceable; | ||||
|         Sha512 = NormalizeValue(sha512); | ||||
|         PackagePath = NormalizePath(path); | ||||
|         HashPath = NormalizePath(hashPath); | ||||
|     } | ||||
|  | ||||
|     public string Key { get; } | ||||
|  | ||||
|     public string Id { get; } | ||||
|  | ||||
|     public string Version { get; } | ||||
|  | ||||
|     public string Type { get; } | ||||
|  | ||||
|     public bool? Serviceable { get; } | ||||
|  | ||||
|     public string? Sha512 { get; } | ||||
|  | ||||
|     public string? PackagePath { get; } | ||||
|  | ||||
|     public string? HashPath { get; } | ||||
|  | ||||
|     public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|     public IReadOnlyCollection<string> Dependencies => _dependencies; | ||||
|  | ||||
|     public IReadOnlyCollection<string> TargetFrameworks => _targetFrameworks; | ||||
|  | ||||
|     public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers; | ||||
|  | ||||
|     public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library) | ||||
|     { | ||||
|         library = null; | ||||
|         if (!TrySplitNameAndVersion(key, out var id, out var version)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String | ||||
|             ? typeElement.GetString() ?? string.Empty | ||||
|             : string.Empty; | ||||
|  | ||||
|         bool? serviceable = null; | ||||
|         if (element.TryGetProperty("serviceable", out var serviceableElement)) | ||||
|         { | ||||
|             if (serviceableElement.ValueKind is JsonValueKind.True) | ||||
|             { | ||||
|                 serviceable = true; | ||||
|             } | ||||
|             else if (serviceableElement.ValueKind is JsonValueKind.False) | ||||
|             { | ||||
|                 serviceable = false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String | ||||
|             ? sha512Element.GetString() | ||||
|             : null; | ||||
|  | ||||
|         var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String | ||||
|             ? pathElement.GetString() | ||||
|             : null; | ||||
|  | ||||
|         var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String | ||||
|             ? hashElement.GetString() | ||||
|             : null; | ||||
|  | ||||
|         library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath); | ||||
|         library.MergeLibraryMetadata(element); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public void AddTargetFramework(string tfm) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(tfm)) | ||||
|         { | ||||
|             _targetFrameworks.Add(tfm); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void AddRuntimeIdentifier(string rid) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(rid)) | ||||
|         { | ||||
|             _runtimeIdentifiers.Add(rid); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void MergeTargetMetadata(JsonElement element) | ||||
|     { | ||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|         { | ||||
|             AddDependency(dependencyProperty.Name); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void MergeLibraryMetadata(JsonElement element) | ||||
|     { | ||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|         { | ||||
|             AddDependency(dependencyProperty.Name); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void AddDependency(string name) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var dependencyId = name; | ||||
|         if (TrySplitNameAndVersion(name, out var parsedName, out _)) | ||||
|         { | ||||
|             dependencyId = parsedName; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(dependencyId)) | ||||
|         { | ||||
|             _dependencies.Add(dependencyId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TrySplitNameAndVersion(string key, out string name, out string version) | ||||
|     { | ||||
|         name = string.Empty; | ||||
|         version = string.Empty; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(key)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var separatorIndex = key.LastIndexOf('/'); | ||||
|         if (separatorIndex <= 0 || separatorIndex >= key.Length - 1) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         name = key[..separatorIndex].Trim(); | ||||
|         version = key[(separatorIndex + 1)..].Trim(); | ||||
|         return name.Length > 0 && version.Length > 0; | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizePath(string? path) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return path.Replace('\\', '/'); | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeValue(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return value.Trim(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,158 @@ | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||
|  | ||||
| internal sealed class DotNetRuntimeConfig | ||||
| { | ||||
|     private DotNetRuntimeConfig( | ||||
|         string relativePath, | ||||
|         IReadOnlyCollection<string> tfms, | ||||
|         IReadOnlyCollection<string> frameworks, | ||||
|         IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph) | ||||
|     { | ||||
|         RelativePath = relativePath; | ||||
|         Tfms = tfms; | ||||
|         Frameworks = frameworks; | ||||
|         RuntimeGraph = runtimeGraph; | ||||
|     } | ||||
|  | ||||
|     public string RelativePath { get; } | ||||
|  | ||||
|     public IReadOnlyCollection<string> Tfms { get; } | ||||
|  | ||||
|     public IReadOnlyCollection<string> Frameworks { get; } | ||||
|  | ||||
|     public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; } | ||||
|  | ||||
|     public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken) | ||||
|     { | ||||
|         using var stream = File.OpenRead(absolutePath); | ||||
|         using var document = JsonDocument.Parse(stream, new JsonDocumentOptions | ||||
|         { | ||||
|             AllowTrailingCommas = true, | ||||
|             CommentHandling = JsonCommentHandling.Skip | ||||
|         }); | ||||
|  | ||||
|         var root = document.RootElement; | ||||
|         if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         var runtimeGraph = new List<RuntimeGraphEntry>(); | ||||
|  | ||||
|         if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             AddIfPresent(tfms, tfmElement.GetString()); | ||||
|         } | ||||
|  | ||||
|         if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object) | ||||
|         { | ||||
|             var frameworkId = FormatFramework(frameworkElement); | ||||
|             AddIfPresent(frameworks, frameworkId); | ||||
|         } | ||||
|  | ||||
|         if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array) | ||||
|         { | ||||
|             foreach (var item in frameworksElement.EnumerateArray()) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|                 var frameworkId = FormatFramework(item); | ||||
|                 AddIfPresent(frameworks, frameworkId); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array) | ||||
|         { | ||||
|             foreach (var item in includedElement.EnumerateArray()) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|                 var frameworkId = FormatFramework(item); | ||||
|                 AddIfPresent(frameworks, frameworkId); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) && | ||||
|             runtimeGraphElement.ValueKind == JsonValueKind.Object && | ||||
|             runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) && | ||||
|             runtimesElement.ValueKind == JsonValueKind.Object) | ||||
|         { | ||||
|             foreach (var ridProperty in runtimesElement.EnumerateObject()) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(ridProperty.Name)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var fallbacks = new List<string>(); | ||||
|                 if (ridProperty.Value.ValueKind == JsonValueKind.Object && | ||||
|                     ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) && | ||||
|                     fallbacksElement.ValueKind == JsonValueKind.Array) | ||||
|                 { | ||||
|                     foreach (var fallback in fallbacksElement.EnumerateArray()) | ||||
|                     { | ||||
|                         if (fallback.ValueKind == JsonValueKind.String) | ||||
|                         { | ||||
|                             var fallbackValue = fallback.GetString(); | ||||
|                             if (!string.IsNullOrWhiteSpace(fallbackValue)) | ||||
|                             { | ||||
|                                 fallbacks.Add(fallbackValue.Trim()); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new DotNetRuntimeConfig( | ||||
|             relativePath, | ||||
|             tfms.ToArray(), | ||||
|             frameworks.ToArray(), | ||||
|             runtimeGraph); | ||||
|     } | ||||
|  | ||||
|     private static void AddIfPresent(ISet<string> set, string? value) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             set.Add(value.Trim()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? FormatFramework(JsonElement element) | ||||
|     { | ||||
|         if (element.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String | ||||
|             ? nameElement.GetString() | ||||
|             : null; | ||||
|  | ||||
|         var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String | ||||
|             ? versionElement.GetString() | ||||
|             : null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return name.Trim(); | ||||
|         } | ||||
|  | ||||
|         return $"{name.Trim()}@{version.Trim()}"; | ||||
|     } | ||||
|  | ||||
|     internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks); | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
|  | ||||
| internal static class Placeholder | ||||
| { | ||||
|     // Analyzer implementation will be added during Sprint LA4. | ||||
| } | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-305A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. | | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-305A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-305B | TODO | SCANNER-ANALYZERS-LANG-10-305A | Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | Signing metadata captured for signed assemblies; offline trust store documented; hash validations deterministic. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-305C | TODO | SCANNER-ANALYZERS-LANG-10-305B | Handle self-contained apps and native assets; merge with EntryTrace usage hints. | Self-contained fixtures map to components with RID flags; usage hints propagate; tests cover linux/win variants. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307D | TODO | SCANNER-ANALYZERS-LANG-10-305C | Integrate shared helpers (license mapping, quiet provenance) and concurrency-safe caches. | Shared helpers reused; concurrency tests for parallel layer scans pass; no redundant allocations. | | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.DotNet/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.DotNet/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "schemaVersion": "1.0", | ||||
|   "id": "stellaops.analyzer.lang.dotnet", | ||||
|   "displayName": "StellaOps .NET Analyzer (preview)", | ||||
|   "version": "0.1.0", | ||||
|   "requiresRestart": true, | ||||
|   "entryPoint": { | ||||
|     "type": "dotnet", | ||||
|     "assembly": "StellaOps.Scanner.Analyzers.Lang.DotNet.dll", | ||||
|     "typeName": "StellaOps.Scanner.Analyzers.Lang.DotNet.DotNetAnalyzerPlugin" | ||||
|   }, | ||||
|   "capabilities": [ | ||||
|     "language-analyzer", | ||||
|     "dotnet", | ||||
|     "nuget" | ||||
|   ], | ||||
|   "metadata": { | ||||
|     "org.stellaops.analyzer.language": "dotnet", | ||||
|     "org.stellaops.analyzer.kind": "language", | ||||
|     "org.stellaops.restart.required": "true", | ||||
|     "org.stellaops.analyzer.status": "preview" | ||||
|   } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| @@ -0,0 +1,118 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "golang", | ||||
|     "componentKey": "purl::pkg:golang/example.com/app@v1.2.3", | ||||
|     "purl": "pkg:golang/example.com/app@v1.2.3", | ||||
|     "name": "example.com/app", | ||||
|     "version": "v1.2.3", | ||||
|     "type": "golang", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binaryPath": "app", | ||||
|       "build.GOARCH": "amd64", | ||||
|       "build.GOOS": "linux", | ||||
|       "build.vcs": "git", | ||||
|       "build.vcs.modified": "false", | ||||
|       "build.vcs.revision": "1234567890abcdef1234567890abcdef12345678", | ||||
|       "build.vcs.time": "2025-09-14T12:34:56Z", | ||||
|       "go.version": "go1.22.5", | ||||
|       "modulePath": "example.com/app", | ||||
|       "modulePath.main": "example.com/app", | ||||
|       "moduleSum": "h1:mainchecksum", | ||||
|       "moduleVersion": "v1.2.3" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "GOARCH", | ||||
|         "value": "amd64" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "GOOS", | ||||
|         "value": "linux" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "vcs.modified", | ||||
|         "value": "false" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "vcs.revision", | ||||
|         "value": "1234567890abcdef1234567890abcdef12345678" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "vcs.time", | ||||
|         "value": "2025-09-14T12:34:56Z" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo.setting", | ||||
|         "locator": "vcs", | ||||
|         "value": "git" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo", | ||||
|         "locator": "module:example.com/app", | ||||
|         "value": "v1.2.3", | ||||
|         "sha256": "h1:mainchecksum" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.modified", | ||||
|         "value": "false" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.revision", | ||||
|         "value": "1234567890abcdef1234567890abcdef12345678" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.time", | ||||
|         "value": "2025-09-14T12:34:56Z" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs", | ||||
|         "value": "git" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "golang", | ||||
|     "componentKey": "purl::pkg:golang/example.com/lib@v1.0.0", | ||||
|     "purl": "pkg:golang/example.com/lib@v1.0.0", | ||||
|     "name": "example.com/lib", | ||||
|     "version": "v1.0.0", | ||||
|     "type": "golang", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binaryPath": "app", | ||||
|       "modulePath": "example.com/lib", | ||||
|       "moduleSum": "h1:depchecksum", | ||||
|       "moduleVersion": "v1.0.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo", | ||||
|         "locator": "module:example.com/lib", | ||||
|         "value": "v1.0.0", | ||||
|         "sha256": "h1:depchecksum" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
										
											Binary file not shown.
										
									
								
							| @@ -0,0 +1,80 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "golang", | ||||
|     "componentKey": "purl::pkg:golang/example.com/app@v0.0.0", | ||||
|     "purl": "pkg:golang/example.com/app@v0.0.0", | ||||
|     "name": "example.com/app", | ||||
|     "version": "v0.0.0", | ||||
|     "type": "golang", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binaryPath": "app", | ||||
|       "build.vcs": "git", | ||||
|       "build.vcs.modified": "true", | ||||
|       "build.vcs.revision": "abcdef0123456789abcdef0123456789abcdef01", | ||||
|       "build.vcs.time": "2025-01-02T03:04:05Z", | ||||
|       "go.version": "go1.20.3", | ||||
|       "modulePath": "example.com/app", | ||||
|       "modulePath.main": "example.com/app", | ||||
|       "moduleSum": "h1:dwarfchecksum", | ||||
|       "moduleVersion": "v0.0.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo", | ||||
|         "locator": "module:example.com/app", | ||||
|         "value": "v0.0.0", | ||||
|         "sha256": "h1:dwarfchecksum" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.modified", | ||||
|         "value": "true" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.revision", | ||||
|         "value": "abcdef0123456789abcdef0123456789abcdef01" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs.time", | ||||
|         "value": "2025-01-02T03:04:05Z" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.dwarf", | ||||
|         "locator": "vcs", | ||||
|         "value": "git" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "golang", | ||||
|     "componentKey": "purl::pkg:golang/example.com/lib@v0.1.0", | ||||
|     "purl": "pkg:golang/example.com/lib@v0.1.0", | ||||
|     "name": "example.com/lib", | ||||
|     "version": "v0.1.0", | ||||
|     "type": "golang", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binaryPath": "app", | ||||
|       "modulePath": "example.com/lib", | ||||
|       "moduleSum": "h1:libchecksum", | ||||
|       "moduleVersion": "v0.1.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.buildinfo", | ||||
|         "locator": "module:example.com/lib", | ||||
|         "value": "v0.1.0", | ||||
|         "sha256": "h1:libchecksum" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -0,0 +1,47 @@ | ||||
| using System.IO; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Go; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests; | ||||
|  | ||||
| public sealed class GoLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task BuildInfoFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "go", "basic"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new GoLanguageAnalyzer(), | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DwarfOnlyFixtureFallsBackToMetadataAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "go", "dwarf-only"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new GoLanguageAnalyzer(), | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Remove="Microsoft.NET.Test.Sdk" /> | ||||
|     <PackageReference Remove="xunit" /> | ||||
|     <PackageReference Remove="xunit.runner.visualstudio" /> | ||||
|     <PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" /> | ||||
|     <PackageReference Remove="Mongo2Go" /> | ||||
|     <PackageReference Remove="coverlet.collector" /> | ||||
|     <PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" /> | ||||
|     <ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" /> | ||||
|     <Using Remove="StellaOps.Concelier.Testing" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Go\StellaOps.Scanner.Analyzers.Lang.Go.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										17
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/GoAnalyzerPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/GoAnalyzerPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go; | ||||
|  | ||||
| public sealed class GoAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Go"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return new GoLanguageAnalyzer(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										292
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/GoLanguageAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/GoLanguageAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,292 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Linq; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go; | ||||
|  | ||||
| public sealed class GoLanguageAnalyzer : ILanguageAnalyzer | ||||
| { | ||||
|     public string Id => "golang"; | ||||
|  | ||||
|     public string DisplayName => "Go Analyzer"; | ||||
|  | ||||
|     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         ArgumentNullException.ThrowIfNull(writer); | ||||
|  | ||||
|         var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath)); | ||||
|         candidatePaths.Sort(StringComparer.Ordinal); | ||||
|  | ||||
|         foreach (var absolutePath in candidatePaths) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             EmitComponents(buildInfo, context, writer); | ||||
|         } | ||||
|  | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer) | ||||
|     { | ||||
|         var components = new List<GoModule> { buildInfo.MainModule }; | ||||
|         components.AddRange(buildInfo.Dependencies | ||||
|             .OrderBy(static module => module.Path, StringComparer.Ordinal) | ||||
|             .ThenBy(static module => module.Version, StringComparer.Ordinal)); | ||||
|  | ||||
|         string? binaryHash = null; | ||||
|         var binaryRelativePath = context.GetRelativePath(buildInfo.AbsoluteBinaryPath); | ||||
|  | ||||
|         foreach (var module in components) | ||||
|         { | ||||
|             var metadata = BuildMetadata(buildInfo, module, binaryRelativePath); | ||||
|             var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash); | ||||
|             var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath); | ||||
|  | ||||
|             var purl = BuildPurl(module.Path, module.Version); | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(purl)) | ||||
|             { | ||||
|                 writer.AddFromPurl( | ||||
|                     analyzerId: Id, | ||||
|                     purl: purl, | ||||
|                     name: module.Path, | ||||
|                     version: module.Version, | ||||
|                     type: "golang", | ||||
|                     metadata: metadata, | ||||
|                     evidence: evidence, | ||||
|                     usedByEntrypoint: usedByEntrypoint); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 var componentKey = BuildFallbackComponentKey(module, buildInfo, binaryRelativePath, ref binaryHash); | ||||
|  | ||||
|                 writer.AddFromExplicitKey( | ||||
|                     analyzerId: Id, | ||||
|                     componentKey: componentKey, | ||||
|                     purl: null, | ||||
|                     name: module.Path, | ||||
|                     version: module.Version, | ||||
|                     type: "golang", | ||||
|                     metadata: metadata, | ||||
|                     evidence: evidence, | ||||
|                     usedByEntrypoint: usedByEntrypoint); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<KeyValuePair<string, string?>> BuildMetadata(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath) | ||||
|     { | ||||
|         var entries = new List<KeyValuePair<string, string?>>(16) | ||||
|         { | ||||
|             new("modulePath", module.Path), | ||||
|             new("binaryPath", string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath), | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(module.Version)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("moduleVersion", module.Version)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(module.Sum)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("moduleSum", module.Sum)); | ||||
|         } | ||||
|  | ||||
|         if (module.Replacement is not null) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("replacedBy.path", module.Replacement.Path)); | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(module.Replacement.Version)) | ||||
|             { | ||||
|                 entries.Add(new KeyValuePair<string, string?>("replacedBy.version", module.Replacement.Version)); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(module.Replacement.Sum)) | ||||
|             { | ||||
|                 entries.Add(new KeyValuePair<string, string?>("replacedBy.sum", module.Replacement.Sum)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (module.IsMain) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("go.version", buildInfo.GoVersion)); | ||||
|             entries.Add(new KeyValuePair<string, string?>("modulePath.main", buildInfo.ModulePath)); | ||||
|  | ||||
|             foreach (var setting in buildInfo.Settings) | ||||
|             { | ||||
|                 var key = $"build.{setting.Key}"; | ||||
|                 if (!entries.Any(pair => string.Equals(pair.Key, key, StringComparison.Ordinal))) | ||||
|                 { | ||||
|                     entries.Add(new KeyValuePair<string, string?>(key, setting.Value)); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (buildInfo.DwarfMetadata is { } dwarf) | ||||
|             { | ||||
|                 AddIfMissing(entries, "build.vcs", dwarf.VcsSystem); | ||||
|                 AddIfMissing(entries, "build.vcs.revision", dwarf.Revision); | ||||
|                 AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant()); | ||||
|                 AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|         return entries; | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash) | ||||
|     { | ||||
|         var evidence = new List<LanguageComponentEvidence> | ||||
|         { | ||||
|             new( | ||||
|                 LanguageEvidenceKind.Metadata, | ||||
|                 "go.buildinfo", | ||||
|                 $"module:{module.Path}", | ||||
|                 module.Version ?? string.Empty, | ||||
|                 module.Sum) | ||||
|         }; | ||||
|  | ||||
|         if (module.IsMain) | ||||
|         { | ||||
|             foreach (var setting in buildInfo.Settings) | ||||
|             { | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Metadata, | ||||
|                     "go.buildinfo.setting", | ||||
|                     setting.Key, | ||||
|                     setting.Value, | ||||
|                     null)); | ||||
|             } | ||||
|  | ||||
|             if (buildInfo.DwarfMetadata is { } dwarf) | ||||
|             { | ||||
|                 if (!string.IsNullOrWhiteSpace(dwarf.VcsSystem)) | ||||
|                 { | ||||
|                     evidence.Add(new LanguageComponentEvidence( | ||||
|                         LanguageEvidenceKind.Metadata, | ||||
|                         "go.dwarf", | ||||
|                         "vcs", | ||||
|                         dwarf.VcsSystem, | ||||
|                         null)); | ||||
|                 } | ||||
|  | ||||
|                 if (!string.IsNullOrWhiteSpace(dwarf.Revision)) | ||||
|                 { | ||||
|                     evidence.Add(new LanguageComponentEvidence( | ||||
|                         LanguageEvidenceKind.Metadata, | ||||
|                         "go.dwarf", | ||||
|                         "vcs.revision", | ||||
|                         dwarf.Revision, | ||||
|                         null)); | ||||
|                 } | ||||
|  | ||||
|                 if (dwarf.Modified.HasValue) | ||||
|                 { | ||||
|                     evidence.Add(new LanguageComponentEvidence( | ||||
|                         LanguageEvidenceKind.Metadata, | ||||
|                         "go.dwarf", | ||||
|                         "vcs.modified", | ||||
|                         dwarf.Modified.Value ? "true" : "false", | ||||
|                         null)); | ||||
|                 } | ||||
|  | ||||
|                 if (!string.IsNullOrWhiteSpace(dwarf.TimestampUtc)) | ||||
|                 { | ||||
|                     evidence.Add(new LanguageComponentEvidence( | ||||
|                         LanguageEvidenceKind.Metadata, | ||||
|                         "go.dwarf", | ||||
|                         "vcs.time", | ||||
|                         dwarf.TimestampUtc, | ||||
|                         null)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Attach binary hash evidence for fallback components without purl. | ||||
|         if (string.IsNullOrEmpty(module.Version)) | ||||
|         { | ||||
|             binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath); | ||||
|             if (!string.IsNullOrEmpty(binaryHash)) | ||||
|             { | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.File, | ||||
|                     "binary", | ||||
|                     string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath, | ||||
|                     null, | ||||
|                     binaryHash)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey)); | ||||
|         return evidence; | ||||
|     } | ||||
|  | ||||
|     private static string? BuildPurl(string path, string? version) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var cleanedPath = path.Trim(); | ||||
|         var cleanedVersion = version.Trim(); | ||||
|         var encodedVersion = Uri.EscapeDataString(cleanedVersion); | ||||
|         return $"pkg:golang/{cleanedPath}@{encodedVersion}"; | ||||
|     } | ||||
|  | ||||
|     private static string BuildFallbackComponentKey(GoModule module, GoBuildInfo buildInfo, string binaryRelativePath, ref string? binaryHash) | ||||
|     { | ||||
|         var relative = string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath; | ||||
|         binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath); | ||||
|         if (!string.IsNullOrEmpty(binaryHash)) | ||||
|         { | ||||
|             return $"golang::module:{module.Path}::{relative}::{binaryHash}"; | ||||
|         } | ||||
|  | ||||
|         return $"golang::module:{module.Path}::{relative}"; | ||||
|     } | ||||
|  | ||||
|     private static void AddIfMissing(List<KeyValuePair<string, string?>> entries, string key, string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (entries.Any(entry => string.Equals(entry.Key, key, StringComparison.Ordinal))) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         entries.Add(new KeyValuePair<string, string?>(key, value)); | ||||
|     } | ||||
|  | ||||
|     private static string? ComputeBinaryHash(string path) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|             using var sha = SHA256.Create(); | ||||
|             var hash = sha.ComputeHash(stream); | ||||
|             return Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,63 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal static class GoBinaryScanner | ||||
| { | ||||
|     private static readonly ReadOnlyMemory<byte> BuildInfoMagic = new byte[] | ||||
|     { | ||||
|         0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':' | ||||
|     }; | ||||
|  | ||||
|     public static IEnumerable<string> EnumerateCandidateFiles(string rootPath) | ||||
|     { | ||||
|         var enumeration = new EnumerationOptions | ||||
|         { | ||||
|             RecurseSubdirectories = true, | ||||
|             IgnoreInaccessible = true, | ||||
|             AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||
|             MatchCasing = MatchCasing.CaseSensitive, | ||||
|         }; | ||||
|  | ||||
|         foreach (var path in Directory.EnumerateFiles(rootPath, "*", enumeration)) | ||||
|         { | ||||
|             yield return path; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData) | ||||
|     { | ||||
|         goVersion = null; | ||||
|         moduleData = null; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var info = new FileInfo(filePath); | ||||
|             if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var data = File.ReadAllBytes(filePath); | ||||
|             var span = new ReadOnlySpan<byte>(data); | ||||
|             var offset = span.IndexOf(BuildInfoMagic.Span); | ||||
|             if (offset < 0) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var view = span[offset..]; | ||||
|             return GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,80 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal sealed class GoBuildInfo | ||||
| { | ||||
|     public GoBuildInfo( | ||||
|         string goVersion, | ||||
|         string absoluteBinaryPath, | ||||
|         string modulePath, | ||||
|         GoModule mainModule, | ||||
|         IEnumerable<GoModule> dependencies, | ||||
|         IEnumerable<KeyValuePair<string, string?>> settings, | ||||
|         GoDwarfMetadata? dwarfMetadata = null) | ||||
|         : this( | ||||
|             goVersion, | ||||
|             absoluteBinaryPath, | ||||
|             modulePath, | ||||
|             mainModule, | ||||
|             dependencies? | ||||
|                 .Where(static module => module is not null) | ||||
|                 .ToImmutableArray() | ||||
|                 ?? ImmutableArray<GoModule>.Empty, | ||||
|             settings? | ||||
|                 .Where(static pair => pair.Key is not null) | ||||
|                 .Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value)) | ||||
|                 .ToImmutableArray() | ||||
|                 ?? ImmutableArray<KeyValuePair<string, string?>>.Empty, | ||||
|             dwarfMetadata) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     private GoBuildInfo( | ||||
|         string goVersion, | ||||
|         string absoluteBinaryPath, | ||||
|         string modulePath, | ||||
|         GoModule mainModule, | ||||
|         ImmutableArray<GoModule> dependencies, | ||||
|         ImmutableArray<KeyValuePair<string, string?>> settings, | ||||
|         GoDwarfMetadata? dwarfMetadata) | ||||
|     { | ||||
|         GoVersion = goVersion ?? throw new ArgumentNullException(nameof(goVersion)); | ||||
|         AbsoluteBinaryPath = absoluteBinaryPath ?? throw new ArgumentNullException(nameof(absoluteBinaryPath)); | ||||
|         ModulePath = modulePath ?? throw new ArgumentNullException(nameof(modulePath)); | ||||
|         MainModule = mainModule ?? throw new ArgumentNullException(nameof(mainModule)); | ||||
|         Dependencies = dependencies; | ||||
|         Settings = settings; | ||||
|         DwarfMetadata = dwarfMetadata; | ||||
|     } | ||||
|  | ||||
|     public string GoVersion { get; } | ||||
|  | ||||
|     public string AbsoluteBinaryPath { get; } | ||||
|  | ||||
|     public string ModulePath { get; } | ||||
|  | ||||
|     public GoModule MainModule { get; } | ||||
|  | ||||
|     public ImmutableArray<GoModule> Dependencies { get; } | ||||
|  | ||||
|     public ImmutableArray<KeyValuePair<string, string?>> Settings { get; } | ||||
|  | ||||
|     public GoDwarfMetadata? DwarfMetadata { get; } | ||||
|  | ||||
|     public GoBuildInfo WithDwarf(GoDwarfMetadata metadata) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(metadata); | ||||
|         return new GoBuildInfo( | ||||
|             GoVersion, | ||||
|             AbsoluteBinaryPath, | ||||
|             ModulePath, | ||||
|             MainModule, | ||||
|             Dependencies, | ||||
|             Settings, | ||||
|             metadata); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,159 @@ | ||||
| using System; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal static class GoBuildInfoDecoder | ||||
| { | ||||
|     private const string BuildInfoMagic = "\xff Go buildinf:"; | ||||
|     private const int HeaderSize = 32; | ||||
|     private const byte VarintEncodingFlag = 0x02; | ||||
|  | ||||
|     public static bool TryDecode(ReadOnlySpan<byte> data, out string? goVersion, out string? moduleData) | ||||
|     { | ||||
|         goVersion = null; | ||||
|         moduleData = null; | ||||
|  | ||||
|         if (data.Length < HeaderSize) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!IsMagicMatch(data)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var pointerSize = data[14]; | ||||
|         var flags = data[15]; | ||||
|  | ||||
|         if (pointerSize != 4 && pointerSize != 8) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if ((flags & VarintEncodingFlag) == 0) | ||||
|         { | ||||
|             // Older Go toolchains encode pointers to strings instead of inline data. | ||||
|             // The Sprint 10 scope targets Go 1.18+, which always sets the varint flag. | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var payload = data.Slice(HeaderSize); | ||||
|  | ||||
|         if (!TryReadVarString(payload, out var version, out var consumed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         payload = payload.Slice(consumed); | ||||
|  | ||||
|         if (!TryReadVarString(payload, out var modules, out _)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         modules = StripSentinel(modules); | ||||
|  | ||||
|         goVersion = version; | ||||
|         moduleData = modules; | ||||
|         return !string.IsNullOrWhiteSpace(moduleData); | ||||
|     } | ||||
|  | ||||
|     private static bool IsMagicMatch(ReadOnlySpan<byte> data) | ||||
|     { | ||||
|         if (data.Length < BuildInfoMagic.Length) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         for (var i = 0; i < BuildInfoMagic.Length; i++) | ||||
|         { | ||||
|             if (data[i] != BuildInfoMagic[i]) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool TryReadVarString(ReadOnlySpan<byte> data, out string result, out int consumed) | ||||
|     { | ||||
|         result = string.Empty; | ||||
|         consumed = 0; | ||||
|  | ||||
|         if (!TryReadUVarint(data, out var length, out var lengthBytes)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (length > int.MaxValue) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var stringLength = (int)length; | ||||
|         var totalRequired = lengthBytes + stringLength; | ||||
|         if (stringLength <= 0 || totalRequired > data.Length) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var slice = data.Slice(lengthBytes, stringLength); | ||||
|         result = Encoding.UTF8.GetString(slice); | ||||
|         consumed = totalRequired; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool TryReadUVarint(ReadOnlySpan<byte> data, out ulong value, out int bytesRead) | ||||
|     { | ||||
|         value = 0; | ||||
|         bytesRead = 0; | ||||
|  | ||||
|         ulong x = 0; | ||||
|         var shift = 0; | ||||
|  | ||||
|         for (var i = 0; i < data.Length; i++) | ||||
|         { | ||||
|             var b = data[i]; | ||||
|             if (b < 0x80) | ||||
|             { | ||||
|                 if (i > 9 || i == 9 && b > 1) | ||||
|                 { | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 value = x | (ulong)b << shift; | ||||
|                 bytesRead = i + 1; | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             x |= (ulong)(b & 0x7F) << shift; | ||||
|             shift += 7; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static string StripSentinel(string value) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(value) || value.Length < 33) | ||||
|         { | ||||
|             return value; | ||||
|         } | ||||
|  | ||||
|         var sentinelIndex = value.Length - 17; | ||||
|         if (value[sentinelIndex] != '\n') | ||||
|         { | ||||
|             return value; | ||||
|         } | ||||
|  | ||||
|         return value[16..^16]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,234 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal static class GoBuildInfoParser | ||||
| { | ||||
|     private const string PathPrefix = "path\t"; | ||||
|     private const string ModulePrefix = "mod\t"; | ||||
|     private const string DependencyPrefix = "dep\t"; | ||||
|     private const string ReplacementPrefix = "=>\t"; | ||||
|     private const string BuildPrefix = "build\t"; | ||||
|  | ||||
|     public static bool TryParse(string goVersion, string absoluteBinaryPath, string rawModuleData, out GoBuildInfo? info) | ||||
|     { | ||||
|         info = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(rawModuleData)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         string? modulePath = null; | ||||
|         GoModule? mainModule = null; | ||||
|         var dependencies = new List<GoModule>(); | ||||
|         var settings = new SortedDictionary<string, string?>(StringComparer.Ordinal); | ||||
|  | ||||
|         GoModule? lastModule = null; | ||||
|         using var reader = new StringReader(rawModuleData); | ||||
|  | ||||
|         while (reader.ReadLine() is { } line) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(line)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(PathPrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 modulePath = line[PathPrefix.Length..].Trim(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(ModulePrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 mainModule = ParseModule(line.AsSpan(ModulePrefix.Length), isMain: true); | ||||
|                 lastModule = mainModule; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(DependencyPrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 var dependency = ParseModule(line.AsSpan(DependencyPrefix.Length), isMain: false); | ||||
|                 if (dependency is not null) | ||||
|                 { | ||||
|                     dependencies.Add(dependency); | ||||
|                     lastModule = dependency; | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(ReplacementPrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 if (lastModule is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var replacement = ParseReplacement(line.AsSpan(ReplacementPrefix.Length)); | ||||
|                 if (replacement is not null) | ||||
|                 { | ||||
|                     lastModule.SetReplacement(replacement); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(BuildPrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 var pair = ParseBuildSetting(line.AsSpan(BuildPrefix.Length)); | ||||
|                 if (!string.IsNullOrEmpty(pair.Key)) | ||||
|                 { | ||||
|                     settings[pair.Key] = pair.Value; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (mainModule is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrEmpty(modulePath)) | ||||
|         { | ||||
|             modulePath = mainModule.Path; | ||||
|         } | ||||
|  | ||||
|         info = new GoBuildInfo( | ||||
|             goVersion, | ||||
|             absoluteBinaryPath, | ||||
|             modulePath, | ||||
|             mainModule, | ||||
|             dependencies, | ||||
|             settings); | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static GoModule? ParseModule(ReadOnlySpan<char> span, bool isMain) | ||||
|     { | ||||
|         var fields = SplitFields(span, expected: 4); | ||||
|         if (fields.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var path = fields[0]; | ||||
|         if (string.IsNullOrWhiteSpace(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var version = fields.Count > 1 ? fields[1] : null; | ||||
|         var sum = fields.Count > 2 ? fields[2] : null; | ||||
|  | ||||
|         return new GoModule(path, version, sum, isMain); | ||||
|     } | ||||
|  | ||||
|     private static GoModuleReplacement? ParseReplacement(ReadOnlySpan<char> span) | ||||
|     { | ||||
|         var fields = SplitFields(span, expected: 3); | ||||
|         if (fields.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var path = fields[0]; | ||||
|         if (string.IsNullOrWhiteSpace(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var version = fields.Count > 1 ? fields[1] : null; | ||||
|         var sum = fields.Count > 2 ? fields[2] : null; | ||||
|  | ||||
|         return new GoModuleReplacement(path, version, sum); | ||||
|     } | ||||
|  | ||||
|     private static KeyValuePair<string, string?> ParseBuildSetting(ReadOnlySpan<char> span) | ||||
|     { | ||||
|         span = span.Trim(); | ||||
|         if (span.IsEmpty) | ||||
|         { | ||||
|             return default; | ||||
|         } | ||||
|  | ||||
|         var separatorIndex = span.IndexOf('='); | ||||
|         if (separatorIndex <= 0) | ||||
|         { | ||||
|             return default; | ||||
|         } | ||||
|  | ||||
|         var rawKey = span[..separatorIndex].Trim(); | ||||
|         var rawValue = span[(separatorIndex + 1)..].Trim(); | ||||
|  | ||||
|         var key = Unquote(rawKey.ToString()); | ||||
|         if (string.IsNullOrWhiteSpace(key)) | ||||
|         { | ||||
|             return default; | ||||
|         } | ||||
|  | ||||
|         var value = Unquote(rawValue.ToString()); | ||||
|         return new KeyValuePair<string, string?>(key, value); | ||||
|     } | ||||
|  | ||||
|     private static List<string> SplitFields(ReadOnlySpan<char> span, int expected) | ||||
|     { | ||||
|         var fields = new List<string>(expected); | ||||
|         var builder = new StringBuilder(); | ||||
|  | ||||
|         for (var i = 0; i < span.Length; i++) | ||||
|         { | ||||
|             var current = span[i]; | ||||
|             if (current == '\t') | ||||
|             { | ||||
|                 fields.Add(builder.ToString()); | ||||
|                 builder.Clear(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             builder.Append(current); | ||||
|         } | ||||
|  | ||||
|         fields.Add(builder.ToString()); | ||||
|         return fields; | ||||
|     } | ||||
|  | ||||
|     private static string Unquote(string value) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(value)) | ||||
|         { | ||||
|             return value; | ||||
|         } | ||||
|  | ||||
|         value = value.Trim(); | ||||
|         if (value.Length < 2) | ||||
|         { | ||||
|             return value; | ||||
|         } | ||||
|  | ||||
|         if (value[0] == '"' && value[^1] == '"') | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 return JsonSerializer.Deserialize<string>(value) ?? value; | ||||
|             } | ||||
|             catch (JsonException) | ||||
|             { | ||||
|                 return value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (value[0] == '`' && value[^1] == '`') | ||||
|         { | ||||
|             return value[1..^1]; | ||||
|         } | ||||
|  | ||||
|         return value; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,82 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.IO; | ||||
| using System.Security; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal static class GoBuildInfoProvider | ||||
| { | ||||
|     private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new(); | ||||
|  | ||||
|     public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info) | ||||
|     { | ||||
|         info = null; | ||||
|  | ||||
|         FileInfo fileInfo; | ||||
|         try | ||||
|         { | ||||
|             fileInfo = new FileInfo(absolutePath); | ||||
|             if (!fileInfo.Exists) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (System.Security.SecurityException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks); | ||||
|         info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath); | ||||
|         return info is not null; | ||||
|     } | ||||
|  | ||||
|     private static GoBuildInfo? CreateBuildInfo(string absolutePath) | ||||
|     { | ||||
|         if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(moduleData)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (!GoBuildInfoParser.TryParse(goVersion!, absolutePath, moduleData!, out var buildInfo) || buildInfo is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (GoDwarfReader.TryRead(absolutePath, out var dwarf) && dwarf is not null) | ||||
|         { | ||||
|             buildInfo = buildInfo.WithDwarf(dwarf); | ||||
|         } | ||||
|  | ||||
|         return buildInfo; | ||||
|     } | ||||
|  | ||||
|     private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks) | ||||
|     { | ||||
|         private readonly string _normalizedPath = OperatingSystem.IsWindows() | ||||
|             ? Path.ToLowerInvariant() | ||||
|             : Path; | ||||
|  | ||||
|         public bool Equals(GoBinaryCacheKey other) | ||||
|             => Length == other.Length | ||||
|                && LastWriteTicks == other.LastWriteTicks | ||||
|                && string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal); | ||||
|  | ||||
|         public override int GetHashCode() | ||||
|             => HashCode.Combine(_normalizedPath, Length, LastWriteTicks); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,33 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal sealed class GoDwarfMetadata | ||||
| { | ||||
|     public GoDwarfMetadata(string? vcsSystem, string? revision, bool? modified, string? timestampUtc) | ||||
|     { | ||||
|         VcsSystem = Normalize(vcsSystem); | ||||
|         Revision = Normalize(revision); | ||||
|         Modified = modified; | ||||
|         TimestampUtc = Normalize(timestampUtc); | ||||
|     } | ||||
|  | ||||
|     public string? VcsSystem { get; } | ||||
|  | ||||
|     public string? Revision { get; } | ||||
|  | ||||
|     public bool? Modified { get; } | ||||
|  | ||||
|     public string? TimestampUtc { get; } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = value.Trim(); | ||||
|         return trimmed.Length == 0 ? null : trimmed; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal static class GoDwarfReader | ||||
| { | ||||
|     private static readonly byte[] VcsSystemToken = Encoding.UTF8.GetBytes("vcs="); | ||||
|     private static readonly byte[] VcsRevisionToken = Encoding.UTF8.GetBytes("vcs.revision="); | ||||
|     private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified="); | ||||
|     private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time="); | ||||
|  | ||||
|     public static bool TryRead(string path, out GoDwarfMetadata? metadata) | ||||
|     { | ||||
|         metadata = null; | ||||
|  | ||||
|         ReadOnlySpan<byte> data; | ||||
|         try | ||||
|         { | ||||
|             var fileInfo = new FileInfo(path); | ||||
|             if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             data = File.ReadAllBytes(path); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var revision = ExtractValue(data, VcsRevisionToken); | ||||
|         var modifiedText = ExtractValue(data, VcsModifiedToken); | ||||
|         var timestamp = ExtractValue(data, VcsTimeToken); | ||||
|         var system = ExtractValue(data, VcsSystemToken); | ||||
|  | ||||
|         bool? modified = null; | ||||
|         if (!string.IsNullOrWhiteSpace(modifiedText)) | ||||
|         { | ||||
|             if (bool.TryParse(modifiedText, out var parsed)) | ||||
|             { | ||||
|                 modified = parsed; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         metadata = new GoDwarfMetadata(system, revision, modified, timestamp); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token) | ||||
|     { | ||||
|         var index = data.IndexOf(token); | ||||
|         if (index < 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var start = index + token.Length; | ||||
|         var end = start; | ||||
|  | ||||
|         while (end < data.Length) | ||||
|         { | ||||
|             var current = data[end]; | ||||
|             if (current == 0 || current == (byte)'\n' || current == (byte)'\r') | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             end++; | ||||
|         } | ||||
|  | ||||
|         if (end <= start) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return Encoding.UTF8.GetString(data.Slice(start, end - start)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										67
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/Internal/GoModule.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/Internal/GoModule.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal sealed class GoModule | ||||
| { | ||||
|     public GoModule(string path, string? version, string? sum, bool isMain) | ||||
|     { | ||||
|         Path = path ?? throw new ArgumentNullException(nameof(path)); | ||||
|         Version = Normalize(version); | ||||
|         Sum = Normalize(sum); | ||||
|         IsMain = isMain; | ||||
|     } | ||||
|  | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public string? Version { get; } | ||||
|  | ||||
|     public string? Sum { get; } | ||||
|  | ||||
|     public GoModuleReplacement? Replacement { get; private set; } | ||||
|  | ||||
|     public bool IsMain { get; } | ||||
|  | ||||
|     public void SetReplacement(GoModuleReplacement replacement) | ||||
|     { | ||||
|         Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement)); | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = value.Trim(); | ||||
|         return trimmed.Length == 0 ? null : trimmed; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class GoModuleReplacement | ||||
| { | ||||
|     public GoModuleReplacement(string path, string? version, string? sum) | ||||
|     { | ||||
|         Path = path ?? throw new ArgumentNullException(nameof(path)); | ||||
|         Version = Normalize(version); | ||||
|         Sum = Normalize(sum); | ||||
|     } | ||||
|  | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public string? Version { get; } | ||||
|  | ||||
|     public string? Sum { get; } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = value.Trim(); | ||||
|         return trimmed.Length == 0 ? null : trimmed; | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go; | ||||
|  | ||||
| internal static class Placeholder | ||||
| { | ||||
|     // Analyzer implementation will be added during Sprint LA3. | ||||
| } | ||||
| @@ -2,8 +2,8 @@ | ||||
|  | ||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-304A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.18–1.23 fixtures; evidence includes VCS, module path, and build settings. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-304B | TODO | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95 % fixtures; cache reduces duplicated IO by ≥70 %. | | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-304A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.18–1.23 fixtures; evidence includes VCS, module path, and build settings. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-304B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95 % fixtures; cache reduces duplicated IO by ≥70 %. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-304C | TODO | SCANNER-ANALYZERS-LANG-10-304B | Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | Heuristic labels clearly separated; tests ensure no false “observed” provenance; documentation updated. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307G | TODO | SCANNER-ANALYZERS-LANG-10-304C | Wire shared helpers (license mapping, usage flags) and ensure concurrency-safe buffer reuse. | Analyzer reuses shared infrastructure; concurrency tests with parallel scans pass; no data races. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308G | TODO | SCANNER-ANALYZERS-LANG-10-307G | Determinism fixtures + benchmark harness (Vs competitor). | Fixtures under `Fixtures/lang/go/`; CI determinism check; benchmark runs showing ≥20 % speed advantage. | | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Go/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "schemaVersion": "1.0", | ||||
|   "id": "stellaops.analyzer.lang.go", | ||||
|   "displayName": "StellaOps Go Analyzer (preview)", | ||||
|   "version": "0.1.0", | ||||
|   "requiresRestart": true, | ||||
|   "entryPoint": { | ||||
|     "type": "dotnet", | ||||
|     "assembly": "StellaOps.Scanner.Analyzers.Lang.Go.dll", | ||||
|     "typeName": "StellaOps.Scanner.Analyzers.Lang.Go.GoAnalyzerPlugin" | ||||
|   }, | ||||
|   "capabilities": [ | ||||
|     "language-analyzer", | ||||
|     "golang", | ||||
|     "go" | ||||
|   ], | ||||
|   "metadata": { | ||||
|     "org.stellaops.analyzer.language": "go", | ||||
|     "org.stellaops.analyzer.kind": "language", | ||||
|     "org.stellaops.restart.required": "true", | ||||
|     "org.stellaops.analyzer.status": "preview" | ||||
|   } | ||||
| } | ||||
| @@ -1,35 +1,35 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "java", | ||||
|     "componentKey": "purl::pkg:maven/com/example/demo@1.0.0", | ||||
|     "purl": "pkg:maven/com/example/demo@1.0.0", | ||||
|     "name": "demo", | ||||
|     "version": "1.0.0", | ||||
|     "type": "maven", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "artifactId": "demo", | ||||
|       "displayName": "Demo Library", | ||||
|       "groupId": "com.example", | ||||
|       "jarPath": "libs/demo.jar", | ||||
|       "manifestTitle": "Demo", | ||||
|       "manifestVendor": "Example Corp", | ||||
|       "manifestVersion": "1.0.0", | ||||
|       "packaging": "jar" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "MANIFEST.MF", | ||||
|         "locator": "libs/demo.jar!META-INF/MANIFEST.MF", | ||||
|         "value": "title=Demo;version=1.0.0;vendor=Example Corp" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "pom.properties", | ||||
|         "locator": "libs/demo.jar!META-INF/maven/com.example/demo/pom.properties", | ||||
|         "sha256": "c20f36aa1b9d89d28cf9ed131519ffd6287a4dac0c7cb926130496f3f8157bf1" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "java", | ||||
|     "componentKey": "purl::pkg:maven/com/example/demo@1.0.0", | ||||
|     "purl": "pkg:maven/com/example/demo@1.0.0", | ||||
|     "name": "demo", | ||||
|     "version": "1.0.0", | ||||
|     "type": "maven", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "artifactId": "demo", | ||||
|       "displayName": "Demo Library", | ||||
|       "groupId": "com.example", | ||||
|       "jarPath": "libs/demo.jar", | ||||
|       "manifestTitle": "Demo", | ||||
|       "manifestVendor": "Example Corp", | ||||
|       "manifestVersion": "1.0.0", | ||||
|       "packaging": "jar" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "MANIFEST.MF", | ||||
|         "locator": "libs/demo.jar!META-INF/MANIFEST.MF", | ||||
|         "value": "title=Demo;version=1.0.0;vendor=Example Corp" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "pom.properties", | ||||
|         "locator": "libs/demo.jar!META-INF/maven/com.example/demo/pom.properties", | ||||
|         "sha256": "c20f36aa1b9d89d28cf9ed131519ffd6287a4dac0c7cb926130496f3f8157bf1" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -1,33 +1,33 @@ | ||||
| using StellaOps.Scanner.Analyzers.Lang.Java; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
| 
 | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Tests.Java; | ||||
| 
 | ||||
| public sealed class JavaLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ExtractsMavenArtifactFromJarAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var root = TestPaths.CreateTemporaryDirectory(); | ||||
|         try | ||||
|         { | ||||
|             var jarPath = JavaFixtureBuilder.CreateSampleJar(root); | ||||
|             var usageHints = new LanguageUsageHints(new[] { jarPath }); | ||||
|             var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() }; | ||||
|             var goldenPath = TestPaths.ResolveFixture("java", "basic", "expected.json"); | ||||
| 
 | ||||
|             await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|                 fixturePath: root, | ||||
|                 goldenPath: goldenPath, | ||||
|                 analyzers: analyzers, | ||||
|                 cancellationToken: cancellationToken, | ||||
|                 usageHints: usageHints); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             TestPaths.SafeDelete(root); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| using StellaOps.Scanner.Analyzers.Lang.Java; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
| 
 | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests; | ||||
| 
 | ||||
| public sealed class JavaLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ExtractsMavenArtifactFromJarAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var root = TestPaths.CreateTemporaryDirectory(); | ||||
|         try | ||||
|         { | ||||
|             var jarPath = JavaFixtureBuilder.CreateSampleJar(root); | ||||
|             var usageHints = new LanguageUsageHints(new[] { jarPath }); | ||||
|             var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() }; | ||||
|             var goldenPath = TestPaths.ResolveFixture("java", "basic", "expected.json"); | ||||
| 
 | ||||
|             await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|                 fixturePath: root, | ||||
|                 goldenPath: goldenPath, | ||||
|                 analyzers: analyzers, | ||||
|                 cancellationToken: cancellationToken, | ||||
|                 usageHints: usageHints); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             TestPaths.SafeDelete(root); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Remove="Microsoft.NET.Test.Sdk" /> | ||||
|     <PackageReference Remove="xunit" /> | ||||
|     <PackageReference Remove="xunit.runner.visualstudio" /> | ||||
|     <PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" /> | ||||
|     <PackageReference Remove="Mongo2Go" /> | ||||
|     <PackageReference Remove="coverlet.collector" /> | ||||
|     <PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" /> | ||||
|     <ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" /> | ||||
|     <Using Remove="StellaOps.Concelier.Testing" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,134 +1,134 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/left-pad@1.3.0", | ||||
|     "purl": "pkg:npm/left-pad@1.3.0", | ||||
|     "name": "left-pad", | ||||
|     "version": "1.3.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-LEFTPAD", | ||||
|       "path": "packages/app/node_modules/left-pad", | ||||
|       "resolved": "https://registry.example/left-pad-1.3.0.tgz" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/left-pad/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/lib@2.0.1", | ||||
|     "purl": "pkg:npm/lib@2.0.1", | ||||
|     "name": "lib", | ||||
|     "version": "2.0.1", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-LIB", | ||||
|       "path": "packages/lib", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "workspaceLink": "packages/app/node_modules/lib", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/lib" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/lib/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/lib/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/root-workspace@1.0.0", | ||||
|     "purl": "pkg:npm/root-workspace@1.0.0", | ||||
|     "name": "root-workspace", | ||||
|     "version": "1.0.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "path": ".", | ||||
|       "private": "true" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/shared@3.1.4", | ||||
|     "purl": "pkg:npm/shared@3.1.4", | ||||
|     "name": "shared", | ||||
|     "version": "3.1.4", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-SHARED", | ||||
|       "path": "packages/shared", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "workspaceLink": "packages/app/node_modules/shared", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/shared", | ||||
|       "workspaceTargets": "packages/lib" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/shared/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/shared/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/workspace-app@1.0.0", | ||||
|     "purl": "pkg:npm/workspace-app@1.0.0", | ||||
|     "name": "workspace-app", | ||||
|     "version": "1.0.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "installScripts": "true", | ||||
|       "path": "packages/app", | ||||
|       "policyHint.installLifecycle": "postinstall", | ||||
|       "script.postinstall": "node scripts/setup.js", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/app", | ||||
|       "workspaceTargets": "packages/lib;packages/shared" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "package.json:scripts", | ||||
|         "locator": "packages/app/package.json#scripts.postinstall", | ||||
|         "value": "node scripts/setup.js", | ||||
|         "sha256": "f9ae4e4c9313857d1acc31947cee9984232cbefe93c8a56c718804744992728a" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/left-pad@1.3.0", | ||||
|     "purl": "pkg:npm/left-pad@1.3.0", | ||||
|     "name": "left-pad", | ||||
|     "version": "1.3.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-LEFTPAD", | ||||
|       "path": "packages/app/node_modules/left-pad", | ||||
|       "resolved": "https://registry.example/left-pad-1.3.0.tgz" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/left-pad/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/lib@2.0.1", | ||||
|     "purl": "pkg:npm/lib@2.0.1", | ||||
|     "name": "lib", | ||||
|     "version": "2.0.1", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-LIB", | ||||
|       "path": "packages/lib", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "workspaceLink": "packages/app/node_modules/lib", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/lib" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/lib/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/lib/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/root-workspace@1.0.0", | ||||
|     "purl": "pkg:npm/root-workspace@1.0.0", | ||||
|     "name": "root-workspace", | ||||
|     "version": "1.0.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "path": ".", | ||||
|       "private": "true" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/shared@3.1.4", | ||||
|     "purl": "pkg:npm/shared@3.1.4", | ||||
|     "name": "shared", | ||||
|     "version": "3.1.4", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "integrity": "sha512-SHARED", | ||||
|       "path": "packages/shared", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "workspaceLink": "packages/app/node_modules/shared", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/shared", | ||||
|       "workspaceTargets": "packages/lib" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/node_modules/shared/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/shared/package.json" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "node", | ||||
|     "componentKey": "purl::pkg:npm/workspace-app@1.0.0", | ||||
|     "purl": "pkg:npm/workspace-app@1.0.0", | ||||
|     "name": "workspace-app", | ||||
|     "version": "1.0.0", | ||||
|     "type": "npm", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "installScripts": "true", | ||||
|       "path": "packages/app", | ||||
|       "policyHint.installLifecycle": "postinstall", | ||||
|       "script.postinstall": "node scripts/setup.js", | ||||
|       "workspaceMember": "true", | ||||
|       "workspaceRoot": "packages/app", | ||||
|       "workspaceTargets": "packages/lib;packages/shared" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "package.json", | ||||
|         "locator": "packages/app/package.json" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "package.json:scripts", | ||||
|         "locator": "packages/app/package.json#scripts.postinstall", | ||||
|         "value": "node scripts/setup.js", | ||||
|         "sha256": "f9ae4e4c9313857d1acc31947cee9984232cbefe93c8a56c718804744992728a" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -1,49 +1,49 @@ | ||||
| { | ||||
|   "name": "root-workspace", | ||||
|   "version": "1.0.0", | ||||
|   "lockfileVersion": 3, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "root-workspace", | ||||
|       "version": "1.0.0", | ||||
|       "private": true, | ||||
|       "workspaces": [ | ||||
|         "packages/*" | ||||
|       ] | ||||
|     }, | ||||
|     "packages/app": { | ||||
|       "name": "workspace-app", | ||||
|       "version": "1.0.0" | ||||
|     }, | ||||
|     "packages/lib": { | ||||
|       "name": "lib", | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "integrity": "sha512-LIB" | ||||
|     }, | ||||
|     "packages/shared": { | ||||
|       "name": "shared", | ||||
|       "version": "3.1.4", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "integrity": "sha512-SHARED" | ||||
|     }, | ||||
|     "packages/app/node_modules/lib": { | ||||
|       "name": "lib", | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "integrity": "sha512-LIB" | ||||
|     }, | ||||
|     "packages/app/node_modules/shared": { | ||||
|       "name": "shared", | ||||
|       "version": "3.1.4", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "integrity": "sha512-SHARED" | ||||
|     }, | ||||
|     "packages/app/node_modules/left-pad": { | ||||
|       "name": "left-pad", | ||||
|       "version": "1.3.0", | ||||
|       "resolved": "https://registry.example/left-pad-1.3.0.tgz", | ||||
|       "integrity": "sha512-LEFTPAD" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| { | ||||
|   "name": "root-workspace", | ||||
|   "version": "1.0.0", | ||||
|   "lockfileVersion": 3, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "root-workspace", | ||||
|       "version": "1.0.0", | ||||
|       "private": true, | ||||
|       "workspaces": [ | ||||
|         "packages/*" | ||||
|       ] | ||||
|     }, | ||||
|     "packages/app": { | ||||
|       "name": "workspace-app", | ||||
|       "version": "1.0.0" | ||||
|     }, | ||||
|     "packages/lib": { | ||||
|       "name": "lib", | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "integrity": "sha512-LIB" | ||||
|     }, | ||||
|     "packages/shared": { | ||||
|       "name": "shared", | ||||
|       "version": "3.1.4", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "integrity": "sha512-SHARED" | ||||
|     }, | ||||
|     "packages/app/node_modules/lib": { | ||||
|       "name": "lib", | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.example/lib-2.0.1.tgz", | ||||
|       "integrity": "sha512-LIB" | ||||
|     }, | ||||
|     "packages/app/node_modules/shared": { | ||||
|       "name": "shared", | ||||
|       "version": "3.1.4", | ||||
|       "resolved": "https://registry.example/shared-3.1.4.tgz", | ||||
|       "integrity": "sha512-SHARED" | ||||
|     }, | ||||
|     "packages/app/node_modules/left-pad": { | ||||
|       "name": "left-pad", | ||||
|       "version": "1.3.0", | ||||
|       "resolved": "https://registry.example/left-pad-1.3.0.tgz", | ||||
|       "integrity": "sha512-LEFTPAD" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,10 +1,10 @@ | ||||
| { | ||||
|   "name": "root-workspace", | ||||
|   "version": "1.0.0", | ||||
|   "private": true, | ||||
|   "workspaces": [ | ||||
|     "packages/app", | ||||
|     "packages/lib", | ||||
|     "packages/shared" | ||||
|   ] | ||||
| } | ||||
| { | ||||
|   "name": "root-workspace", | ||||
|   "version": "1.0.0", | ||||
|   "private": true, | ||||
|   "workspaces": [ | ||||
|     "packages/app", | ||||
|     "packages/lib", | ||||
|     "packages/shared" | ||||
|   ] | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
|   "name": "left-pad", | ||||
|   "version": "1.3.0", | ||||
|   "main": "index.js" | ||||
| } | ||||
| { | ||||
|   "name": "left-pad", | ||||
|   "version": "1.3.0", | ||||
|   "main": "index.js" | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
|   "name": "lib", | ||||
|   "version": "2.0.1", | ||||
|   "main": "index.js" | ||||
| } | ||||
| { | ||||
|   "name": "lib", | ||||
|   "version": "2.0.1", | ||||
|   "main": "index.js" | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
|   "name": "shared", | ||||
|   "version": "3.1.4", | ||||
|   "main": "index.js" | ||||
| } | ||||
| { | ||||
|   "name": "shared", | ||||
|   "version": "3.1.4", | ||||
|   "main": "index.js" | ||||
| } | ||||
| @@ -1,11 +1,11 @@ | ||||
| { | ||||
|   "name": "workspace-app", | ||||
|   "version": "1.0.0", | ||||
|   "dependencies": { | ||||
|     "lib": "workspace:../lib", | ||||
|     "shared": "workspace:../shared" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "postinstall": "node scripts/setup.js" | ||||
|   } | ||||
| } | ||||
| { | ||||
|   "name": "workspace-app", | ||||
|   "version": "1.0.0", | ||||
|   "dependencies": { | ||||
|     "lib": "workspace:../lib", | ||||
|     "shared": "workspace:../shared" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "postinstall": "node scripts/setup.js" | ||||
|   } | ||||
| } | ||||
| @@ -1 +1 @@ | ||||
| console.log('setup'); | ||||
| console.log('setup'); | ||||
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|   "name": "lib", | ||||
|   "version": "2.0.1", | ||||
|   "dependencies": { | ||||
|     "left-pad": "1.3.0" | ||||
|   } | ||||
| } | ||||
| { | ||||
|   "name": "lib", | ||||
|   "version": "2.0.1", | ||||
|   "dependencies": { | ||||
|     "left-pad": "1.3.0" | ||||
|   } | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|   "name": "shared", | ||||
|   "version": "3.1.4", | ||||
|   "dependencies": { | ||||
|     "lib": "workspace:../lib" | ||||
|   } | ||||
| } | ||||
| { | ||||
|   "name": "shared", | ||||
|   "version": "3.1.4", | ||||
|   "dependencies": { | ||||
|     "lib": "workspace:../lib" | ||||
|   } | ||||
| } | ||||
| @@ -1,27 +1,27 @@ | ||||
| using StellaOps.Scanner.Analyzers.Lang.Node; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
| 
 | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Tests.Node; | ||||
| 
 | ||||
| public sealed class NodeLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task WorkspaceFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
| 
 | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new NodeLanguageAnalyzer() | ||||
|         }; | ||||
| 
 | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
| } | ||||
| using StellaOps.Scanner.Analyzers.Lang.Node; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
| 
 | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests; | ||||
| 
 | ||||
| public sealed class NodeLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task WorkspaceFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "node", "workspaces"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
| 
 | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new NodeLanguageAnalyzer() | ||||
|         }; | ||||
| 
 | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Remove="Microsoft.NET.Test.Sdk" /> | ||||
|     <PackageReference Remove="xunit" /> | ||||
|     <PackageReference Remove="xunit.runner.visualstudio" /> | ||||
|     <PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" /> | ||||
|     <PackageReference Remove="Mongo2Go" /> | ||||
|     <PackageReference Remove="coverlet.collector" /> | ||||
|     <PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" /> | ||||
|     <ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" /> | ||||
|     <Using Remove="StellaOps.Concelier.Testing" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Analyzers.Lang; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Node; | ||||
|  | ||||
| public sealed class NodeAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Node"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return new NodeLanguageAnalyzer(); | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Node; | ||||
|  | ||||
| internal static class Placeholder | ||||
| { | ||||
|     // Analyzer implementation will be added during Sprint LA1. | ||||
| } | ||||
| @@ -5,6 +5,6 @@ | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-302A | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-307 | Build deterministic module graph walker covering npm, Yarn, and PNPM; capture package.json provenance and integrity metadata. | Walker indexes >100 k modules in <1.5 s (hot cache); golden fixtures verify deterministic ordering and path normalization. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-302B | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-302A | Resolve workspaces/symlinks and attribute components to originating package with usage hints; guard against directory traversal. | Workspace attribution accurate on multi-workspace fixture; symlink resolver proves canonical path; security tests ensure no traversal. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-302C | DONE (2025-10-19) | SCANNER-ANALYZERS-LANG-10-302B | Surface script metadata (postinstall/preinstall) and policy hints; emit telemetry counters and evidence records. | Analyzer output includes script metadata + evidence; metrics `scanner_analyzer_node_scripts_total` recorded; policy hints documented. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307N | TODO | SCANNER-ANALYZERS-LANG-10-302C | Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation. | Reuse shared helpers without duplication; unit tests confirm stable metadata merge; no analyzer-specific serializer drift. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308N | TODO | SCANNER-ANALYZERS-LANG-10-307N | Author determinism harness + fixtures for Node analyzer; add benchmark suite. | Fixtures committed under `Fixtures/lang/node/`; determinism CI job compares JSON snapshots; benchmark CSV published. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309N | TODO | SCANNER-ANALYZERS-LANG-10-308N | Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes). | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer after restart; Offline Kit docs updated. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-302C | Integrate shared helpers for license/licence evidence, canonical JSON serialization, and usage flag propagation. | Reuse shared helpers without duplication; unit tests confirm stable metadata merge; no analyzer-specific serializer drift. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-307N | Author determinism harness + fixtures for Node analyzer; add benchmark suite. | Fixtures committed under `Fixtures/lang/node/`; determinism CI job compares JSON snapshots; benchmark CSV published. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309N | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-308N | Package Node analyzer as restart-time plug-in (manifest, DI registration, Offline Kit notes). | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer after restart; Offline Kit docs updated. | | ||||
|   | ||||
							
								
								
									
										22
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Node/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Node/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| { | ||||
|   "schemaVersion": "1.0", | ||||
|   "id": "stellaops.analyzer.lang.node", | ||||
|   "displayName": "StellaOps Node.js Analyzer", | ||||
|   "version": "0.1.0", | ||||
|   "requiresRestart": true, | ||||
|   "entryPoint": { | ||||
|     "type": "dotnet", | ||||
|     "assembly": "StellaOps.Scanner.Analyzers.Lang.Node.dll", | ||||
|     "typeName": "StellaOps.Scanner.Analyzers.Lang.Node.NodeAnalyzerPlugin" | ||||
|   }, | ||||
|   "capabilities": [ | ||||
|     "language-analyzer", | ||||
|     "node", | ||||
|     "npm" | ||||
|   ], | ||||
|   "metadata": { | ||||
|     "org.stellaops.analyzer.language": "node", | ||||
|     "org.stellaops.analyzer.kind": "language", | ||||
|     "org.stellaops.restart.required": "true" | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,64 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "python", | ||||
|     "componentKey": "purl::pkg:pypi/simple@1.0.0", | ||||
|     "purl": "pkg:pypi/simple@1.0.0", | ||||
|     "name": "simple", | ||||
|     "version": "1.0.0", | ||||
|     "type": "pypi", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "author": "Example Dev", | ||||
|       "authorEmail": "dev@example.com", | ||||
|       "classifiers": "Programming Language :: Python :: 3;License :: OSI Approved :: Apache Software License", | ||||
|       "distInfoPath": "lib/python3.11/site-packages/simple-1.0.0.dist-info", | ||||
|       "editable": "true", | ||||
|       "entryPoints.console_scripts": "simple-tool=simple.core:main", | ||||
|       "homePage": "https://example.com/simple", | ||||
|       "installer": "pip", | ||||
|       "license": "Apache-2.0", | ||||
|       "name": "simple", | ||||
|       "projectUrl": "Source, https://example.com/simple/src", | ||||
|       "record.hashMismatches": "0", | ||||
|       "record.hashedEntries": "9", | ||||
|       "record.ioErrors": "0", | ||||
|       "record.missingFiles": "0", | ||||
|       "record.totalEntries": "10", | ||||
|       "requiresDist": "requests (\u003E=2.0)", | ||||
|       "requiresPython": "\u003E=3.9", | ||||
|       "sourceCommit": "abc123def", | ||||
|       "sourceSubdirectory": "src/simple", | ||||
|       "sourceUrl": "https://example.com/simple-1.0.0.tar.gz", | ||||
|       "sourceVcs": "git", | ||||
|       "summary": "Simple fixture package", | ||||
|       "version": "1.0.0", | ||||
|       "wheel.generator": "pip 24.0", | ||||
|       "wheel.rootIsPurelib": "true", | ||||
|       "wheel.tags": "py3-none-any", | ||||
|       "wheel.version": "1.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "METADATA", | ||||
|         "locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/METADATA" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "WHEEL", | ||||
|         "locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/WHEEL" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "entry_points.txt", | ||||
|         "locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/entry_points.txt" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "direct_url.json", | ||||
|         "locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/direct_url.json", | ||||
|         "value": "https://example.com/simple-1.0.0.tar.gz" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -0,0 +1 @@ | ||||
| pip | ||||
| @@ -0,0 +1,13 @@ | ||||
| Metadata-Version: 2.1 | ||||
| Name: simple | ||||
| Version: 1.0.0 | ||||
| Summary: Simple fixture package | ||||
| Home-page: https://example.com/simple | ||||
| Author: Example Dev | ||||
| Author-email: dev@example.com | ||||
| License: Apache-2.0 | ||||
| Project-URL: Source, https://example.com/simple/src | ||||
| Requires-Python: >=3.9 | ||||
| Requires-Dist: requests (>=2.0) | ||||
| Classifier: Programming Language :: Python :: 3 | ||||
| Classifier: License :: OSI Approved :: Apache Software License | ||||
| @@ -0,0 +1,10 @@ | ||||
| simple/__init__.py,sha256=03NWG/tm5eky+tnGlynp/vcyjtR944EtQMKtwrutl/U=,79 | ||||
| simple/__main__.py,sha256=7pHsIZX9uNTyp1e1AkmZ1vXZxw/dYB1TbR1rYDJca6c=,62 | ||||
| simple/core.py,sha256=8HaF+vPTo2roSP7kivqePnjG+d/WqotH269Qey/BM+s=,67 | ||||
| ../../../bin/simple-tool,sha256=E7eVnffg2E4646m1Ml/5ixyROcpc24GJvy03sEkg6DA=,91 | ||||
| simple-1.0.0.dist-info/METADATA,sha256=Da/AG+nYa85WfbUSNmmRjpTeEEM8Kinf6Z197xb8X2o=,408 | ||||
| simple-1.0.0.dist-info/WHEEL,sha256=m8MHT7vQnqC5W8H/y4uJdEpx9ijH0jJGpuoaxRbcsQg=,79 | ||||
| simple-1.0.0.dist-info/entry_points.txt,sha256=A2WCkblioa0YbdUDurLzv+sIbx7TRSJ9zfBLYMYwpBQ=,49 | ||||
| simple-1.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ+UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg=,4 | ||||
| simple-1.0.0.dist-info/direct_url.json,sha256=EXd4Xj5iohEIqiF7mlR7sCLGhqXiU1/LuOPjijstKCU=,199 | ||||
| simple-1.0.0.dist-info/RECORD,, | ||||
| @@ -0,0 +1,4 @@ | ||||
| Wheel-Version: 1.0 | ||||
| Generator: pip 24.0 | ||||
| Root-Is-Purelib: true | ||||
| Tag: py3-none-any | ||||
| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "url": "https://example.com/simple-1.0.0.tar.gz", | ||||
|   "dir_info": { | ||||
|     "editable": true, | ||||
|     "subdirectory": "src/simple" | ||||
|   }, | ||||
|   "vcs_info": { | ||||
|     "vcs": "git", | ||||
|     "commit_id": "abc123def" | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,2 @@ | ||||
| [console_scripts] | ||||
| simple-tool = simple.core:main | ||||
| @@ -0,0 +1,4 @@ | ||||
| __all__ = ["main"] | ||||
| __version__ = "1.0.0" | ||||
|  | ||||
| from .core import main  # noqa: F401 | ||||
| @@ -0,0 +1,4 @@ | ||||
| from .core import main | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
| @@ -0,0 +1,4 @@ | ||||
| import sys | ||||
|  | ||||
| def main() -> None: | ||||
|     print("simple core", sys.argv) | ||||
| @@ -0,0 +1,33 @@ | ||||
| using StellaOps.Scanner.Analyzers.Lang.Python; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests; | ||||
|  | ||||
| public sealed class PythonLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task SimpleVenvFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "python", "simple-venv"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var usageHints = new LanguageUsageHints(new[] | ||||
|         { | ||||
|             Path.Combine(fixturePath, "bin", "simple-tool") | ||||
|         }); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new PythonLanguageAnalyzer() | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken, | ||||
|             usageHints); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Remove="Microsoft.NET.Test.Sdk" /> | ||||
|     <PackageReference Remove="xunit" /> | ||||
|     <PackageReference Remove="xunit.runner.visualstudio" /> | ||||
|     <PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" /> | ||||
|     <PackageReference Remove="Mongo2Go" /> | ||||
|     <PackageReference Remove="coverlet.collector" /> | ||||
|     <PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" /> | ||||
|     <ProjectReference Remove="..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Remove="$(MSBuildThisFileDirectory)..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" /> | ||||
|     <Using Remove="StellaOps.Concelier.Testing" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Tests\StellaOps.Scanner.Analyzers.Lang.Tests.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Python\StellaOps.Scanner.Analyzers.Lang.Python.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,7 +1,8 @@ | ||||
| global using System; | ||||
| global using System.Collections.Generic; | ||||
| global using System.IO; | ||||
| global using System.Threading; | ||||
| global using System.Threading.Tasks; | ||||
|  | ||||
| global using StellaOps.Scanner.Analyzers.Lang; | ||||
| global using System.Collections.Generic; | ||||
| global using System.IO; | ||||
| global using System.Linq; | ||||
| global using System.Threading; | ||||
| global using System.Threading.Tasks; | ||||
|  | ||||
| global using StellaOps.Scanner.Analyzers.Lang; | ||||
|   | ||||
| @@ -0,0 +1,989 @@ | ||||
| using System.Buffers; | ||||
| using System.Globalization; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal; | ||||
|  | ||||
| internal static class PythonDistributionLoader | ||||
| { | ||||
|  | ||||
|     public static async Task<PythonDistribution?> LoadAsync(LanguageAnalyzerContext context, string distInfoPath, CancellationToken cancellationToken) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(distInfoPath) || !Directory.Exists(distInfoPath)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var metadataPath = Path.Combine(distInfoPath, "METADATA"); | ||||
|         var wheelPath = Path.Combine(distInfoPath, "WHEEL"); | ||||
|         var entryPointsPath = Path.Combine(distInfoPath, "entry_points.txt"); | ||||
|         var recordPath = Path.Combine(distInfoPath, "RECORD"); | ||||
|         var installerPath = Path.Combine(distInfoPath, "INSTALLER"); | ||||
|         var directUrlPath = Path.Combine(distInfoPath, "direct_url.json"); | ||||
|  | ||||
|         var metadataDocument = await PythonMetadataDocument.LoadAsync(metadataPath, cancellationToken).ConfigureAwait(false); | ||||
|         var name = metadataDocument.GetFirst("Name") ?? ExtractNameFromDirectory(distInfoPath); | ||||
|         var version = metadataDocument.GetFirst("Version") ?? ExtractVersionFromDirectory(distInfoPath); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmedName = name.Trim(); | ||||
|         var trimmedVersion = version.Trim(); | ||||
|         var normalizedName = NormalizePackageName(trimmedName); | ||||
|         var purl = $"pkg:pypi/{normalizedName}@{trimmedVersion}"; | ||||
|  | ||||
|         var metadataEntries = new List<KeyValuePair<string, string?>>(); | ||||
|         var evidenceEntries = new List<LanguageComponentEvidence>(); | ||||
|  | ||||
|         AddFileEvidence(context, metadataPath, "METADATA", evidenceEntries); | ||||
|         AddFileEvidence(context, wheelPath, "WHEEL", evidenceEntries); | ||||
|         AddFileEvidence(context, entryPointsPath, "entry_points.txt", evidenceEntries); | ||||
|  | ||||
|         AppendMetadata(metadataEntries, "distInfoPath", PythonPathHelper.NormalizeRelative(context, distInfoPath)); | ||||
|         AppendMetadata(metadataEntries, "name", trimmedName); | ||||
|         AppendMetadata(metadataEntries, "version", trimmedVersion); | ||||
|         AppendMetadata(metadataEntries, "summary", metadataDocument.GetFirst("Summary")); | ||||
|         AppendMetadata(metadataEntries, "license", metadataDocument.GetFirst("License")); | ||||
|         AppendMetadata(metadataEntries, "homePage", metadataDocument.GetFirst("Home-page")); | ||||
|         AppendMetadata(metadataEntries, "author", metadataDocument.GetFirst("Author")); | ||||
|         AppendMetadata(metadataEntries, "authorEmail", metadataDocument.GetFirst("Author-email")); | ||||
|         AppendMetadata(metadataEntries, "projectUrl", metadataDocument.GetFirst("Project-URL")); | ||||
|         AppendMetadata(metadataEntries, "requiresPython", metadataDocument.GetFirst("Requires-Python")); | ||||
|  | ||||
|         var classifiers = metadataDocument.GetAll("Classifier"); | ||||
|         if (classifiers.Count > 0) | ||||
|         { | ||||
|             AppendMetadata(metadataEntries, "classifiers", string.Join(';', classifiers)); | ||||
|         } | ||||
|  | ||||
|         var requiresDist = metadataDocument.GetAll("Requires-Dist"); | ||||
|         if (requiresDist.Count > 0) | ||||
|         { | ||||
|             AppendMetadata(metadataEntries, "requiresDist", string.Join(';', requiresDist)); | ||||
|         } | ||||
|  | ||||
|         var entryPoints = await PythonEntryPointSet.LoadAsync(entryPointsPath, cancellationToken).ConfigureAwait(false); | ||||
|         foreach (var group in entryPoints.Groups.OrderBy(static g => g.Key, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             AppendMetadata(metadataEntries, $"entryPoints.{group.Key}", string.Join(';', group.Value.Select(static ep => $"{ep.Name}={ep.Target}"))); | ||||
|         } | ||||
|  | ||||
|         var wheelInfo = await PythonWheelInfo.LoadAsync(wheelPath, cancellationToken).ConfigureAwait(false); | ||||
|         if (wheelInfo is not null) | ||||
|         { | ||||
|             foreach (var pair in wheelInfo.ToMetadata()) | ||||
|             { | ||||
|                 AppendMetadata(metadataEntries, pair.Key, pair.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var installer = await ReadSingleLineAsync(installerPath, cancellationToken).ConfigureAwait(false); | ||||
|         if (!string.IsNullOrWhiteSpace(installer)) | ||||
|         { | ||||
|             AppendMetadata(metadataEntries, "installer", installer); | ||||
|         } | ||||
|  | ||||
|         var directUrl = await PythonDirectUrlInfo.LoadAsync(directUrlPath, cancellationToken).ConfigureAwait(false); | ||||
|         if (directUrl is not null) | ||||
|         { | ||||
|             foreach (var pair in directUrl.ToMetadata()) | ||||
|             { | ||||
|                 AppendMetadata(metadataEntries, pair.Key, pair.Value); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(directUrl.Url)) | ||||
|             { | ||||
|                 evidenceEntries.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Metadata, | ||||
|                     "direct_url.json", | ||||
|                     PythonPathHelper.NormalizeRelative(context, directUrlPath), | ||||
|                     directUrl.Url, | ||||
|                     Sha256: null)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var recordEntries = await PythonRecordParser.LoadAsync(recordPath, cancellationToken).ConfigureAwait(false); | ||||
|         var verification = await PythonRecordVerifier.VerifyAsync(context, distInfoPath, recordEntries, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         metadataEntries.Add(new KeyValuePair<string, string?>("record.totalEntries", verification.TotalEntries.ToString(CultureInfo.InvariantCulture))); | ||||
|         metadataEntries.Add(new KeyValuePair<string, string?>("record.hashedEntries", verification.HashedEntries.ToString(CultureInfo.InvariantCulture))); | ||||
|         metadataEntries.Add(new KeyValuePair<string, string?>("record.missingFiles", verification.MissingFiles.ToString(CultureInfo.InvariantCulture))); | ||||
|         metadataEntries.Add(new KeyValuePair<string, string?>("record.hashMismatches", verification.HashMismatches.ToString(CultureInfo.InvariantCulture))); | ||||
|         metadataEntries.Add(new KeyValuePair<string, string?>("record.ioErrors", verification.IoErrors.ToString(CultureInfo.InvariantCulture))); | ||||
|  | ||||
|         if (verification.UnsupportedAlgorithms.Count > 0) | ||||
|         { | ||||
|             AppendMetadata(metadataEntries, "record.unsupportedAlgorithms", string.Join(';', verification.UnsupportedAlgorithms)); | ||||
|         } | ||||
|  | ||||
|         evidenceEntries.AddRange(verification.Evidence); | ||||
|         var usedByEntrypoint = verification.UsedByEntrypoint || EvaluateEntryPointUsage(context, distInfoPath, entryPoints); | ||||
|  | ||||
|         return new PythonDistribution( | ||||
|             trimmedName, | ||||
|             trimmedVersion, | ||||
|             purl, | ||||
|             metadataEntries, | ||||
|             evidenceEntries, | ||||
|             usedByEntrypoint); | ||||
|     } | ||||
|  | ||||
|     private static bool EvaluateEntryPointUsage(LanguageAnalyzerContext context, string distInfoPath, PythonEntryPointSet entryPoints) | ||||
|     { | ||||
|         if (entryPoints.Groups.Count == 0) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var parentDirectory = Directory.GetParent(distInfoPath)?.FullName; | ||||
|         if (string.IsNullOrWhiteSpace(parentDirectory)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         foreach (var group in entryPoints.Groups.Values) | ||||
|         { | ||||
|             foreach (var entryPoint in group) | ||||
|             { | ||||
|                 var candidatePaths = entryPoint.GetCandidateRelativeScriptPaths(); | ||||
|                 foreach (var relative in candidatePaths) | ||||
|                 { | ||||
|                     var combined = Path.GetFullPath(Path.Combine(parentDirectory, relative)); | ||||
|                     if (context.UsageHints.IsPathUsed(combined)) | ||||
|                     { | ||||
|                         return true; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static void AddFileEvidence(LanguageAnalyzerContext context, string path, string source, ICollection<LanguageComponentEvidence> evidence) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             source, | ||||
|             PythonPathHelper.NormalizeRelative(context, path), | ||||
|             Value: null, | ||||
|             Sha256: null)); | ||||
|     } | ||||
|  | ||||
|     private static void AppendMetadata(ICollection<KeyValuePair<string, string?>> metadata, string key, string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(key)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         metadata.Add(new KeyValuePair<string, string?>(key, value.Trim())); | ||||
|     } | ||||
|  | ||||
|     private static string? ExtractNameFromDirectory(string distInfoPath) | ||||
|     { | ||||
|         var directoryName = Path.GetFileName(distInfoPath); | ||||
|         if (string.IsNullOrWhiteSpace(directoryName)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var suffixIndex = directoryName.IndexOf(".dist-info", StringComparison.OrdinalIgnoreCase); | ||||
|         if (suffixIndex <= 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = directoryName[..suffixIndex]; | ||||
|         var dashIndex = trimmed.LastIndexOf('-'); | ||||
|         if (dashIndex <= 0) | ||||
|         { | ||||
|             return trimmed; | ||||
|         } | ||||
|  | ||||
|         return trimmed[..dashIndex]; | ||||
|     } | ||||
|  | ||||
|     private static string? ExtractVersionFromDirectory(string distInfoPath) | ||||
|     { | ||||
|         var directoryName = Path.GetFileName(distInfoPath); | ||||
|         if (string.IsNullOrWhiteSpace(directoryName)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var suffixIndex = directoryName.IndexOf(".dist-info", StringComparison.OrdinalIgnoreCase); | ||||
|         if (suffixIndex <= 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = directoryName[..suffixIndex]; | ||||
|         var dashIndex = trimmed.LastIndexOf('-'); | ||||
|         if (dashIndex >= 0 && dashIndex < trimmed.Length - 1) | ||||
|         { | ||||
|             return trimmed[(dashIndex + 1)..]; | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizePackageName(string name) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = new StringBuilder(name.Length); | ||||
|         foreach (var ch in name.Trim().ToLowerInvariant()) | ||||
|         { | ||||
|             builder.Append(ch switch | ||||
|             { | ||||
|                 '_' => '-', | ||||
|                 '.' => '-', | ||||
|                 ' ' => '-', | ||||
|                 _ => ch | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         return builder.ToString(); | ||||
|     } | ||||
|  | ||||
|     private static async Task<string?> ReadSingleLineAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true); | ||||
|         var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); | ||||
|         return line?.Trim(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record PythonDistribution( | ||||
|     string Name, | ||||
|     string Version, | ||||
|     string Purl, | ||||
|     IReadOnlyCollection<KeyValuePair<string, string?>> Metadata, | ||||
|     IReadOnlyCollection<LanguageComponentEvidence> Evidence, | ||||
|     bool UsedByEntrypoint) | ||||
| { | ||||
|     public IReadOnlyCollection<KeyValuePair<string, string?>> SortedMetadata => | ||||
|         Metadata | ||||
|             .OrderBy(static pair => pair.Key, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|     public IReadOnlyCollection<LanguageComponentEvidence> SortedEvidence => | ||||
|         Evidence | ||||
|             .OrderBy(static item => item.Locator, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
| } | ||||
|  | ||||
| internal sealed class PythonMetadataDocument | ||||
| { | ||||
|     private readonly Dictionary<string, List<string>> _values; | ||||
|  | ||||
|     private PythonMetadataDocument(Dictionary<string, List<string>> values) | ||||
|     { | ||||
|         _values = values; | ||||
|     } | ||||
|  | ||||
|     public static async Task<PythonMetadataDocument> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return new PythonMetadataDocument(new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)); | ||||
|         } | ||||
|  | ||||
|         var values = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true); | ||||
|  | ||||
|         string? currentKey = null; | ||||
|         var builder = new StringBuilder(); | ||||
|  | ||||
|         while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (line.Length == 0) | ||||
|             { | ||||
|                 Commit(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith(' ') || line.StartsWith('\t')) | ||||
|             { | ||||
|                 if (currentKey is not null) | ||||
|                 { | ||||
|                     if (builder.Length > 0) | ||||
|                     { | ||||
|                         builder.Append(' '); | ||||
|                     } | ||||
|  | ||||
|                     builder.Append(line.Trim()); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             Commit(); | ||||
|  | ||||
|             var separator = line.IndexOf(':'); | ||||
|             if (separator <= 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             currentKey = line[..separator].Trim(); | ||||
|             builder.Clear(); | ||||
|             builder.Append(line[(separator + 1)..].Trim()); | ||||
|         } | ||||
|  | ||||
|         Commit(); | ||||
|         return new PythonMetadataDocument(values); | ||||
|  | ||||
|         void Commit() | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(currentKey)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (!values.TryGetValue(currentKey, out var list)) | ||||
|             { | ||||
|                 list = new List<string>(); | ||||
|                 values[currentKey] = list; | ||||
|             } | ||||
|  | ||||
|             var value = builder.ToString().Trim(); | ||||
|             if (value.Length > 0) | ||||
|             { | ||||
|                 list.Add(value); | ||||
|             } | ||||
|  | ||||
|             currentKey = null; | ||||
|             builder.Clear(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public string? GetFirst(string key) | ||||
|     { | ||||
|         if (key is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return _values.TryGetValue(key, out var list) && list.Count > 0 | ||||
|             ? list[0] | ||||
|             : null; | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyList<string> GetAll(string key) | ||||
|     { | ||||
|         if (key is null) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         return _values.TryGetValue(key, out var list) | ||||
|             ? list.AsReadOnly() | ||||
|             : Array.Empty<string>(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class PythonWheelInfo | ||||
| { | ||||
|     private readonly Dictionary<string, string> _values; | ||||
|  | ||||
|     private PythonWheelInfo(Dictionary<string, string> values) | ||||
|     { | ||||
|         _values = values; | ||||
|     } | ||||
|  | ||||
|     public static async Task<PythonWheelInfo?> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true); | ||||
|  | ||||
|         while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(line)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var separator = line.IndexOf(':'); | ||||
|             if (separator <= 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var key = line[..separator].Trim(); | ||||
|             var value = line[(separator + 1)..].Trim(); | ||||
|             if (key.Length == 0 || value.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             values[key] = value; | ||||
|         } | ||||
|  | ||||
|         return new PythonWheelInfo(values); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata() | ||||
|     { | ||||
|         var entries = new List<KeyValuePair<string, string?>>(4); | ||||
|  | ||||
|         if (_values.TryGetValue("Wheel-Version", out var wheelVersion)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("wheel.version", wheelVersion)); | ||||
|         } | ||||
|  | ||||
|         if (_values.TryGetValue("Tag", out var tags)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("wheel.tags", tags)); | ||||
|         } | ||||
|  | ||||
|         if (_values.TryGetValue("Root-Is-Purelib", out var purelib)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("wheel.rootIsPurelib", purelib)); | ||||
|         } | ||||
|  | ||||
|         if (_values.TryGetValue("Generator", out var generator)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("wheel.generator", generator)); | ||||
|         } | ||||
|  | ||||
|         return entries; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class PythonEntryPointSet | ||||
| { | ||||
|     public IReadOnlyDictionary<string, IReadOnlyList<PythonEntryPoint>> Groups { get; } | ||||
|  | ||||
|     private PythonEntryPointSet(Dictionary<string, IReadOnlyList<PythonEntryPoint>> groups) | ||||
|     { | ||||
|         Groups = groups; | ||||
|     } | ||||
|  | ||||
|     public static async Task<PythonEntryPointSet> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return new PythonEntryPointSet(new Dictionary<string, IReadOnlyList<PythonEntryPoint>>(StringComparer.OrdinalIgnoreCase)); | ||||
|         } | ||||
|  | ||||
|         var groups = new Dictionary<string, List<PythonEntryPoint>>(StringComparer.OrdinalIgnoreCase); | ||||
|         string? currentGroup = null; | ||||
|  | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true); | ||||
|  | ||||
|         while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             line = line.Trim(); | ||||
|             if (line.Length == 0 || line.StartsWith('#')) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (line.StartsWith('[') && line.EndsWith(']')) | ||||
|             { | ||||
|                 currentGroup = line[1..^1].Trim(); | ||||
|                 if (currentGroup.Length == 0) | ||||
|                 { | ||||
|                     currentGroup = null; | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (currentGroup is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var separator = line.IndexOf('='); | ||||
|             if (separator <= 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var name = line[..separator].Trim(); | ||||
|             var target = line[(separator + 1)..].Trim(); | ||||
|             if (name.Length == 0 || target.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!groups.TryGetValue(currentGroup, out var list)) | ||||
|             { | ||||
|                 list = new List<PythonEntryPoint>(); | ||||
|                 groups[currentGroup] = list; | ||||
|             } | ||||
|  | ||||
|             list.Add(new PythonEntryPoint(name, target)); | ||||
|         } | ||||
|  | ||||
|         return new PythonEntryPointSet(groups.ToDictionary( | ||||
|             static pair => pair.Key, | ||||
|             static pair => (IReadOnlyList<PythonEntryPoint>)pair.Value.AsReadOnly(), | ||||
|             StringComparer.OrdinalIgnoreCase)); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record PythonEntryPoint(string Name, string Target) | ||||
| { | ||||
|     public IReadOnlyCollection<string> GetCandidateRelativeScriptPaths() | ||||
|     { | ||||
|         var list = new List<string>(3) | ||||
|         { | ||||
|             Path.Combine("bin", Name), | ||||
|             Path.Combine("Scripts", $"{Name}.exe"), | ||||
|             Path.Combine("Scripts", Name) | ||||
|         }; | ||||
|  | ||||
|         return list; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record PythonRecordEntry(string Path, string? HashAlgorithm, string? HashValue, long? Size); | ||||
|  | ||||
| internal static class PythonRecordParser | ||||
| { | ||||
|     public static async Task<IReadOnlyList<PythonRecordEntry>> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return Array.Empty<PythonRecordEntry>(); | ||||
|         } | ||||
|  | ||||
|         var entries = new List<PythonRecordEntry>(); | ||||
|  | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream, PythonEncoding.Utf8, detectEncodingFromByteOrderMarks: true); | ||||
|  | ||||
|         while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (line.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var fields = ParseCsvLine(line); | ||||
|             if (fields.Count < 1) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var entryPath = fields[0]; | ||||
|             string? algorithm = null; | ||||
|             string? hashValue = null; | ||||
|  | ||||
|             if (fields.Count > 1 && !string.IsNullOrWhiteSpace(fields[1])) | ||||
|             { | ||||
|                 var hashField = fields[1].Trim(); | ||||
|                 var separator = hashField.IndexOf('='); | ||||
|                 if (separator > 0 && separator < hashField.Length - 1) | ||||
|                 { | ||||
|                     algorithm = hashField[..separator]; | ||||
|                     hashValue = hashField[(separator + 1)..]; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             long? size = null; | ||||
|             if (fields.Count > 2 && long.TryParse(fields[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)) | ||||
|             { | ||||
|                 size = parsedSize; | ||||
|             } | ||||
|  | ||||
|             entries.Add(new PythonRecordEntry(entryPath, algorithm, hashValue, size)); | ||||
|         } | ||||
|  | ||||
|         return entries; | ||||
|     } | ||||
|  | ||||
|     private static List<string> ParseCsvLine(string line) | ||||
|     { | ||||
|         var values = new List<string>(); | ||||
|         var builder = new StringBuilder(); | ||||
|         var inQuotes = false; | ||||
|  | ||||
|         for (var i = 0; i < line.Length; i++) | ||||
|         { | ||||
|             var ch = line[i]; | ||||
|  | ||||
|             if (inQuotes) | ||||
|             { | ||||
|                 if (ch == '"') | ||||
|                 { | ||||
|                     var next = i + 1 < line.Length ? line[i + 1] : '\0'; | ||||
|                     if (next == '"') | ||||
|                     { | ||||
|                         builder.Append('"'); | ||||
|                         i++; | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         inQuotes = false; | ||||
|                     } | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     builder.Append(ch); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == ',') | ||||
|             { | ||||
|                 values.Add(builder.ToString()); | ||||
|                 builder.Clear(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == '"') | ||||
|             { | ||||
|                 inQuotes = true; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             builder.Append(ch); | ||||
|         } | ||||
|  | ||||
|         values.Add(builder.ToString()); | ||||
|         return values; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class PythonRecordVerificationResult | ||||
| { | ||||
|     public PythonRecordVerificationResult( | ||||
|         int totalEntries, | ||||
|         int hashedEntries, | ||||
|         int missingFiles, | ||||
|         int hashMismatches, | ||||
|         int ioErrors, | ||||
|         bool usedByEntrypoint, | ||||
|         IReadOnlyCollection<string> unsupportedAlgorithms, | ||||
|         IReadOnlyCollection<LanguageComponentEvidence> evidence) | ||||
|     { | ||||
|         TotalEntries = totalEntries; | ||||
|         HashedEntries = hashedEntries; | ||||
|         MissingFiles = missingFiles; | ||||
|         HashMismatches = hashMismatches; | ||||
|         IoErrors = ioErrors; | ||||
|         UsedByEntrypoint = usedByEntrypoint; | ||||
|         UnsupportedAlgorithms = unsupportedAlgorithms; | ||||
|         Evidence = evidence; | ||||
|     } | ||||
|  | ||||
|     public int TotalEntries { get; } | ||||
|     public int HashedEntries { get; } | ||||
|     public int MissingFiles { get; } | ||||
|     public int HashMismatches { get; } | ||||
|     public int IoErrors { get; } | ||||
|     public bool UsedByEntrypoint { get; } | ||||
|     public IReadOnlyCollection<string> UnsupportedAlgorithms { get; } | ||||
|     public IReadOnlyCollection<LanguageComponentEvidence> Evidence { get; } | ||||
| } | ||||
|  | ||||
| internal static class PythonRecordVerifier | ||||
| { | ||||
|     private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|         "sha256" | ||||
|     }; | ||||
|  | ||||
|     public static async Task<PythonRecordVerificationResult> VerifyAsync( | ||||
|         LanguageAnalyzerContext context, | ||||
|         string distInfoPath, | ||||
|         IReadOnlyList<PythonRecordEntry> entries, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (entries.Count == 0) | ||||
|         { | ||||
|             return new PythonRecordVerificationResult(0, 0, 0, 0, 0, usedByEntrypoint: false, Array.Empty<string>(), Array.Empty<LanguageComponentEvidence>()); | ||||
|         } | ||||
|  | ||||
|         var evidence = new List<LanguageComponentEvidence>(); | ||||
|         var unsupported = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         var root = context.RootPath; | ||||
|         if (!root.EndsWith(Path.DirectorySeparatorChar)) | ||||
|         { | ||||
|             root += Path.DirectorySeparatorChar; | ||||
|         } | ||||
|  | ||||
|         var parent = Directory.GetParent(distInfoPath)?.FullName ?? distInfoPath; | ||||
|  | ||||
|         var total = 0; | ||||
|         var hashed = 0; | ||||
|         var missing = 0; | ||||
|         var mismatched = 0; | ||||
|         var ioErrors = 0; | ||||
|         var usedByEntrypoint = false; | ||||
|  | ||||
|         foreach (var entry in entries) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             total++; | ||||
|  | ||||
|             var entryPath = entry.Path.Replace('/', Path.DirectorySeparatorChar); | ||||
|             var fullPath = Path.GetFullPath(Path.Combine(parent, entryPath)); | ||||
|  | ||||
|             if (!fullPath.StartsWith(root, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 missing++; | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Derived, | ||||
|                     "RECORD", | ||||
|                     PythonPathHelper.NormalizeRelative(context, fullPath), | ||||
|                     "outside-root", | ||||
|                     Sha256: null)); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!File.Exists(fullPath)) | ||||
|             { | ||||
|                 missing++; | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Derived, | ||||
|                     "RECORD", | ||||
|                     PythonPathHelper.NormalizeRelative(context, fullPath), | ||||
|                     "missing", | ||||
|                     Sha256: null)); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (context.UsageHints.IsPathUsed(fullPath)) | ||||
|             { | ||||
|                 usedByEntrypoint = true; | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(entry.HashAlgorithm) || string.IsNullOrWhiteSpace(entry.HashValue)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             hashed++; | ||||
|  | ||||
|             if (!SupportedAlgorithms.Contains(entry.HashAlgorithm)) | ||||
|             { | ||||
|                 unsupported.Add(entry.HashAlgorithm); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             string? actualHash = null; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 actualHash = await ComputeSha256Base64Async(fullPath, cancellationToken).ConfigureAwait(false); | ||||
|             } | ||||
|             catch (IOException) | ||||
|             { | ||||
|                 ioErrors++; | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Derived, | ||||
|                     "RECORD", | ||||
|                     PythonPathHelper.NormalizeRelative(context, fullPath), | ||||
|                     "io-error", | ||||
|                     Sha256: null)); | ||||
|                 continue; | ||||
|             } | ||||
|             catch (UnauthorizedAccessException) | ||||
|             { | ||||
|                 ioErrors++; | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Derived, | ||||
|                     "RECORD", | ||||
|                     PythonPathHelper.NormalizeRelative(context, fullPath), | ||||
|                     "access-denied", | ||||
|                     Sha256: null)); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (actualHash is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(actualHash, entry.HashValue, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 mismatched++; | ||||
|                 evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.Derived, | ||||
|                     "RECORD", | ||||
|                     PythonPathHelper.NormalizeRelative(context, fullPath), | ||||
|                     $"sha256 mismatch expected={entry.HashValue} actual={actualHash}", | ||||
|                     Sha256: actualHash)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new PythonRecordVerificationResult( | ||||
|             total, | ||||
|             hashed, | ||||
|             missing, | ||||
|             mismatched, | ||||
|             ioErrors, | ||||
|             usedByEntrypoint, | ||||
|             unsupported.ToArray(), | ||||
|             evidence); | ||||
|     } | ||||
|  | ||||
|     private static async Task<string> ComputeSha256Base64Async(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|  | ||||
|         using var sha = SHA256.Create(); | ||||
|         var buffer = ArrayPool<byte>.Shared.Rent(81920); | ||||
|         try | ||||
|         { | ||||
|             int bytesRead; | ||||
|             while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) | ||||
|             { | ||||
|                 sha.TransformBlock(buffer, 0, bytesRead, null, 0); | ||||
|             } | ||||
|  | ||||
|             sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0); | ||||
|             return Convert.ToBase64String(sha.Hash ?? Array.Empty<byte>()); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             ArrayPool<byte>.Shared.Return(buffer); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class PythonDirectUrlInfo | ||||
| { | ||||
|     public string? Url { get; } | ||||
|     public bool IsEditable { get; } | ||||
|     public string? Subdirectory { get; } | ||||
|     public string? Vcs { get; } | ||||
|     public string? Commit { get; } | ||||
|  | ||||
|     private PythonDirectUrlInfo(string? url, bool isEditable, string? subdirectory, string? vcs, string? commit) | ||||
|     { | ||||
|         Url = url; | ||||
|         IsEditable = isEditable; | ||||
|         Subdirectory = subdirectory; | ||||
|         Vcs = vcs; | ||||
|         Commit = commit; | ||||
|     } | ||||
|  | ||||
|     public static async Task<PythonDirectUrlInfo?> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         var root = document.RootElement; | ||||
|  | ||||
|         var url = root.TryGetProperty("url", out var urlElement) ? urlElement.GetString() : null; | ||||
|         var isEditable = root.TryGetProperty("dir_info", out var dirInfo) && dirInfo.TryGetProperty("editable", out var editableValue) && editableValue.GetBoolean(); | ||||
|         var subdir = root.TryGetProperty("dir_info", out dirInfo) && dirInfo.TryGetProperty("subdirectory", out var subdirElement) ? subdirElement.GetString() : null; | ||||
|  | ||||
|         string? vcs = null; | ||||
|         string? commit = null; | ||||
|  | ||||
|         if (root.TryGetProperty("vcs_info", out var vcsInfo)) | ||||
|         { | ||||
|             vcs = vcsInfo.TryGetProperty("vcs", out var vcsElement) ? vcsElement.GetString() : null; | ||||
|             commit = vcsInfo.TryGetProperty("commit_id", out var commitElement) ? commitElement.GetString() : null; | ||||
|         } | ||||
|  | ||||
|         return new PythonDirectUrlInfo(url, isEditable, subdir, vcs, commit); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata() | ||||
|     { | ||||
|         var entries = new List<KeyValuePair<string, string?>>(); | ||||
|  | ||||
|         if (IsEditable) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("editable", "true")); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(Url)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("sourceUrl", Url)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(Subdirectory)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("sourceSubdirectory", Subdirectory)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(Vcs)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("sourceVcs", Vcs)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(Commit)) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, string?>("sourceCommit", Commit)); | ||||
|         } | ||||
|  | ||||
|         return entries; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class PythonPathHelper | ||||
| { | ||||
|     public static string NormalizeRelative(LanguageAnalyzerContext context, string path) | ||||
|     { | ||||
|         var relative = context.GetRelativePath(path); | ||||
|         if (string.IsNullOrEmpty(relative) || relative == ".") | ||||
|         { | ||||
|             return "."; | ||||
|         } | ||||
|  | ||||
|         return relative; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class PythonEncoding | ||||
| { | ||||
|     public static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Python; | ||||
|  | ||||
| internal static class Placeholder | ||||
| { | ||||
|     // Analyzer implementation will be added during Sprint LA2. | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Python; | ||||
|  | ||||
| public sealed class PythonAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Python"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return new PythonLanguageAnalyzer(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,72 @@ | ||||
| using System.Text.Json; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Python.Internal; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Python; | ||||
|  | ||||
| public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer | ||||
| { | ||||
|     private static readonly EnumerationOptions Enumeration = new() | ||||
|     { | ||||
|         RecurseSubdirectories = true, | ||||
|         IgnoreInaccessible = true, | ||||
|         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint | ||||
|     }; | ||||
|  | ||||
|     public string Id => "python"; | ||||
|  | ||||
|     public string DisplayName => "Python Analyzer"; | ||||
|  | ||||
|     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         ArgumentNullException.ThrowIfNull(writer); | ||||
|  | ||||
|         return AnalyzeInternalAsync(context, writer, cancellationToken); | ||||
|     } | ||||
|  | ||||
|     private static async ValueTask AnalyzeInternalAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var distInfoDirectories = Directory | ||||
|             .EnumerateDirectories(context.RootPath, "*.dist-info", Enumeration) | ||||
|             .OrderBy(static path => path, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         foreach (var distInfoPath in distInfoDirectories) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             PythonDistribution? distribution; | ||||
|             try | ||||
|             { | ||||
|                 distribution = await PythonDistributionLoader.LoadAsync(context, distInfoPath, cancellationToken).ConfigureAwait(false); | ||||
|             } | ||||
|             catch (IOException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|             catch (JsonException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|             catch (UnauthorizedAccessException) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (distribution is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             writer.AddFromPurl( | ||||
|                 analyzerId: "python", | ||||
|                 purl: distribution.Purl, | ||||
|                 name: distribution.Name, | ||||
|                 version: distribution.Version, | ||||
|                 type: "pypi", | ||||
|                 metadata: distribution.SortedMetadata, | ||||
|                 evidence: distribution.SortedEvidence, | ||||
|                 usedByEntrypoint: distribution.UsedByEntrypoint); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,9 +2,9 @@ | ||||
|  | ||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-303A | TODO | SCANNER-ANALYZERS-LANG-10-307 | STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture. | Parser handles CPython 3.8–3.12 metadata variations; fixtures confirm canonical ordering and UTF-8 handling. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-303B | TODO | SCANNER-ANALYZERS-LANG-10-303A | RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics. | Verifier processes 5 GB RECORD fixture without allocations >2 MB; mismatches produce deterministic evidence records. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-303C | TODO | SCANNER-ANALYZERS-LANG-10-303B | Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags. | Editable installs resolved to source path; usage flags propagated; regression tests cover mixed editable + wheel installs. | | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-303A | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-307 | STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture. | Parser handles CPython 3.8–3.12 metadata variations; fixtures confirm canonical ordering and UTF-8 handling. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-303B | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303A | RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics. | Verifier processes 5 GB RECORD fixture without allocations >2 MB; mismatches produce deterministic evidence records. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-303C | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303B | Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags. | Editable installs resolved to source path; usage flags propagated; regression tests cover mixed editable + wheel installs. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307P | TODO | SCANNER-ANALYZERS-LANG-10-303C | Shared helper integration (license metadata, quiet provenance, component merging). | Shared helpers reused; analyzer-specific metadata minimal; deterministic merge tests pass. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308P | TODO | SCANNER-ANALYZERS-LANG-10-307P | Golden fixtures + determinism harness for Python analyzer; add benchmark and hash throughput reporting. | Fixtures under `Fixtures/lang/python/`; determinism CI guard; benchmark CSV added with threshold alerts. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309P | TODO | SCANNER-ANALYZERS-LANG-10-308P | Package plug-in (manifest, DI registration) and document Offline Kit bundling of Python stdlib metadata if needed. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. | | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "schemaVersion": "1.0", | ||||
|   "id": "stellaops.analyzer.lang.python", | ||||
|   "displayName": "StellaOps Python Analyzer (preview)", | ||||
|   "version": "0.1.0", | ||||
|   "requiresRestart": true, | ||||
|   "entryPoint": { | ||||
|     "type": "dotnet", | ||||
|     "assembly": "StellaOps.Scanner.Analyzers.Lang.Python.dll", | ||||
|     "typeName": "StellaOps.Scanner.Analyzers.Lang.Python.PythonAnalyzerPlugin" | ||||
|   }, | ||||
|   "capabilities": [ | ||||
|     "language-analyzer", | ||||
|     "python", | ||||
|     "pypi" | ||||
|   ], | ||||
|   "metadata": { | ||||
|     "org.stellaops.analyzer.language": "python", | ||||
|     "org.stellaops.analyzer.kind": "language", | ||||
|     "org.stellaops.restart.required": "true", | ||||
|     "org.stellaops.analyzer.status": "preview" | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; | ||||
|  | ||||
| public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => false; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return new RustLanguageAnalyzer(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; | ||||
|  | ||||
| public sealed class RustLanguageAnalyzer : ILanguageAnalyzer | ||||
| { | ||||
|     public string Id => "rust"; | ||||
|  | ||||
|     public string DisplayName => "Rust Analyzer (preview)"; | ||||
|  | ||||
|     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|         => ValueTask.FromException(new NotImplementedException("Rust analyzer implementation pending Sprint LA5.")); | ||||
| } | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "schemaVersion": "1.0", | ||||
|   "id": "stellaops.analyzer.lang.rust", | ||||
|   "displayName": "StellaOps Rust Analyzer (preview)", | ||||
|   "version": "0.1.0", | ||||
|   "requiresRestart": true, | ||||
|   "entryPoint": { | ||||
|     "type": "dotnet", | ||||
|     "assembly": "StellaOps.Scanner.Analyzers.Lang.Rust.dll", | ||||
|     "typeName": "StellaOps.Scanner.Analyzers.Lang.Rust.RustAnalyzerPlugin" | ||||
|   }, | ||||
|   "capabilities": [ | ||||
|     "language-analyzer", | ||||
|     "rust", | ||||
|     "cargo" | ||||
|   ], | ||||
|   "metadata": { | ||||
|     "org.stellaops.analyzer.language": "rust", | ||||
|     "org.stellaops.analyzer.kind": "language", | ||||
|     "org.stellaops.restart.required": "true", | ||||
|     "org.stellaops.analyzer.status": "preview" | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| using System.IO; | ||||
| using StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Tests.DotNet; | ||||
|  | ||||
| public sealed class DotNetLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task SimpleFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "simple"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new DotNetLanguageAnalyzer() | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,73 @@ | ||||
| { | ||||
|   "runtimeTarget": { | ||||
|     "name": ".NETCoreApp,Version=v10.0/linux-x64" | ||||
|   }, | ||||
|   "targets": { | ||||
|     ".NETCoreApp,Version=v10.0": { | ||||
|       "Sample.App/1.0.0": { | ||||
|         "dependencies": { | ||||
|           "StellaOps.Toolkit": "1.2.3", | ||||
|           "Microsoft.Extensions.Logging": "9.0.0" | ||||
|         } | ||||
|       }, | ||||
|       "StellaOps.Toolkit/1.2.3": { | ||||
|         "dependencies": { | ||||
|           "Microsoft.Extensions.Logging": "9.0.0" | ||||
|         }, | ||||
|         "runtime": { | ||||
|           "lib/net10.0/StellaOps.Toolkit.dll": { | ||||
|             "assemblyVersion": "1.2.3.0", | ||||
|             "fileVersion": "1.2.3.0" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "Microsoft.Extensions.Logging/9.0.0": { | ||||
|         "runtime": { | ||||
|           "lib/net9.0/Microsoft.Extensions.Logging.dll": { | ||||
|             "assemblyVersion": "9.0.0.0", | ||||
|             "fileVersion": "9.0.24.52809" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     ".NETCoreApp,Version=v10.0/linux-x64": { | ||||
|       "StellaOps.Toolkit/1.2.3": { | ||||
|         "runtime": { | ||||
|           "runtimes/linux-x64/native/libstellaops.toolkit.so": {} | ||||
|         } | ||||
|       }, | ||||
|       "Microsoft.Extensions.Logging/9.0.0": { | ||||
|         "runtime": { | ||||
|           "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll": {} | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     ".NETCoreApp,Version=v10.0/win-x86": { | ||||
|       "Microsoft.Extensions.Logging/9.0.0": { | ||||
|         "runtime": { | ||||
|           "runtimes/win-x86/lib/net9.0/Microsoft.Extensions.Logging.dll": {} | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "libraries": { | ||||
|     "Sample.App/1.0.0": { | ||||
|       "type": "project", | ||||
|       "serviceable": false | ||||
|     }, | ||||
|     "StellaOps.Toolkit/1.2.3": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|       "sha512": "sha512-FAKE_TOOLKIT_SHA==", | ||||
|       "path": "stellaops.toolkit/1.2.3", | ||||
|       "hashPath": "stellaops.toolkit.1.2.3.nupkg.sha512" | ||||
|     }, | ||||
|     "Microsoft.Extensions.Logging/9.0.0": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|       "sha512": "sha512-FAKE_LOGGING_SHA==", | ||||
|       "path": "microsoft.extensions.logging/9.0.0", | ||||
|       "hashPath": "microsoft.extensions.logging.9.0.0.nupkg.sha512" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| { | ||||
|   "runtimeOptions": { | ||||
|     "tfm": "net10.0", | ||||
|     "framework": { | ||||
|       "name": "Microsoft.NETCore.App", | ||||
|       "version": "10.0.0" | ||||
|     }, | ||||
|     "frameworks": [ | ||||
|       { | ||||
|         "name": "Microsoft.NETCore.App", | ||||
|         "version": "10.0.0" | ||||
|       }, | ||||
|       { | ||||
|         "name": "Microsoft.AspNetCore.App", | ||||
|         "version": "10.0.0" | ||||
|       } | ||||
|     ], | ||||
|     "runtimeGraph": { | ||||
|       "runtimes": { | ||||
|         "linux-x64": { | ||||
|           "fallbacks": [ | ||||
|             "linux", | ||||
|             "unix" | ||||
|           ] | ||||
|         }, | ||||
|         "win-x86": { | ||||
|           "fallbacks": [ | ||||
|             "win", | ||||
|             "any" | ||||
|           ] | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,62 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "dotnet", | ||||
|     "componentKey": "purl::pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||
|     "purl": "pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||
|     "name": "Microsoft.Extensions.Logging", | ||||
|     "version": "9.0.0", | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "deps.path[0]": "Sample.App.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.rid[1]": "win-x86", | ||||
|       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "package.hashPath[0]": "microsoft.extensions.logging.9.0.0.nupkg.sha512", | ||||
|       "package.id": "Microsoft.Extensions.Logging", | ||||
|       "package.id.normalized": "microsoft.extensions.logging", | ||||
|       "package.path[0]": "microsoft.extensions.logging/9.0.0", | ||||
|       "package.serviceable": "true", | ||||
|       "package.sha512[0]": "sha512-FAKE_LOGGING_SHA==", | ||||
|       "package.version": "9.0.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "deps.json", | ||||
|         "locator": "Sample.App.deps.json", | ||||
|         "value": "Microsoft.Extensions.Logging/9.0.0" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "dotnet", | ||||
|     "componentKey": "purl::pkg:nuget/stellaops.toolkit@1.2.3", | ||||
|     "purl": "pkg:nuget/stellaops.toolkit@1.2.3", | ||||
|     "name": "StellaOps.Toolkit", | ||||
|     "version": "1.2.3", | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "deps.dependency[0]": "microsoft.extensions.logging", | ||||
|       "deps.path[0]": "Sample.App.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "package.hashPath[0]": "stellaops.toolkit.1.2.3.nupkg.sha512", | ||||
|       "package.id": "StellaOps.Toolkit", | ||||
|       "package.id.normalized": "stellaops.toolkit", | ||||
|       "package.path[0]": "stellaops.toolkit/1.2.3", | ||||
|       "package.serviceable": "true", | ||||
|       "package.sha512[0]": "sha512-FAKE_TOOLKIT_SHA==", | ||||
|       "package.version": "1.2.3" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "deps.json", | ||||
|         "locator": "Sample.App.deps.json", | ||||
|         "value": "StellaOps.Toolkit/1.2.3" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -29,12 +29,11 @@ | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.DotNet\StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   | ||||
| @@ -35,17 +35,18 @@ public static class TestPaths | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static string ResolveProjectRoot() | ||||
|     { | ||||
|         var directory = AppContext.BaseDirectory; | ||||
|         while (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             if (File.Exists(Path.Combine(directory, "StellaOps.Scanner.Analyzers.Lang.Tests.csproj"))) | ||||
|             { | ||||
|                 return directory; | ||||
|             } | ||||
|  | ||||
|             directory = Path.GetDirectoryName(directory) ?? string.Empty; | ||||
|     public static string ResolveProjectRoot() | ||||
|     { | ||||
|         var directory = AppContext.BaseDirectory; | ||||
|         while (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             var matches = Directory.EnumerateFiles(directory, "StellaOps.Scanner.Analyzers.Lang*.Tests.csproj", SearchOption.TopDirectoryOnly); | ||||
|             if (matches.Any()) | ||||
|             { | ||||
|                 return directory; | ||||
|             } | ||||
|  | ||||
|             directory = Path.GetDirectoryName(directory) ?? string.Empty; | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException("Unable to locate project root."); | ||||
|   | ||||
| @@ -0,0 +1,15 @@ | ||||
| using System; | ||||
| using StellaOps.Plugin; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a restart-time plug-in that exposes a language analyzer. | ||||
| /// </summary> | ||||
| public interface ILanguageAnalyzerPlugin : IAvailabilityPlugin | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Creates the analyzer instance bound to the service provider. | ||||
|     /// </summary> | ||||
|     ILanguageAnalyzer CreateAnalyzer(IServiceProvider services); | ||||
| } | ||||
| @@ -0,0 +1,147 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Plugin; | ||||
| using StellaOps.Plugin.Hosting; | ||||
| using StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Plugin; | ||||
|  | ||||
| public interface ILanguageAnalyzerPluginCatalog | ||||
| { | ||||
|     IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins { get; } | ||||
|  | ||||
|     void LoadFromDirectory(string directory, bool seal = true); | ||||
|  | ||||
|     IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services); | ||||
| } | ||||
|  | ||||
| public sealed class LanguageAnalyzerPluginCatalog : ILanguageAnalyzerPluginCatalog | ||||
| { | ||||
|     private readonly ILogger<LanguageAnalyzerPluginCatalog> _logger; | ||||
|     private readonly IPluginCatalogGuard _guard; | ||||
|     private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private IReadOnlyList<ILanguageAnalyzerPlugin> _plugins = Array.Empty<ILanguageAnalyzerPlugin>(); | ||||
|  | ||||
|     public LanguageAnalyzerPluginCatalog(IPluginCatalogGuard guard, ILogger<LanguageAnalyzerPluginCatalog> logger) | ||||
|     { | ||||
|         _guard = guard ?? throw new ArgumentNullException(nameof(guard)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => _plugins; | ||||
|  | ||||
|     public void LoadFromDirectory(string directory, bool seal = true) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(directory); | ||||
|         var fullDirectory = Path.GetFullPath(directory); | ||||
|  | ||||
|         var options = new PluginHostOptions | ||||
|         { | ||||
|             PluginsDirectory = fullDirectory, | ||||
|             EnsureDirectoryExists = false, | ||||
|             RecursiveSearch = false, | ||||
|         }; | ||||
|         options.SearchPatterns.Add("StellaOps.Scanner.Analyzers.*.dll"); | ||||
|  | ||||
|         var result = PluginHost.LoadPlugins(options, _logger); | ||||
|         if (result.Plugins.Count == 0) | ||||
|         { | ||||
|             _logger.LogWarning("No language analyzer plug-ins discovered under '{Directory}'.", fullDirectory); | ||||
|         } | ||||
|  | ||||
|         foreach (var descriptor in result.Plugins) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 _guard.EnsureRegistrationAllowed(descriptor.AssemblyPath); | ||||
|                 _assemblies[descriptor.AssemblyPath] = descriptor.Assembly; | ||||
|                 _logger.LogInformation( | ||||
|                     "Registered language analyzer plug-in assembly '{Assembly}' from '{Path}'.", | ||||
|                     descriptor.Assembly.FullName, | ||||
|                     descriptor.AssemblyPath); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Failed to register language analyzer plug-in '{Path}'.", descriptor.AssemblyPath); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         RefreshPluginList(); | ||||
|  | ||||
|         if (seal) | ||||
|         { | ||||
|             _guard.Seal(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|  | ||||
|         if (_plugins.Count == 0) | ||||
|         { | ||||
|             _logger.LogWarning("No language analyzer plug-ins available; skipping language analysis."); | ||||
|             return Array.Empty<ILanguageAnalyzer>(); | ||||
|         } | ||||
|  | ||||
|         var analyzers = new List<ILanguageAnalyzer>(_plugins.Count); | ||||
|  | ||||
|         foreach (var plugin in _plugins) | ||||
|         { | ||||
|             if (!IsPluginAvailable(plugin, services)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var analyzer = plugin.CreateAnalyzer(services); | ||||
|                 if (analyzer is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 analyzers.Add(analyzer); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Language analyzer plug-in '{Plugin}' failed to create analyzer instance.", plugin.Name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (analyzers.Count == 0) | ||||
|         { | ||||
|             _logger.LogWarning("All language analyzer plug-ins were unavailable."); | ||||
|             return Array.Empty<ILanguageAnalyzer>(); | ||||
|         } | ||||
|  | ||||
|         analyzers.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id)); | ||||
|         return new ReadOnlyCollection<ILanguageAnalyzer>(analyzers); | ||||
|     } | ||||
|  | ||||
|     private void RefreshPluginList() | ||||
|     { | ||||
|         var assemblies = _assemblies.Values.ToArray(); | ||||
|         var plugins = PluginLoader.LoadPlugins<ILanguageAnalyzerPlugin>(assemblies); | ||||
|         _plugins = plugins is IReadOnlyList<ILanguageAnalyzerPlugin> list | ||||
|             ? list | ||||
|             : new ReadOnlyCollection<ILanguageAnalyzerPlugin>(plugins.ToArray()); | ||||
|     } | ||||
|  | ||||
|     private static bool IsPluginAvailable(ILanguageAnalyzerPlugin plugin, IServiceProvider services) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             return plugin.IsAvailable(services); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -20,7 +20,7 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana | ||||
|   - `Fixtures/lang/node/**` golden outputs. | ||||
|   - Analyzer benchmark CSV + flamegraph (commit under `bench/Scanner.Analyzers`). | ||||
|   - Worker integration sample enabling Node analyzer via manifest. | ||||
| - **Progress (2025-10-19):** Module walker with package-lock/yarn/pnpm resolution, workspace attribution, integrity metadata, and deterministic fixture harness committed; Node tasks 10-302A/B marked DONE. Shared component mapper + canonical result harness landed, closing tasks 10-307/308. Script metadata & telemetry (10-302C) emit policy hints, hashed evidence, and feed `scanner_analyzer_node_scripts_total` into Worker OpenTelemetry pipeline. | ||||
| - **Progress (2025-10-21):** Module walker with package-lock/yarn/pnpm resolution, workspace attribution, integrity metadata, and deterministic fixture harness committed; Node tasks 10-302A/B remain green. Shared component mapper + canonical result harness landed, closing tasks 10-307/308. Script metadata & telemetry (10-302C) emit policy hints, hashed evidence, and feed `scanner_analyzer_node_scripts_total` into Worker OpenTelemetry pipeline. Restart-time packaging closed (10-309): manifest added, Worker language catalog loads the Node analyzer, integration tests cover dispatch + layer fragments, and Offline Kit docs call out bundled language plug-ins. | ||||
|  | ||||
| ## Sprint LA2 — Python Analyzer & Entry Point Attribution (Tasks 10-303, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Parse `*.dist-info`, `RECORD` hashes, entry points, and pip-installed editable packages; integrate usage hints from EntryTrace. | ||||
| @@ -32,24 +32,26 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana | ||||
|   - Hash verification throughput ≥75 MB/s sustained with streaming reader. | ||||
|   - False-positive rate for editable installs <1 % on curated fixtures. | ||||
|   - Determinism check across CPython 3.8–3.12 generated metadata. | ||||
| - **Gate Artifacts:** | ||||
|   - Golden fixtures for `site-packages`, virtualenv, and layered pip caches. | ||||
|   - Usage hint propagation tests (EntryTrace → analyzer → SBOM). | ||||
|   - Metrics counters (`scanner_analyzer_python_components_total`) documented. | ||||
| - **Gate Artifacts:** | ||||
|   - Golden fixtures for `site-packages`, virtualenv, and layered pip caches. | ||||
|   - Usage hint propagation tests (EntryTrace → analyzer → SBOM). | ||||
|   - Metrics counters (`scanner_analyzer_python_components_total`) documented. | ||||
| - **Progress (2025-10-21):** Python analyzer landed; Tasks 10-303A/B/C are DONE with dist-info parsing, RECORD verification, editable install detection, and deterministic `simple-venv` fixture + benchmark hooks recorded. | ||||
|  | ||||
| ## Sprint LA3 — Go Analyzer & Build Info Synthesis (Tasks 10-304, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Extract Go build metadata from `.note.go.buildid`, embedded module info, and fallback to `bin:{sha256}`; surface VCS provenance. | ||||
| - **Deliverables:** | ||||
|   - `StellaOps.Scanner.Analyzers.Lang.Go` plug-in. | ||||
|   - DWARF-lite parser to enrich component origin (commit hash + dirty flag) when available. | ||||
|   - Shared hash cache to dedupe repeated binaries across layers. | ||||
| - **Acceptance Metrics:** | ||||
|   - Analyzer latency ≤400 µs per binary (hot cache) / ≤2 ms (cold). | ||||
|   - Provenance coverage ≥95 % on representative Go fixture suite. | ||||
|   - Zero allocations in happy path beyond pooled buffers (validated via BenchmarkDotNet). | ||||
| - **Gate Artifacts:** | ||||
|   - Benchmarks vs competitor open-source tool (Trivy or Syft) demonstrating faster metadata extraction. | ||||
|   - Documentation snippet explaining VCS metadata fields for Policy team. | ||||
| ## Sprint LA3 — Go Analyzer & Build Info Synthesis (Tasks 10-304, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Extract Go build metadata from `.note.go.buildid`, embedded module info, and fallback to `bin:{sha256}`; surface VCS provenance. | ||||
| - **Deliverables:** | ||||
|   - `StellaOps.Scanner.Analyzers.Lang.Go` plug-in. | ||||
|   - DWARF-lite parser to enrich component origin (commit hash + dirty flag) when available. | ||||
|   - Shared hash cache to dedupe repeated binaries across layers. | ||||
| - **Acceptance Metrics:** | ||||
|   - Analyzer latency ≤400 µs per binary (hot cache) / ≤2 ms (cold). | ||||
|   - Provenance coverage ≥95 % on representative Go fixture suite. | ||||
|   - Zero allocations in happy path beyond pooled buffers (validated via BenchmarkDotNet). | ||||
| - **Gate Artifacts:** | ||||
|   - Benchmarks vs competitor open-source tool (Trivy or Syft) demonstrating faster metadata extraction. | ||||
|   - Documentation snippet explaining VCS metadata fields for Policy team. | ||||
| - **Progress (2025-10-22):** Build-info decoder shipped with DWARF-string fallback for `vcs.*` markers, plus cached metadata keyed by binary length/timestamp. Added Go test fixtures covering build-info and DWARF-only binaries with deterministic goldens; analyzer now emits `go.dwarf` evidence alongside `go.buildinfo` metadata to feed downstream provenance rules. | ||||
|  | ||||
| ## Sprint LA4 — .NET Analyzer & RID Variants (Tasks 10-305, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Parse `*.deps.json`, `runtimeconfig.json`, assembly metadata, and RID-specific assets; correlate with native dependencies. | ||||
| @@ -61,10 +63,11 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana | ||||
|   - Multi-target app fixture processed <1.2 s; memory <250 MB. | ||||
|   - RID variant collapse reduces component explosion by ≥40 % vs naive listing. | ||||
|   - All security metadata (signing Publisher, timestamp) surfaced deterministically. | ||||
| - **Gate Artifacts:** | ||||
|   - Signed .NET sample apps (framework-dependent & self-contained) under `samples/scanner/lang/dotnet/`. | ||||
|   - Tests verifying dual runtimeconfig merge logic. | ||||
|   - Guidance for Policy on license propagation from NuGet metadata. | ||||
| - **Gate Artifacts:** | ||||
|   - Signed .NET sample apps (framework-dependent & self-contained) under `samples/scanner/lang/dotnet/`. | ||||
|   - Tests verifying dual runtimeconfig merge logic. | ||||
|   - Guidance for Policy on license propagation from NuGet metadata. | ||||
| - **Progress (2025-10-22):** Completed task 10-305A with a deterministic deps/runtimeconfig ingest pipeline producing `pkg:nuget` components across RID targets. Added dotnet fixture + golden output to the shared harness, wired analyzer plugin availability, and surfaced RID metadata in component records for downstream emit/diff work. | ||||
|  | ||||
| ## Sprint LA5 — Rust Analyzer & Binary Fingerprinting (Tasks 10-306, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Detect crates via metadata in `.fingerprint`, Cargo.lock fragments, or embedded `rustc` markers; robust fallback to binary hash classification. | ||||
|   | ||||
| @@ -12,10 +12,11 @@ | ||||
|     <Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" /> | ||||
|     <EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" /> | ||||
|     <None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" /> | ||||
|     <ProjectReference Include="..\\StellaOps.Plugin\\StellaOps.Plugin.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -3,11 +3,11 @@ | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-ANALYZERS-LANG-10-301 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-CORE-09-501, SCANNER-WORKER-09-203 | Java analyzer emitting deterministic `pkg:maven` components using pom.properties / MANIFEST evidence. | Java analyzer extracts coordinates+version+licenses with provenance; golden fixtures deterministic; microbenchmark meets target. | | ||||
| | SCANNER-ANALYZERS-LANG-10-302 | DOING (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Node analyzer resolving workspaces/symlinks into `pkg:npm` identities. | Node analyzer handles symlinks/workspaces; outputs sorted components; determinism harness covers hoisted deps. | | ||||
| | SCANNER-ANALYZERS-LANG-10-303 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Python analyzer consuming `*.dist-info` metadata and RECORD hashes. | Analyzer binds METADATA + RECORD evidence, includes entry points, determinism fixtures stable. | | ||||
| | SCANNER-ANALYZERS-LANG-10-304 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Go analyzer leveraging buildinfo for `pkg:golang` components. | Buildinfo parser emits module path/version + vcs metadata; binaries without buildinfo downgraded gracefully. | | ||||
| | SCANNER-ANALYZERS-LANG-10-305 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | ||||
| | SCANNER-ANALYZERS-LANG-10-302 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Node analyzer resolving workspaces/symlinks into `pkg:npm` identities. | Node analyzer handles symlinks/workspaces; outputs sorted components; determinism harness covers hoisted deps. | | ||||
| | SCANNER-ANALYZERS-LANG-10-303 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Python analyzer consuming `*.dist-info` metadata and RECORD hashes. | Analyzer binds METADATA + RECORD evidence, includes entry points, determinism fixtures stable. | | ||||
| | SCANNER-ANALYZERS-LANG-10-304 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Go analyzer leveraging buildinfo for `pkg:golang` components. | Buildinfo parser emits module path/version + vcs metadata; binaries without buildinfo downgraded gracefully. | | ||||
| | SCANNER-ANALYZERS-LANG-10-305 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | ||||
| | SCANNER-ANALYZERS-LANG-10-306 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`. | Analyzer emits `pkg:cargo` when metadata present; falls back to binary hash; fixtures cover both paths. | | ||||
| | SCANNER-ANALYZERS-LANG-10-307 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-CORE-09-501 | Shared language evidence helpers + usage flag propagation. | Shared abstractions implemented; analyzers reuse helpers; evidence includes usage hints; unit tests cover canonical ordering. | | ||||
| | SCANNER-ANALYZERS-LANG-10-308 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Determinism + fixture harness for language analyzers. | Harness executes analyzers against fixtures; golden JSON stored; CI helper ensures stable hashes. | | ||||
| | SCANNER-ANALYZERS-LANG-10-309 | DOING (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301..308 | Package language analyzers as restart-time plug-ins (manifest + host registration). | Plugin manifests authored under `plugins/scanner/analyzers/lang`; Worker loads via DI; restart required flag enforced; tests confirm manifest integrity. | | ||||
| | SCANNER-ANALYZERS-LANG-10-309 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301..308 | Package language analyzers as restart-time plug-ins (manifest + host registration). | Plugin manifests authored under `plugins/scanner/analyzers/lang`; Worker loads via DI; restart required flag enforced; tests confirm manifest integrity. | | ||||
|   | ||||
| @@ -11,10 +11,19 @@ using StellaOps.Plugin.Hosting; | ||||
| using StellaOps.Scanner.Analyzers.OS.Abstractions; | ||||
| using StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.OS.Plugin; | ||||
|  | ||||
| public sealed class OsAnalyzerPluginCatalog | ||||
| { | ||||
| namespace StellaOps.Scanner.Analyzers.OS.Plugin; | ||||
|  | ||||
| public interface IOSAnalyzerPluginCatalog | ||||
| { | ||||
|     IReadOnlyCollection<IOSAnalyzerPlugin> Plugins { get; } | ||||
|  | ||||
|     void LoadFromDirectory(string directory, bool seal = true); | ||||
|  | ||||
|     IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services); | ||||
| } | ||||
|  | ||||
| public sealed class OsAnalyzerPluginCatalog : IOSAnalyzerPluginCatalog | ||||
| { | ||||
|     private readonly ILogger<OsAnalyzerPluginCatalog> _logger; | ||||
|     private readonly IPluginCatalogGuard _guard; | ||||
|     private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase); | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user