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";
}
}