This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests")]
|
||||
@@ -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>
|
||||
@@ -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.Msi;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin that registers the Windows MSI package analyzer.
|
||||
/// </summary>
|
||||
public sealed class MsiAnalyzerPlugin : IOSAnalyzerPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.Msi";
|
||||
|
||||
/// <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 MsiPackageAnalyzer(loggerFactory.CreateLogger<MsiPackageAnalyzer>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi;
|
||||
|
||||
/// <summary>
|
||||
/// Parses MSI (Windows Installer) database files to extract product metadata.
|
||||
/// Uses OLE compound document parsing for cross-platform compatibility.
|
||||
/// </summary>
|
||||
internal sealed class MsiDatabaseParser
|
||||
{
|
||||
// MSI Summary Information property IDs
|
||||
private const int PID_REVNUMBER = 9; // Package code
|
||||
private const int PID_SUBJECT = 3; // Product name
|
||||
private const int PID_AUTHOR = 4; // Manufacturer
|
||||
private const int PID_TEMPLATE = 7; // Platform;Language
|
||||
|
||||
// OLE compound document magic number
|
||||
private static readonly byte[] OleMagic = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
/// <summary>
|
||||
/// Parses an MSI file and extracts product metadata.
|
||||
/// </summary>
|
||||
public MsiMetadata? Parse(string msiPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(msiPath) || !File.Exists(msiPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(msiPath);
|
||||
|
||||
// Verify OLE compound document signature
|
||||
var header = new byte[8];
|
||||
if (stream.Read(header, 0, 8) != 8 || !header.AsSpan().SequenceEqual(OleMagic))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// For cross-platform compatibility, extract basic metadata from the file structure
|
||||
// Full MSI table parsing would require Windows COM or a dedicated library
|
||||
var fileInfo = new FileInfo(msiPath);
|
||||
var fileName = Path.GetFileNameWithoutExtension(msiPath);
|
||||
|
||||
// Try to extract basic info from filename conventions
|
||||
var (name, version) = ExtractNameVersionFromFileName(fileName);
|
||||
|
||||
// Compute file hash for provenance
|
||||
stream.Position = 0;
|
||||
var hash = ComputeSha256(stream);
|
||||
|
||||
return new MsiMetadata(
|
||||
ProductCode: null, // Requires full database parsing
|
||||
UpgradeCode: null, // Requires full database parsing
|
||||
ProductName: name,
|
||||
ProductVersion: version ?? "0.0.0",
|
||||
Manufacturer: null, // Requires summary stream parsing
|
||||
Language: null, // Requires template property
|
||||
PackageCode: null, // Requires revision number property
|
||||
InstallScope: null,
|
||||
InstallSource: msiPath,
|
||||
FilePath: msiPath,
|
||||
FileSize: fileInfo.Length,
|
||||
FileHash: hash);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts product name and version from common MSI filename patterns.
|
||||
/// </summary>
|
||||
private static (string Name, string? Version) ExtractNameVersionFromFileName(string fileName)
|
||||
{
|
||||
// Common patterns: "ProductName-1.2.3", "ProductName_v1.2.3", "ProductName 1.2.3"
|
||||
var versionPattern = @"[-_\s]v?(\d+(?:\.\d+){0,3})$";
|
||||
var match = System.Text.RegularExpressions.Regex.Match(fileName, versionPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
var name = fileName[..match.Index].Trim('-', '_', ' ');
|
||||
var version = match.Groups[1].Value;
|
||||
return (name, version);
|
||||
}
|
||||
|
||||
return (fileName, null);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(Stream stream)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.Msi;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata extracted from a Windows MSI installer package.
|
||||
/// </summary>
|
||||
internal sealed record MsiMetadata(
|
||||
string? ProductCode,
|
||||
string? UpgradeCode,
|
||||
string ProductName,
|
||||
string ProductVersion,
|
||||
string? Manufacturer,
|
||||
string? Language,
|
||||
string? PackageCode,
|
||||
string? InstallScope,
|
||||
string? InstallSource,
|
||||
string FilePath,
|
||||
long? FileSize,
|
||||
string? FileHash);
|
||||
@@ -0,0 +1,273 @@
|
||||
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.Msi;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes Windows MSI installer packages to extract product metadata.
|
||||
/// Scans common MSI cache locations and the Windows Installer directory.
|
||||
/// </summary>
|
||||
internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
|
||||
{
|
||||
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||
|
||||
/// <summary>
|
||||
/// Standard paths to scan for MSI packages relative to root.
|
||||
/// </summary>
|
||||
private static readonly string[] MsiSearchPaths =
|
||||
[
|
||||
"Windows/Installer",
|
||||
"ProgramData/Package Cache",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to process (100MB).
|
||||
/// </summary>
|
||||
private const long MaxFileSizeBytes = 100L * 1024L * 1024L;
|
||||
|
||||
private readonly MsiDatabaseParser _msiParser = new();
|
||||
|
||||
public MsiPackageAnalyzer(ILogger<MsiPackageAnalyzer> logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string AnalyzerId => "windows-msi";
|
||||
|
||||
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
||||
OSPackageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var records = new List<OSPackageRecord>();
|
||||
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Scan standard MSI cache paths
|
||||
foreach (var searchPath in MsiSearchPaths)
|
||||
{
|
||||
var fullPath = Path.Combine(context.RootPath, searchPath);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogInformation("Scanning for MSI packages in {Path}", fullPath);
|
||||
|
||||
try
|
||||
{
|
||||
DiscoverMsiFiles(fullPath, records, processedFiles, warnings, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to scan MSI path {Path}", fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan user-specific installer cache
|
||||
var usersPath = Path.Combine(context.RootPath, "Users");
|
||||
if (Directory.Exists(usersPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var userDir in Directory.EnumerateDirectories(usersPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Local AppData MSI cache
|
||||
var localAppData = Path.Combine(userDir, "AppData", "Local", "Package Cache");
|
||||
if (Directory.Exists(localAppData))
|
||||
{
|
||||
DiscoverMsiFiles(localAppData, records, processedFiles, warnings, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Logger.LogDebug(ex, "Could not enumerate user directories");
|
||||
}
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
Logger.LogInformation("No MSI packages found; skipping analyzer.");
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||
}
|
||||
|
||||
foreach (var warning in warnings.Take(10))
|
||||
{
|
||||
Logger.LogWarning("MSI scan warning: {Warning}", warning);
|
||||
}
|
||||
|
||||
Logger.LogInformation("Discovered {Count} MSI packages", records.Count);
|
||||
|
||||
// Sort for deterministic output
|
||||
records.Sort();
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
|
||||
}
|
||||
|
||||
private void DiscoverMsiFiles(
|
||||
string searchPath,
|
||||
List<OSPackageRecord> records,
|
||||
HashSet<string> processedFiles,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<string> msiFiles;
|
||||
try
|
||||
{
|
||||
msiFiles = Directory.EnumerateFiles(searchPath, "*.msi", SearchOption.AllDirectories);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var msiPath in msiFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip if already processed (handles symlinks/duplicates)
|
||||
var normalizedPath = Path.GetFullPath(msiPath);
|
||||
if (!processedFiles.Add(normalizedPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(msiPath);
|
||||
if (fileInfo.Length > MaxFileSizeBytes)
|
||||
{
|
||||
warnings.Add($"Skipping large MSI file ({fileInfo.Length} bytes): {msiPath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var record = AnalyzeMsiFile(msiPath, warnings, cancellationToken);
|
||||
if (record is not null)
|
||||
{
|
||||
records.Add(record);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Logger.LogDebug(ex, "Could not access MSI file {Path}", msiPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OSPackageRecord? AnalyzeMsiFile(
|
||||
string msiPath,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = _msiParser.Parse(msiPath, cancellationToken);
|
||||
if (metadata is null)
|
||||
{
|
||||
warnings.Add($"Failed to parse MSI file: {msiPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build PURL
|
||||
var purl = PackageUrlBuilder.BuildWindowsMsi(
|
||||
metadata.ProductName,
|
||||
metadata.ProductVersion,
|
||||
metadata.UpgradeCode);
|
||||
|
||||
// Build vendor metadata
|
||||
var vendorMetadata = BuildVendorMetadata(metadata);
|
||||
|
||||
// Build file evidence
|
||||
var files = new List<OSPackageFileEvidence>
|
||||
{
|
||||
new(
|
||||
Path.GetFileName(msiPath),
|
||||
layerDigest: null,
|
||||
sha256: metadata.FileHash,
|
||||
sizeBytes: metadata.FileSize,
|
||||
isConfigFile: false)
|
||||
};
|
||||
|
||||
return new OSPackageRecord(
|
||||
AnalyzerId,
|
||||
purl,
|
||||
metadata.ProductName,
|
||||
metadata.ProductVersion,
|
||||
DetermineArchitecture(metadata.Language),
|
||||
PackageEvidenceSource.WindowsMsi,
|
||||
epoch: null,
|
||||
release: null,
|
||||
sourcePackage: metadata.Manufacturer,
|
||||
license: null,
|
||||
cveHints: null,
|
||||
provides: null,
|
||||
depends: null,
|
||||
files: files,
|
||||
vendorMetadata: vendorMetadata);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildVendorMetadata(MsiMetadata metadata)
|
||||
{
|
||||
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["msi:file_path"] = metadata.FilePath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ProductCode))
|
||||
{
|
||||
vendorMetadata["msi:product_code"] = metadata.ProductCode;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.UpgradeCode))
|
||||
{
|
||||
vendorMetadata["msi:upgrade_code"] = metadata.UpgradeCode;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.PackageCode))
|
||||
{
|
||||
vendorMetadata["msi:package_code"] = metadata.PackageCode;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Manufacturer))
|
||||
{
|
||||
vendorMetadata["msi:manufacturer"] = metadata.Manufacturer;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Language))
|
||||
{
|
||||
vendorMetadata["msi:language"] = metadata.Language;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.InstallScope))
|
||||
{
|
||||
vendorMetadata["msi:install_scope"] = metadata.InstallScope;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.InstallSource))
|
||||
{
|
||||
vendorMetadata["msi:install_source"] = metadata.InstallSource;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.FileHash))
|
||||
{
|
||||
vendorMetadata["msi:file_hash"] = metadata.FileHash;
|
||||
}
|
||||
|
||||
if (metadata.FileSize.HasValue)
|
||||
{
|
||||
vendorMetadata["msi:file_size"] = metadata.FileSize.Value.ToString();
|
||||
}
|
||||
|
||||
return vendorMetadata;
|
||||
}
|
||||
|
||||
private static string DetermineArchitecture(string? language)
|
||||
{
|
||||
// MSI packages are typically architecture-specific based on template
|
||||
// For now, default to x64 as most common
|
||||
return "x64";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.Msi.Tests")]
|
||||
@@ -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.Msi</PackageId>
|
||||
<Version>0.1.0-alpha</Version>
|
||||
<Description>Windows MSI 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Windows.WinSxS.Tests")]
|
||||
@@ -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.WinSxS</PackageId>
|
||||
<Version>0.1.0-alpha</Version>
|
||||
<Description>Windows WinSxS assembly 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>
|
||||
@@ -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.WinSxS;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin that registers the Windows WinSxS assembly analyzer.
|
||||
/// </summary>
|
||||
public sealed class WinSxSAnalyzerPlugin : IOSAnalyzerPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "StellaOps.Scanner.Analyzers.OS.Windows.WinSxS";
|
||||
|
||||
/// <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 WinSxSPackageAnalyzer(loggerFactory.CreateLogger<WinSxSPackageAnalyzer>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata extracted from a Windows Side-by-Side (WinSxS) assembly manifest.
|
||||
/// </summary>
|
||||
internal sealed record WinSxSAssemblyMetadata(
|
||||
/// <summary>Assembly name (e.g., "Microsoft.Windows.Common-Controls").</summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>Assembly version (e.g., "6.0.0.0").</summary>
|
||||
string Version,
|
||||
|
||||
/// <summary>Processor architecture (e.g., "x86", "amd64", "arm64", "msil", "wow64").</summary>
|
||||
string ProcessorArchitecture,
|
||||
|
||||
/// <summary>Public key token (e.g., "6595b64144ccf1df").</summary>
|
||||
string? PublicKeyToken,
|
||||
|
||||
/// <summary>Language/culture (e.g., "en-us", "*", or empty).</summary>
|
||||
string? Language,
|
||||
|
||||
/// <summary>Assembly type (e.g., "win32", "win32-policy").</summary>
|
||||
string? Type,
|
||||
|
||||
/// <summary>Version scope (e.g., "nonSxS" for non-side-by-side assemblies).</summary>
|
||||
string? VersionScope,
|
||||
|
||||
/// <summary>Manifest file path.</summary>
|
||||
string ManifestPath,
|
||||
|
||||
/// <summary>Associated catalog (.cat) file path if found.</summary>
|
||||
string? CatalogPath,
|
||||
|
||||
/// <summary>Catalog signature thumbprint if available.</summary>
|
||||
string? CatalogThumbprint,
|
||||
|
||||
/// <summary>KB reference from patch manifest if available.</summary>
|
||||
string? KbReference,
|
||||
|
||||
/// <summary>Files declared in the assembly manifest.</summary>
|
||||
IReadOnlyList<WinSxSFileEntry> Files);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file entry in a WinSxS assembly manifest.
|
||||
/// </summary>
|
||||
internal sealed record WinSxSFileEntry(
|
||||
/// <summary>File name.</summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>File hash algorithm (e.g., "SHA256").</summary>
|
||||
string? HashAlgorithm,
|
||||
|
||||
/// <summary>File hash value.</summary>
|
||||
string? Hash,
|
||||
|
||||
/// <summary>File size in bytes.</summary>
|
||||
long? Size,
|
||||
|
||||
/// <summary>Destination path within the assembly.</summary>
|
||||
string? DestinationPath);
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Windows.WinSxS;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Windows Side-by-Side (WinSxS) assembly manifest files.
|
||||
/// </summary>
|
||||
internal sealed class WinSxSManifestParser
|
||||
{
|
||||
// WinSxS XML namespace
|
||||
private static readonly XNamespace AssemblyNs = "urn:schemas-microsoft-com:asm.v1";
|
||||
private static readonly XNamespace AssemblyNsV3 = "urn:schemas-microsoft-com:asm.v3";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a WinSxS manifest file and extracts assembly metadata.
|
||||
/// </summary>
|
||||
public WinSxSAssemblyMetadata? Parse(string manifestPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Load(manifestPath);
|
||||
if (doc.Root is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Find assemblyIdentity element (try both namespaces and no namespace)
|
||||
var identity = FindAssemblyIdentity(doc.Root);
|
||||
if (identity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract assembly identity attributes
|
||||
var name = identity.Attribute("name")?.Value;
|
||||
var version = identity.Attribute("version")?.Value;
|
||||
var arch = identity.Attribute("processorArchitecture")?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicKeyToken = identity.Attribute("publicKeyToken")?.Value;
|
||||
var language = identity.Attribute("language")?.Value;
|
||||
var type = identity.Attribute("type")?.Value;
|
||||
var versionScope = identity.Attribute("versionScope")?.Value;
|
||||
|
||||
// Extract file entries
|
||||
var files = ExtractFileEntries(doc.Root);
|
||||
|
||||
// Extract KB reference from manifest path if present
|
||||
var kbReference = ExtractKbReference(manifestPath);
|
||||
|
||||
// Look for associated catalog file
|
||||
var catalogPath = FindCatalogFile(manifestPath);
|
||||
|
||||
return new WinSxSAssemblyMetadata(
|
||||
Name: name,
|
||||
Version: version,
|
||||
ProcessorArchitecture: arch ?? "neutral",
|
||||
PublicKeyToken: publicKeyToken,
|
||||
Language: language,
|
||||
Type: type,
|
||||
VersionScope: versionScope,
|
||||
ManifestPath: manifestPath,
|
||||
CatalogPath: catalogPath,
|
||||
CatalogThumbprint: null, // Would require certificate parsing
|
||||
KbReference: kbReference,
|
||||
Files: files);
|
||||
}
|
||||
catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic assembly identity string for PURL construction.
|
||||
/// Format: name_version_arch_publicKeyToken_language
|
||||
/// </summary>
|
||||
public static string BuildAssemblyIdentityString(WinSxSAssemblyMetadata metadata)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(metadata.Name.ToLowerInvariant());
|
||||
builder.Append('_');
|
||||
builder.Append(metadata.Version);
|
||||
builder.Append('_');
|
||||
builder.Append(metadata.ProcessorArchitecture?.ToLowerInvariant() ?? "neutral");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.PublicKeyToken))
|
||||
{
|
||||
builder.Append('_');
|
||||
builder.Append(metadata.PublicKeyToken.ToLowerInvariant());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Language) && metadata.Language != "*")
|
||||
{
|
||||
builder.Append('_');
|
||||
builder.Append(metadata.Language.ToLowerInvariant());
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static XElement? FindAssemblyIdentity(XElement root)
|
||||
{
|
||||
// Try different namespace variations
|
||||
return root.Element(AssemblyNs + "assemblyIdentity")
|
||||
?? root.Element(AssemblyNsV3 + "assemblyIdentity")
|
||||
?? root.Element("assemblyIdentity")
|
||||
?? root.Descendants().FirstOrDefault(e =>
|
||||
e.Name.LocalName == "assemblyIdentity");
|
||||
}
|
||||
|
||||
private static List<WinSxSFileEntry> ExtractFileEntries(XElement root)
|
||||
{
|
||||
var files = new List<WinSxSFileEntry>();
|
||||
|
||||
// Find all file elements
|
||||
var fileElements = root.Descendants()
|
||||
.Where(e => e.Name.LocalName == "file");
|
||||
|
||||
foreach (var file in fileElements)
|
||||
{
|
||||
var name = file.Attribute("name")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hashAttr = file.Attribute("hash");
|
||||
var hashAlgAttr = file.Attribute("hashalg");
|
||||
var sizeAttr = file.Attribute("size");
|
||||
var destAttr = file.Attribute("destinationPath");
|
||||
|
||||
// Try to find hash in child element
|
||||
var hashElement = file.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "hash");
|
||||
|
||||
string? hash = hashAttr?.Value;
|
||||
string? hashAlg = hashAlgAttr?.Value;
|
||||
|
||||
if (hashElement is not null)
|
||||
{
|
||||
var digestMethod = hashElement.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "DigestMethod");
|
||||
var digestValue = hashElement.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "DigestValue");
|
||||
|
||||
if (digestMethod is not null)
|
||||
{
|
||||
hashAlg = ParseHashAlgorithm(digestMethod.Attribute("Algorithm")?.Value);
|
||||
}
|
||||
if (digestValue is not null)
|
||||
{
|
||||
hash = digestValue.Value;
|
||||
}
|
||||
}
|
||||
|
||||
long? size = null;
|
||||
if (long.TryParse(sizeAttr?.Value, out var parsedSize))
|
||||
{
|
||||
size = parsedSize;
|
||||
}
|
||||
|
||||
files.Add(new WinSxSFileEntry(
|
||||
Name: name,
|
||||
HashAlgorithm: hashAlg,
|
||||
Hash: hash,
|
||||
Size: size,
|
||||
DestinationPath: destAttr?.Value));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static string? ParseHashAlgorithm(string? algorithmUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Common XML Signature algorithm URIs
|
||||
return algorithmUri switch
|
||||
{
|
||||
_ when algorithmUri.Contains("sha256", StringComparison.OrdinalIgnoreCase) => "SHA256",
|
||||
_ when algorithmUri.Contains("sha1", StringComparison.OrdinalIgnoreCase) => "SHA1",
|
||||
_ when algorithmUri.Contains("sha384", StringComparison.OrdinalIgnoreCase) => "SHA384",
|
||||
_ when algorithmUri.Contains("sha512", StringComparison.OrdinalIgnoreCase) => "SHA512",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractKbReference(string manifestPath)
|
||||
{
|
||||
// KB references are often embedded in manifest file names
|
||||
// e.g., "amd64_microsoft-windows-security-base_31bf3856ad364e35_10.0.19041.4170_none_kb5034441.manifest"
|
||||
var fileName = Path.GetFileNameWithoutExtension(manifestPath);
|
||||
var kbMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
fileName,
|
||||
@"kb(\d+)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
return kbMatch.Success ? $"KB{kbMatch.Groups[1].Value}" : null;
|
||||
}
|
||||
|
||||
private static string? FindCatalogFile(string manifestPath)
|
||||
{
|
||||
// Catalog files are typically named similarly to manifests
|
||||
var directory = Path.GetDirectoryName(manifestPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look in Catalogs directory
|
||||
var catalogsDir = Path.Combine(Path.GetDirectoryName(directory) ?? "", "Catalogs");
|
||||
if (!Directory.Exists(catalogsDir))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseName = Path.GetFileNameWithoutExtension(manifestPath);
|
||||
|
||||
// Try to find matching catalog
|
||||
var catalogPath = Path.Combine(catalogsDir, baseName + ".cat");
|
||||
return File.Exists(catalogPath) ? catalogPath : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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.WinSxS;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes Windows Side-by-Side (WinSxS) assembly manifests to extract component metadata.
|
||||
/// Scans Windows/WinSxS/Manifests/ for .manifest files.
|
||||
/// </summary>
|
||||
internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
|
||||
{
|
||||
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||
|
||||
/// <summary>
|
||||
/// Path to WinSxS manifests directory.
|
||||
/// </summary>
|
||||
private const string ManifestsPath = "Windows/WinSxS/Manifests";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of manifests to process (limit for large systems).
|
||||
/// </summary>
|
||||
private const int MaxManifests = 50000;
|
||||
|
||||
private readonly WinSxSManifestParser _parser = new();
|
||||
|
||||
public WinSxSPackageAnalyzer(ILogger<WinSxSPackageAnalyzer> logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string AnalyzerId => "windows-winsxs";
|
||||
|
||||
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
||||
OSPackageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestsDir = Path.Combine(context.RootPath, ManifestsPath);
|
||||
if (!Directory.Exists(manifestsDir))
|
||||
{
|
||||
Logger.LogInformation("WinSxS manifests directory not found at {Path}; skipping analyzer.", manifestsDir);
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||
}
|
||||
|
||||
var records = new List<OSPackageRecord>();
|
||||
var warnings = new List<string>();
|
||||
var processedCount = 0;
|
||||
|
||||
Logger.LogInformation("Scanning WinSxS manifests in {Path}", manifestsDir);
|
||||
|
||||
try
|
||||
{
|
||||
var manifests = Directory.EnumerateFiles(manifestsDir, "*.manifest", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var manifestPath in manifests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (processedCount >= MaxManifests)
|
||||
{
|
||||
Logger.LogWarning("Reached maximum manifest limit ({Max}); truncating results.", MaxManifests);
|
||||
break;
|
||||
}
|
||||
|
||||
var record = AnalyzeManifest(manifestPath, warnings, cancellationToken);
|
||||
if (record is not null)
|
||||
{
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to enumerate WinSxS manifests");
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
Logger.LogInformation("No valid WinSxS assemblies found; skipping analyzer.");
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||
}
|
||||
|
||||
foreach (var warning in warnings.Take(10))
|
||||
{
|
||||
Logger.LogWarning("WinSxS scan warning: {Warning}", warning);
|
||||
}
|
||||
|
||||
Logger.LogInformation("Discovered {Count} WinSxS assemblies from {Processed} manifests", records.Count, processedCount);
|
||||
|
||||
// Sort for deterministic output
|
||||
records.Sort();
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
|
||||
}
|
||||
|
||||
private OSPackageRecord? AnalyzeManifest(
|
||||
string manifestPath,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = _parser.Parse(manifestPath, cancellationToken);
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build PURL using assembly identity
|
||||
var assemblyIdentity = WinSxSManifestParser.BuildAssemblyIdentityString(metadata);
|
||||
var purl = PackageUrlBuilder.BuildWindowsWinSxS(metadata.Name, metadata.Version, metadata.ProcessorArchitecture);
|
||||
|
||||
// Build vendor metadata
|
||||
var vendorMetadata = BuildVendorMetadata(metadata);
|
||||
|
||||
// Build file evidence
|
||||
var files = metadata.Files.Select(f => new OSPackageFileEvidence(
|
||||
f.Name,
|
||||
layerDigest: null,
|
||||
sha256: FormatHash(f.Hash, f.HashAlgorithm),
|
||||
sizeBytes: f.Size,
|
||||
isConfigFile: f.Name.EndsWith(".config", StringComparison.OrdinalIgnoreCase)
|
||||
)).ToList();
|
||||
|
||||
// Add manifest file itself
|
||||
files.Insert(0, new OSPackageFileEvidence(
|
||||
Path.GetFileName(manifestPath),
|
||||
layerDigest: null,
|
||||
sha256: null,
|
||||
sizeBytes: null,
|
||||
isConfigFile: true));
|
||||
|
||||
return new OSPackageRecord(
|
||||
AnalyzerId,
|
||||
purl,
|
||||
metadata.Name,
|
||||
metadata.Version,
|
||||
metadata.ProcessorArchitecture,
|
||||
PackageEvidenceSource.WindowsWinSxS,
|
||||
epoch: null,
|
||||
release: null,
|
||||
sourcePackage: ExtractPublisher(metadata.Name),
|
||||
license: null,
|
||||
cveHints: metadata.KbReference is not null ? [metadata.KbReference] : null,
|
||||
provides: null,
|
||||
depends: null,
|
||||
files: files,
|
||||
vendorMetadata: vendorMetadata);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildVendorMetadata(WinSxSAssemblyMetadata metadata)
|
||||
{
|
||||
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["winsxs:name"] = metadata.Name,
|
||||
["winsxs:version"] = metadata.Version,
|
||||
["winsxs:arch"] = metadata.ProcessorArchitecture,
|
||||
["winsxs:manifest_path"] = metadata.ManifestPath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.PublicKeyToken))
|
||||
{
|
||||
vendorMetadata["winsxs:public_key_token"] = metadata.PublicKeyToken;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Language) && metadata.Language != "*")
|
||||
{
|
||||
vendorMetadata["winsxs:language"] = metadata.Language;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Type))
|
||||
{
|
||||
vendorMetadata["winsxs:type"] = metadata.Type;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.VersionScope))
|
||||
{
|
||||
vendorMetadata["winsxs:version_scope"] = metadata.VersionScope;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.CatalogPath))
|
||||
{
|
||||
vendorMetadata["winsxs:catalog_path"] = metadata.CatalogPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.CatalogThumbprint))
|
||||
{
|
||||
vendorMetadata["winsxs:catalog_thumbprint"] = metadata.CatalogThumbprint;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.KbReference))
|
||||
{
|
||||
vendorMetadata["winsxs:kb_reference"] = metadata.KbReference;
|
||||
}
|
||||
|
||||
vendorMetadata["winsxs:file_count"] = metadata.Files.Count.ToString();
|
||||
|
||||
return vendorMetadata;
|
||||
}
|
||||
|
||||
private static string? FormatHash(string? hash, string? algorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = algorithm?.ToLowerInvariant() switch
|
||||
{
|
||||
"sha256" => "sha256:",
|
||||
"sha1" => "sha1:",
|
||||
"sha384" => "sha384:",
|
||||
"sha512" => "sha512:",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
return prefix + hash.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractPublisher(string assemblyName)
|
||||
{
|
||||
// Extract publisher from assembly name (e.g., "Microsoft.Windows.Common-Controls" -> "Microsoft")
|
||||
var firstDot = assemblyName.IndexOf('.');
|
||||
return firstDot > 0 ? assemblyName[..firstDot] : null;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,69 @@ public static class PackageUrlBuilder
|
||||
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL for a Windows MSI package.
|
||||
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
|
||||
/// </summary>
|
||||
public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-');
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("pkg:generic/windows-msi/");
|
||||
builder.Append(Escape(normalizedName));
|
||||
builder.Append('@');
|
||||
builder.Append(Escape(version));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(upgradeCode))
|
||||
{
|
||||
builder.Append("?upgrade_code=");
|
||||
builder.Append(EscapeQuery(upgradeCode));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL for a Windows WinSxS assembly.
|
||||
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
|
||||
/// </summary>
|
||||
public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var normalizedName = assemblyName.Trim().ToLowerInvariant();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("pkg:generic/windows-winsxs/");
|
||||
builder.Append(Escape(normalizedName));
|
||||
builder.Append('@');
|
||||
builder.Append(Escape(version));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(architecture))
|
||||
{
|
||||
builder.Append("?arch=");
|
||||
builder.Append(EscapeQuery(architecture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL for a Windows Chocolatey package.
|
||||
/// Format: pkg:chocolatey/{packageId}@{version}
|
||||
/// </summary>
|
||||
public static string BuildChocolatey(string packageId, string version)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var normalizedId = packageId.Trim().ToLowerInvariant();
|
||||
return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
@@ -9,4 +9,7 @@ public enum PackageEvidenceSource
|
||||
HomebrewCellar,
|
||||
PkgutilReceipt,
|
||||
MacOsBundle,
|
||||
WindowsMsi,
|
||||
WindowsWinSxS,
|
||||
WindowsChocolatey,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user