up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 19:23:54 +02:00
parent d1cbb905f8
commit d040c001ac
36 changed files with 4668 additions and 9 deletions

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
/// <summary>
/// Plugin that registers the Windows Chocolatey package analyzer.
/// </summary>
public sealed class ChocolateyAnalyzerPlugin : IOSAnalyzerPlugin
{
/// <inheritdoc />
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey";
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services) => services is not null;
/// <inheritdoc />
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new ChocolateyPackageAnalyzer(loggerFactory.CreateLogger<ChocolateyPackageAnalyzer>());
}
}

View File

@@ -0,0 +1,278 @@
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;
/// <summary>
/// Analyzes Windows Chocolatey package installations to extract component metadata.
/// Scans ProgramData/Chocolatey/lib/ for installed packages.
/// </summary>
internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
/// <summary>
/// Paths to scan for Chocolatey packages.
/// </summary>
private static readonly string[] ChocolateyPaths =
[
"ProgramData/Chocolatey/lib",
"ProgramData/chocolatey/lib" // Case variation
];
private readonly NuspecParser _nuspecParser = new();
public ChocolateyPackageAnalyzer(ILogger<ChocolateyPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "windows-chocolatey";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var records = new List<OSPackageRecord>();
var warnings = new List<string>();
var chocolateyFound = false;
foreach (var chocoPath in ChocolateyPaths)
{
var libDir = Path.Combine(context.RootPath, chocoPath);
if (!Directory.Exists(libDir))
{
continue;
}
chocolateyFound = true;
Logger.LogInformation("Scanning Chocolatey packages in {Path}", libDir);
try
{
DiscoverPackages(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<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
if (records.Count == 0)
{
Logger.LogInformation("No Chocolatey packages found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
foreach (var warning in warnings.Take(10))
{
Logger.LogWarning("Chocolatey scan warning: {Warning}", warning);
}
Logger.LogInformation("Discovered {Count} Chocolatey packages", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
}
private void DiscoverPackages(
string libDir,
List<OSPackageRecord> records,
List<string> warnings,
CancellationToken cancellationToken)
{
IEnumerable<string> 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(packageDir, warnings, cancellationToken);
if (record is not null)
{
records.Add(record);
}
}
}
private OSPackageRecord? AnalyzePackage(
string packageDir,
List<string> 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($"Could not parse package info from {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 => new OSPackageFileEvidence(
f,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: IsConfigFile(f)))
.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<string, string?> BuildVendorMetadata(ChocolateyPackageMetadata metadata)
{
var vendorMetadata = new Dictionary<string, string?>(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";
}
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
/// <summary>
/// Represents metadata extracted from a Chocolatey package installation.
/// </summary>
internal sealed record ChocolateyPackageMetadata(
/// <summary>Package identifier (e.g., "git", "nodejs").</summary>
string Id,
/// <summary>Package version (e.g., "2.42.0").</summary>
string Version,
/// <summary>Package title/display name.</summary>
string? Title,
/// <summary>Package authors.</summary>
string? Authors,
/// <summary>Package description.</summary>
string? Description,
/// <summary>Package license URL.</summary>
string? LicenseUrl,
/// <summary>Package project URL.</summary>
string? ProjectUrl,
/// <summary>Package checksum.</summary>
string? Checksum,
/// <summary>Checksum algorithm (e.g., "sha256").</summary>
string? ChecksumType,
/// <summary>Source feed URL where package was downloaded.</summary>
string? SourceFeed,
/// <summary>Installation directory.</summary>
string InstallDir,
/// <summary>Installation script hash for determinism.</summary>
string? InstallScriptHash,
/// <summary>Files installed by the package.</summary>
IReadOnlyList<string> InstalledFiles);

View File

@@ -0,0 +1,184 @@
using System.Security.Cryptography;
using System.Xml.Linq;
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
/// <summary>
/// Parses Chocolatey/NuGet .nuspec files to extract package metadata.
/// </summary>
internal sealed class NuspecParser
{
private static readonly XNamespace NuspecNs = "http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd";
private static readonly XNamespace NuspecNsOld = "http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd";
private static readonly XNamespace NuspecNsOld2 = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd";
/// <summary>
/// Parses a .nuspec file and extracts package metadata.
/// </summary>
public ChocolateyPackageMetadata? Parse(string nuspecPath, string installDir, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(nuspecPath) || !File.Exists(nuspecPath))
{
return null;
}
try
{
var doc = XDocument.Load(nuspecPath);
if (doc.Root is null)
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
// Find metadata element
var metadata = FindElement(doc.Root, "metadata");
if (metadata is null)
{
return null;
}
// Extract required fields
var id = GetElementValue(metadata, "id");
var version = GetElementValue(metadata, "version");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version))
{
return null;
}
// Extract optional fields
var title = GetElementValue(metadata, "title");
var authors = GetElementValue(metadata, "authors");
var description = GetElementValue(metadata, "description");
var licenseUrl = GetElementValue(metadata, "licenseUrl");
var projectUrl = GetElementValue(metadata, "projectUrl");
// Look for install script and compute hash
var installScriptHash = ComputeInstallScriptHash(installDir);
// Enumerate installed files
var installedFiles = EnumerateInstalledFiles(installDir);
return new ChocolateyPackageMetadata(
Id: id,
Version: version,
Title: title,
Authors: authors,
Description: description,
LicenseUrl: licenseUrl,
ProjectUrl: projectUrl,
Checksum: null,
ChecksumType: null,
SourceFeed: null,
InstallDir: installDir,
InstallScriptHash: installScriptHash,
InstalledFiles: installedFiles);
}
catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Parses basic package info from directory name pattern.
/// Format: packageid.version
/// </summary>
public static (string Id, string Version)? ParsePackageDirectory(string directoryName)
{
if (string.IsNullOrWhiteSpace(directoryName))
{
return null;
}
// Find the first dot followed by a digit (start of version)
// Iterating from left to right because package IDs can contain dots
// (e.g., "Microsoft.WindowsTerminal.1.18.0" -> id="Microsoft.WindowsTerminal", version="1.18.0")
var versionStartIndex = -1;
for (var i = 0; i < directoryName.Length - 1; i++)
{
if (directoryName[i] == '.' && char.IsDigit(directoryName[i + 1]))
{
versionStartIndex = i;
break;
}
}
if (versionStartIndex <= 0)
{
return null;
}
var id = directoryName[..versionStartIndex];
var version = directoryName[(versionStartIndex + 1)..];
return (id, version);
}
private static XElement? FindElement(XElement parent, string localName)
{
return parent.Element(NuspecNs + localName)
?? parent.Element(NuspecNsOld + localName)
?? parent.Element(NuspecNsOld2 + localName)
?? parent.Element(localName);
}
private static string? GetElementValue(XElement parent, string localName)
{
var element = FindElement(parent, localName);
return element?.Value?.Trim();
}
private static string? ComputeInstallScriptHash(string installDir)
{
// Look for chocolateyinstall.ps1 script
var scriptPath = Path.Combine(installDir, "tools", "chocolateyinstall.ps1");
if (!File.Exists(scriptPath))
{
scriptPath = Path.Combine(installDir, "chocolateyinstall.ps1");
}
if (!File.Exists(scriptPath))
{
return null;
}
try
{
using var stream = File.OpenRead(scriptPath);
var hash = SHA256.HashData(stream);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static List<string> EnumerateInstalledFiles(string installDir)
{
var files = new List<string>();
if (!Directory.Exists(installDir))
{
return files;
}
try
{
foreach (var file in Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(installDir, file);
files.Add(relativePath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Ignore access errors
}
return files;
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests")]

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PackageId>StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey</PackageId>
<Version>0.1.0-alpha</Version>
<Description>Windows Chocolatey and registry package analyzer for StellaOps Scanner</Description>
<Authors>StellaOps</Authors>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/stellaops</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
</ItemGroup>
</Project>