Add scripts for resolving and verifying Chromium binary paths
- 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