Files

154 lines
5.2 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using Xunit;
namespace Ngino.Client.Tests;
public sealed class PackageAuditTests
{
[Fact]
public async Task Solution_HasNoKnownVulnerablePackages()
{
var solutionPath = FindSolutionPath();
var result = await RunDotnetPackageAuditAsync(solutionPath);
Assert.True(
result.ExitCode == 0,
$"Package audit command failed with exit code {result.ExitCode}.{Environment.NewLine}{result.Output}{result.Error}");
using var document = JsonDocument.Parse(result.Output);
var findings = new List<string>();
CollectVulnerablePackages(document.RootElement, findings);
Assert.True(
findings.Count == 0,
"Known vulnerable packages were found:"
+ Environment.NewLine
+ string.Join(Environment.NewLine, findings));
}
private static string FindSolutionPath()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var solutionPath = Path.Combine(directory.FullName, "Ngino.sln");
if (File.Exists(solutionPath))
{
return solutionPath;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Could not find Ngino.sln from the test output directory.");
}
private static async Task<CommandResult> RunDotnetPackageAuditAsync(string solutionPath)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = Path.GetDirectoryName(solutionPath)!,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("list");
startInfo.ArgumentList.Add(solutionPath);
startInfo.ArgumentList.Add("package");
startInfo.ArgumentList.Add("--vulnerable");
startInfo.ArgumentList.Add("--include-transitive");
startInfo.ArgumentList.Add("--format");
startInfo.ArgumentList.Add("json");
startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start dotnet package audit.");
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Best effort; the assertion below will fail with the timeout message.
}
throw new TimeoutException("dotnet package audit did not finish within 2 minutes.");
}
return new CommandResult(
process.ExitCode,
await outputTask,
await errorTask);
}
private static void CollectVulnerablePackages(JsonElement element, List<string> findings)
{
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty("vulnerabilities", out var vulnerabilities)
&& vulnerabilities.ValueKind == JsonValueKind.Array
&& vulnerabilities.GetArrayLength() > 0)
{
findings.Add(DescribePackageFinding(element, vulnerabilities));
}
foreach (var property in element.EnumerateObject())
{
CollectVulnerablePackages(property.Value, findings);
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
CollectVulnerablePackages(item, findings);
}
}
}
private static string DescribePackageFinding(JsonElement package, JsonElement vulnerabilities)
{
var id = package.TryGetProperty("id", out var idElement)
? idElement.GetString()
: "<unknown package>";
var version = package.TryGetProperty("resolvedVersion", out var versionElement)
? versionElement.GetString()
: "<unknown version>";
var advisories = vulnerabilities
.EnumerateArray()
.Select(vulnerability => DescribeVulnerability(vulnerability))
.ToArray();
return $"- {id} {version}: {string.Join(", ", advisories)}";
}
private static string DescribeVulnerability(JsonElement vulnerability)
{
var severity = vulnerability.TryGetProperty("severity", out var severityElement)
? severityElement.GetString()
: "unknown severity";
var advisoryUrl = vulnerability.TryGetProperty("advisoryUrl", out var advisoryElement)
? advisoryElement.GetString()
: "unknown advisory";
return $"{severity} {advisoryUrl}";
}
private sealed record CommandResult(int ExitCode, string Output, string Error);
}