using System.Collections.ObjectModel; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Analyzers.OS.Abstractions; using StellaOps.Scanner.Analyzers.OS.Analyzers; using StellaOps.Scanner.Analyzers.OS.Helpers; namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey; /// /// Analyzes Windows Chocolatey package installations to extract component metadata. /// Scans ProgramData/Chocolatey/lib/ for installed packages. /// internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase { private static readonly IReadOnlyList EmptyPackages = new ReadOnlyCollection(Array.Empty()); /// /// Paths to scan for Chocolatey packages. /// private static readonly string[] ChocolateyPaths = [ "ProgramData/Chocolatey/lib", "ProgramData/chocolatey/lib" // Case variation ]; private readonly NuspecParser _nuspecParser = new(); public ChocolateyPackageAnalyzer(ILogger logger) : base(logger) { } public override string AnalyzerId => "windows-chocolatey"; protected override ValueTask ExecuteCoreAsync( OSPackageAnalyzerContext context, CancellationToken cancellationToken) { var records = new List(); var warnings = new List(); var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata); var chocolateyFound = false; var scannedLibDirs = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var chocoPath in ChocolateyPaths) { var libDir = Path.Combine(context.RootPath, chocoPath); if (!Directory.Exists(libDir)) { continue; } var normalizedDir = Path.GetFullPath(libDir); if (!scannedLibDirs.Add(normalizedDir)) { continue; } chocolateyFound = true; Logger.LogInformation("Scanning Chocolatey packages in {Path}", libDir); try { DiscoverPackages(context.RootPath, evidenceFactory, libDir, records, warnings, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogWarning(ex, "Failed to scan Chocolatey path {Path}", libDir); } } if (!chocolateyFound) { Logger.LogInformation("Chocolatey installation not found; skipping analyzer."); return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages)); } if (records.Count == 0) { Logger.LogInformation("No Chocolatey packages found; skipping analyzer."); return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages)); } foreach (var warning in warnings.Take(10)) { Logger.LogWarning("Chocolatey scan warning ({Code}): {Message}", warning.Code, warning.Message); } Logger.LogInformation("Discovered {Count} Chocolatey packages", records.Count); // Sort for deterministic output records.Sort(); return ValueTask.FromResult(ExecutionResult.From(records, warnings)); } private void DiscoverPackages( string rootPath, OsFileEvidenceFactory evidenceFactory, string libDir, List records, List warnings, CancellationToken cancellationToken) { IEnumerable packageDirs; try { packageDirs = Directory.EnumerateDirectories(libDir); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return; } foreach (var packageDir in packageDirs) { cancellationToken.ThrowIfCancellationRequested(); var dirName = Path.GetFileName(packageDir); if (string.IsNullOrWhiteSpace(dirName) || dirName.StartsWith('.')) { continue; } var record = AnalyzePackage(rootPath, evidenceFactory, packageDir, warnings, cancellationToken); if (record is not null) { records.Add(record); } } } private OSPackageRecord? AnalyzePackage( string rootPath, OsFileEvidenceFactory evidenceFactory, string packageDir, List warnings, CancellationToken cancellationToken) { // Look for .nuspec file var nuspecFile = Directory.GetFiles(packageDir, "*.nuspec", SearchOption.TopDirectoryOnly) .FirstOrDefault(); ChocolateyPackageMetadata? metadata = null; if (nuspecFile is not null) { metadata = _nuspecParser.Parse(nuspecFile, packageDir, cancellationToken); } // Fallback: parse package name from directory if (metadata is null) { var dirName = Path.GetFileName(packageDir); var parsed = NuspecParser.ParsePackageDirectory(dirName); if (parsed is null) { warnings.Add(AnalyzerWarning.From( "windows-chocolatey/unparseable-package-dir", $"Could not parse package info from {Path.GetFileName(packageDir)}")); return null; } metadata = new ChocolateyPackageMetadata( Id: parsed.Value.Id, Version: parsed.Value.Version, Title: null, Authors: null, Description: null, LicenseUrl: null, ProjectUrl: null, Checksum: null, ChecksumType: null, SourceFeed: null, InstallDir: packageDir, InstallScriptHash: null, InstalledFiles: []); } // Build PURL var purl = PackageUrlBuilder.BuildChocolatey(metadata.Id, metadata.Version); // Build vendor metadata var vendorMetadata = BuildVendorMetadata(metadata); // Build file evidence (limit to key files) var files = metadata.InstalledFiles .Where(f => IsKeyFile(f)) .Take(100) // Limit file evidence .Select(f => { var fullPath = Path.Combine(packageDir, f); var relativePath = OsPath.TryGetRootfsRelative(rootPath, fullPath) ?? OsPath.NormalizeRelative(f); return relativePath is null ? null : evidenceFactory.Create(relativePath, IsConfigFile(f)); }) .Where(static file => file is not null) .Select(static file => file!) .ToList(); return new OSPackageRecord( AnalyzerId, purl, metadata.Title ?? metadata.Id, metadata.Version, "x64", // Chocolatey packages are typically platform-neutral or x64 PackageEvidenceSource.WindowsChocolatey, epoch: null, release: null, sourcePackage: metadata.Authors, license: metadata.LicenseUrl, cveHints: null, provides: null, depends: null, files: files, vendorMetadata: vendorMetadata); } private static Dictionary BuildVendorMetadata(ChocolateyPackageMetadata metadata) { var vendorMetadata = new Dictionary(StringComparer.Ordinal) { ["choco:id"] = metadata.Id, ["choco:version"] = metadata.Version, ["choco:install_dir"] = metadata.InstallDir, }; if (!string.IsNullOrWhiteSpace(metadata.Title)) { vendorMetadata["choco:title"] = metadata.Title; } if (!string.IsNullOrWhiteSpace(metadata.Authors)) { vendorMetadata["choco:authors"] = metadata.Authors; } if (!string.IsNullOrWhiteSpace(metadata.Description)) { // Truncate long descriptions vendorMetadata["choco:description"] = metadata.Description.Length > 200 ? metadata.Description[..197] + "..." : metadata.Description; } if (!string.IsNullOrWhiteSpace(metadata.ProjectUrl)) { vendorMetadata["choco:project_url"] = metadata.ProjectUrl; } if (!string.IsNullOrWhiteSpace(metadata.LicenseUrl)) { vendorMetadata["choco:license_url"] = metadata.LicenseUrl; } if (!string.IsNullOrWhiteSpace(metadata.SourceFeed)) { vendorMetadata["choco:source_feed"] = metadata.SourceFeed; } if (!string.IsNullOrWhiteSpace(metadata.InstallScriptHash)) { vendorMetadata["choco:install_script_hash"] = metadata.InstallScriptHash; } if (!string.IsNullOrWhiteSpace(metadata.Checksum)) { vendorMetadata["choco:checksum"] = metadata.Checksum; vendorMetadata["choco:checksum_type"] = metadata.ChecksumType; } vendorMetadata["choco:file_count"] = metadata.InstalledFiles.Count.ToString(); return vendorMetadata; } private static bool IsKeyFile(string path) { // Include executables, libraries, configs, and key metadata var ext = Path.GetExtension(path).ToLowerInvariant(); return ext switch { ".exe" or ".dll" or ".ps1" or ".psm1" or ".bat" or ".cmd" => true, ".config" or ".json" or ".xml" or ".yaml" or ".yml" => true, ".nuspec" or ".nupkg" => true, _ => false }; } private static bool IsConfigFile(string path) { var ext = Path.GetExtension(path).ToLowerInvariant(); return ext is ".config" or ".json" or ".xml" or ".yaml" or ".yml"; } }