using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Analyzers.OS; using StellaOps.Scanner.Analyzers.OS.Abstractions; using StellaOps.Scanner.Analyzers.OS.Analyzers; using StellaOps.Scanner.Analyzers.OS.Helpers; namespace StellaOps.Scanner.Analyzers.OS.Dpkg; internal sealed class DpkgPackageAnalyzer : OsPackageAnalyzerBase { private static readonly IReadOnlyList EmptyPackages = new ReadOnlyCollection(System.Array.Empty()); private readonly DpkgStatusParser _parser = new(); public DpkgPackageAnalyzer(ILogger logger) : base(logger) { } public override string AnalyzerId => "dpkg"; protected override ValueTask> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken) { var statusPath = Path.Combine(context.RootPath, "var", "lib", "dpkg", "status"); if (!File.Exists(statusPath)) { Logger.LogInformation("dpkg status file not found at {Path}; skipping analyzer.", statusPath); return ValueTask.FromResult>(EmptyPackages); } using var stream = File.OpenRead(statusPath); var entries = _parser.Parse(stream, cancellationToken); var infoDirectory = Path.Combine(context.RootPath, "var", "lib", "dpkg", "info"); var records = new List(); foreach (var entry in entries) { if (!IsInstalled(entry.Status)) { continue; } if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(entry.Version) || string.IsNullOrWhiteSpace(entry.Architecture)) { continue; } var versionParts = PackageVersionParser.ParseDebianVersion(entry.Version); var sourceName = ParseSource(entry.Source) ?? entry.Name; var distribution = entry.Origin; if (distribution is null && entry.Metadata.TryGetValue("origin", out var originValue)) { distribution = originValue; } distribution ??= "debian"; var purl = PackageUrlBuilder.BuildDebian(distribution!, entry.Name, entry.Version, entry.Architecture); var vendorMetadata = new Dictionary(StringComparer.Ordinal) { ["source"] = entry.Source, ["homepage"] = entry.Homepage, ["maintainer"] = entry.Maintainer, ["origin"] = entry.Origin, ["priority"] = entry.Priority, ["section"] = entry.Section, }; foreach (var kvp in entry.Metadata) { vendorMetadata[$"dpkg:{kvp.Key}"] = kvp.Value; } var dependencies = entry.Depends.Concat(entry.PreDepends).ToArray(); var provides = entry.Provides.ToArray(); var fileEvidence = BuildFileEvidence(infoDirectory, entry, cancellationToken); var cveHints = CveHintExtractor.Extract(entry.Description, string.Join(' ', dependencies), string.Join(' ', provides)); var record = new OSPackageRecord( AnalyzerId, purl, entry.Name, versionParts.UpstreamVersion, entry.Architecture, PackageEvidenceSource.DpkgStatus, epoch: versionParts.Epoch, release: versionParts.Revision, sourcePackage: sourceName, license: entry.License, cveHints: cveHints, provides: provides, depends: dependencies, files: fileEvidence, vendorMetadata: vendorMetadata); records.Add(record); } records.Sort(); return ValueTask.FromResult>(records); } private static bool IsInstalled(string? status) => status?.Contains("install ok installed", System.StringComparison.OrdinalIgnoreCase) == true; private static string? ParseSource(string? sourceField) { if (string.IsNullOrWhiteSpace(sourceField)) { return null; } var parts = sourceField.Split(' ', 2, System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries); return parts.Length == 0 ? null : parts[0]; } private static IReadOnlyList BuildFileEvidence(string infoDirectory, DpkgPackageEntry entry, CancellationToken cancellationToken) { if (!Directory.Exists(infoDirectory)) { return Array.Empty(); } var files = new Dictionary(StringComparer.Ordinal); void EnsureFile(string path) { if (!files.TryGetValue(path, out _)) { files[path] = new FileEvidenceBuilder(path); } } foreach (var conffile in entry.Conffiles) { var normalized = conffile.Path.Trim(); if (string.IsNullOrWhiteSpace(normalized)) { continue; } EnsureFile(normalized); files[normalized].IsConfig = true; if (!string.IsNullOrWhiteSpace(conffile.Checksum)) { files[normalized].Digests["md5"] = conffile.Checksum.Trim(); } } foreach (var candidate in GetInfoFileCandidates(entry.Name!, entry.Architecture!)) { var listPath = Path.Combine(infoDirectory, candidate + ".list"); if (File.Exists(listPath)) { foreach (var line in File.ReadLines(listPath)) { cancellationToken.ThrowIfCancellationRequested(); var trimmed = line.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) { continue; } EnsureFile(trimmed); } } var confFilePath = Path.Combine(infoDirectory, candidate + ".conffiles"); if (File.Exists(confFilePath)) { foreach (var line in File.ReadLines(confFilePath)) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(line)) { continue; } var parts = line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries); if (parts.Length == 0) { continue; } var path = parts[0]; EnsureFile(path); files[path].IsConfig = true; if (parts.Length >= 2) { files[path].Digests["md5"] = parts[1]; } } } var md5sumsPath = Path.Combine(infoDirectory, candidate + ".md5sums"); if (File.Exists(md5sumsPath)) { foreach (var line in File.ReadLines(md5sumsPath)) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(line)) { continue; } var parts = line.Split(' ', 2, System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries); if (parts.Length != 2) { continue; } var hash = parts[0]; var path = parts[1]; EnsureFile(path); files[path].Digests["md5"] = hash; } } } if (files.Count == 0) { return Array.Empty(); } var evidence = files.Values .Select(builder => builder.ToEvidence()) .OrderBy(e => e) .ToArray(); return new ReadOnlyCollection(evidence); } private static IEnumerable GetInfoFileCandidates(string packageName, string architecture) { yield return packageName + ":" + architecture; yield return packageName; } private sealed class FileEvidenceBuilder { public FileEvidenceBuilder(string path) { Path = path; } public string Path { get; } public bool IsConfig { get; set; } public Dictionary Digests { get; } = new(StringComparer.OrdinalIgnoreCase); public OSPackageFileEvidence ToEvidence() { return new OSPackageFileEvidence(Path, isConfigFile: IsConfig, digests: Digests); } } }