Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
295 lines
9.9 KiB
C#
295 lines
9.9 KiB
C#
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<ExecutionResult> ExecuteCoreAsync(
|
|
OSPackageAnalyzerContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var records = new List<OSPackageRecord>();
|
|
var warnings = new List<AnalyzerWarning>();
|
|
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
|
|
var chocolateyFound = false;
|
|
var scannedLibDirs = new HashSet<string>(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<OSPackageRecord> records,
|
|
List<AnalyzerWarning> 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(rootPath, evidenceFactory, packageDir, warnings, cancellationToken);
|
|
if (record is not null)
|
|
{
|
|
records.Add(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
private OSPackageRecord? AnalyzePackage(
|
|
string rootPath,
|
|
OsFileEvidenceFactory evidenceFactory,
|
|
string packageDir,
|
|
List<AnalyzerWarning> 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<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";
|
|
}
|
|
}
|