Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,21 @@
using System;
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.Dpkg;
public sealed class DpkgAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Dpkg";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new DpkgPackageAnalyzer(loggerFactory.CreateLogger<DpkgPackageAnalyzer>());
}
}

View File

@@ -0,0 +1,267 @@
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);
}
}
}

View File

@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
internal sealed class DpkgStatusParser
{
public IReadOnlyList<DpkgPackageEntry> Parse(Stream stream, CancellationToken cancellationToken)
{
var packages = new List<DpkgPackageEntry>();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var current = new DpkgPackageEntry();
string? currentField = null;
string? line;
while ((line = reader.ReadLine()) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
CommitField();
CommitPackage();
current = new DpkgPackageEntry();
currentField = null;
continue;
}
if (char.IsWhiteSpace(line, 0))
{
var continuation = line.Trim();
if (currentField is not null)
{
current.AppendContinuation(currentField, continuation);
}
continue;
}
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
CommitField();
var fieldName = line[..separator];
var value = line[(separator + 1)..].TrimStart();
currentField = fieldName;
current.SetField(fieldName, value);
}
CommitField();
CommitPackage();
return packages;
void CommitField()
{
if (currentField is not null)
{
current.FieldCompleted(currentField);
}
}
void CommitPackage()
{
if (current.IsValid)
{
packages.Add(current);
}
}
}
}
internal sealed class DpkgPackageEntry
{
private readonly StringBuilder _descriptionBuilder = new();
private readonly Dictionary<string, string?> _metadata = new(StringComparer.OrdinalIgnoreCase);
private string? _currentMultilineField;
public string? Name { get; private set; }
public string? Version { get; private set; }
public string? Architecture { get; private set; }
public string? Status { get; private set; }
public string? Source { get; private set; }
public string? Description { get; private set; }
public string? Homepage { get; private set; }
public string? Maintainer { get; private set; }
public string? Origin { get; private set; }
public string? Priority { get; private set; }
public string? Section { get; private set; }
public string? License { get; private set; }
public List<string> Depends { get; } = new();
public List<string> PreDepends { get; } = new();
public List<string> Provides { get; } = new();
public List<string> Recommends { get; } = new();
public List<string> Suggests { get; } = new();
public List<string> Replaces { get; } = new();
public List<DpkgConffileEntry> Conffiles { get; } = new();
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
public bool IsValid => !string.IsNullOrWhiteSpace(Name)
&& !string.IsNullOrWhiteSpace(Version)
&& !string.IsNullOrWhiteSpace(Architecture)
&& !string.IsNullOrWhiteSpace(Status);
public void SetField(string fieldName, string value)
{
switch (fieldName)
{
case "Package":
Name = value;
break;
case "Version":
Version = value;
break;
case "Architecture":
Architecture = value;
break;
case "Status":
Status = value;
break;
case "Source":
Source = value;
break;
case "Description":
_descriptionBuilder.Clear();
_descriptionBuilder.Append(value);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
break;
case "Homepage":
Homepage = value;
break;
case "Maintainer":
Maintainer = value;
break;
case "Origin":
Origin = value;
break;
case "Priority":
Priority = value;
break;
case "Section":
Section = value;
break;
case "License":
License = value;
break;
case "Depends":
Depends.AddRange(ParseRelations(value));
break;
case "Pre-Depends":
PreDepends.AddRange(ParseRelations(value));
break;
case "Provides":
Provides.AddRange(ParseRelations(value));
break;
case "Recommends":
Recommends.AddRange(ParseRelations(value));
break;
case "Suggests":
Suggests.AddRange(ParseRelations(value));
break;
case "Replaces":
Replaces.AddRange(ParseRelations(value));
break;
case "Conffiles":
_currentMultilineField = fieldName;
if (!string.IsNullOrWhiteSpace(value))
{
AddConffile(value);
}
break;
default:
_metadata[fieldName] = value;
break;
}
}
public void AppendContinuation(string fieldName, string continuation)
{
if (string.Equals(fieldName, "Description", StringComparison.OrdinalIgnoreCase))
{
if (_descriptionBuilder.Length > 0)
{
_descriptionBuilder.AppendLine();
}
_descriptionBuilder.Append(continuation);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
return;
}
if (string.Equals(fieldName, "Conffiles", StringComparison.OrdinalIgnoreCase))
{
AddConffile(continuation);
_currentMultilineField = fieldName;
return;
}
if (_metadata.TryGetValue(fieldName, out var existing) && existing is not null)
{
_metadata[fieldName] = $"{existing}{Environment.NewLine}{continuation}";
}
else
{
_metadata[fieldName] = continuation;
}
}
public void FieldCompleted(string fieldName)
{
if (string.Equals(fieldName, _currentMultilineField, StringComparison.OrdinalIgnoreCase))
{
_currentMultilineField = null;
}
}
private void AddConffile(string value)
{
var tokens = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length >= 1)
{
var path = tokens[0];
var checksum = tokens.Length >= 2 ? tokens[1] : null;
Conffiles.Add(new DpkgConffileEntry(path, checksum));
}
}
private static IEnumerable<string> ParseRelations(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
foreach (var segment in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return segment;
}
}
}
internal sealed record DpkgConffileEntry(string Path, string? Checksum);

View File

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

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</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>

View File

@@ -0,0 +1,19 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzers.os.dpkg",
"displayName": "StellaOps Debian dpkg Analyzer",
"version": "0.1.0-alpha",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.OS.Dpkg.dll"
},
"capabilities": [
"os-analyzer",
"dpkg"
],
"metadata": {
"org.stellaops.analyzer.kind": "os",
"org.stellaops.analyzer.id": "dpkg"
}
}