268 lines
9.4 KiB
C#
268 lines
9.4 KiB
C#
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<OSPackageRecord> EmptyPackages =
|
|
new ReadOnlyCollection<OSPackageRecord>(System.Array.Empty<OSPackageRecord>());
|
|
|
|
private readonly DpkgStatusParser _parser = new();
|
|
|
|
public DpkgPackageAnalyzer(ILogger<DpkgPackageAnalyzer> logger)
|
|
: base(logger)
|
|
{
|
|
}
|
|
|
|
public override string AnalyzerId => "dpkg";
|
|
|
|
protected override ValueTask<IReadOnlyList<OSPackageRecord>> 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<IReadOnlyList<OSPackageRecord>>(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<OSPackageRecord>();
|
|
|
|
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<string, string?>(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<IReadOnlyList<OSPackageRecord>>(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<OSPackageFileEvidence> BuildFileEvidence(string infoDirectory, DpkgPackageEntry entry, CancellationToken cancellationToken)
|
|
{
|
|
if (!Directory.Exists(infoDirectory))
|
|
{
|
|
return Array.Empty<OSPackageFileEvidence>();
|
|
}
|
|
|
|
var files = new Dictionary<string, FileEvidenceBuilder>(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<OSPackageFileEvidence>();
|
|
}
|
|
|
|
var evidence = files.Values
|
|
.Select(builder => builder.ToEvidence())
|
|
.OrderBy(e => e)
|
|
.ToArray();
|
|
|
|
return new ReadOnlyCollection<OSPackageFileEvidence>(evidence);
|
|
}
|
|
|
|
private static IEnumerable<string> 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<string, string> Digests { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public OSPackageFileEvidence ToEvidence()
|
|
{
|
|
return new OSPackageFileEvidence(Path, isConfigFile: IsConfig, digests: Digests);
|
|
}
|
|
}
|
|
}
|