Restructure solution layout by module
This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user