Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// <copyright file="FeedSnapshotCommand.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Testing.FixtureHarvester.Models;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Feed Snapshot command - capture vulnerability feed snapshots from Concelier for deterministic testing.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-005)
|
||||
/// </summary>
|
||||
internal static class FeedSnapshotCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute the feed snapshot command to capture vulnerability feed data.
|
||||
/// </summary>
|
||||
internal static async Task ExecuteAsync(string feedType, string? concelierUrl, int count, string? output)
|
||||
{
|
||||
Console.WriteLine($"Capturing {feedType} feed snapshot...");
|
||||
|
||||
var baseUrl = concelierUrl ?? "http://localhost:5010";
|
||||
var outputDir = output ?? "src/__Tests/fixtures/feeds";
|
||||
var fixtureId = $"feed-{feedType.ToLowerInvariant()}-{count}";
|
||||
var fixtureDir = Path.Combine(outputDir, fixtureId);
|
||||
|
||||
Directory.CreateDirectory(fixtureDir);
|
||||
Directory.CreateDirectory(Path.Combine(fixtureDir, "raw"));
|
||||
|
||||
var capturedAt = DateTime.UtcNow;
|
||||
var advisories = new List<JsonDocument>();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
Console.WriteLine($"Connecting to Concelier: {baseUrl}");
|
||||
|
||||
// Route depends on feed type
|
||||
var apiEndpoint = GetFeedEndpoint(feedType);
|
||||
Console.WriteLine($" Endpoint: {apiEndpoint}");
|
||||
|
||||
// Fetch advisories
|
||||
var response = await client.GetAsync($"{apiEndpoint}?limit={count}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"WARNING: Concelier returned {response.StatusCode}");
|
||||
Console.WriteLine("Falling back to sample feed generation...");
|
||||
await GenerateSampleFeedAsync(feedType, count, fixtureDir, capturedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Save raw response
|
||||
var rawPath = Path.Combine(fixtureDir, "raw", $"{feedType}_snapshot.json");
|
||||
await File.WriteAllTextAsync(rawPath, content);
|
||||
Console.WriteLine($" Raw snapshot: {rawPath}");
|
||||
|
||||
// Compute hash
|
||||
var sha256 = await ComputeSha256Async(rawPath);
|
||||
Console.WriteLine($" SHA-256: {sha256}");
|
||||
|
||||
// Parse and normalize
|
||||
var doc = JsonDocument.Parse(content);
|
||||
var normalizedPath = Path.Combine(fixtureDir, "normalized.ndjson");
|
||||
await NormalizeFeedAsync(doc, normalizedPath, feedType);
|
||||
Console.WriteLine($" Normalized: {normalizedPath}");
|
||||
|
||||
// Create metadata
|
||||
var meta = new FeedSnapshotMeta
|
||||
{
|
||||
Id = fixtureId,
|
||||
FeedType = feedType,
|
||||
Source = "concelier",
|
||||
ConcelierEndpoint = $"{baseUrl}{apiEndpoint}",
|
||||
Count = count,
|
||||
CapturedAt = capturedAt.ToString("O"),
|
||||
Sha256 = sha256,
|
||||
RefreshPolicy = "manual",
|
||||
Notes = $"Captured from Concelier feed snapshot endpoint",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "meta.json");
|
||||
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(metaPath, metaJson);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"✓ Feed snapshot captured: {fixtureDir}");
|
||||
Console.WriteLine($" Type: {feedType}");
|
||||
Console.WriteLine($" Count: {count}");
|
||||
Console.WriteLine($" Source: {baseUrl}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.WriteLine($"WARNING: Could not connect to Concelier: {ex.Message}");
|
||||
Console.WriteLine("Generating sample feed fixtures instead...");
|
||||
await GenerateSampleFeedAsync(feedType, count, fixtureDir, capturedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFeedEndpoint(string feedType)
|
||||
{
|
||||
return feedType.ToUpperInvariant() switch
|
||||
{
|
||||
"OSV" => "/api/v1/feeds/osv/advisories",
|
||||
"GHSA" => "/api/v1/feeds/ghsa/advisories",
|
||||
"NVD" => "/api/v1/feeds/nvd/advisories",
|
||||
"EPSS" => "/api/v1/feeds/epss/scores",
|
||||
"KEV" => "/api/v1/feeds/kev/catalog",
|
||||
"OVAL" => "/api/v1/feeds/oval/definitions",
|
||||
_ => $"/api/v1/feeds/{feedType.ToLowerInvariant()}/advisories",
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task GenerateSampleFeedAsync(string feedType, int count, string fixtureDir, DateTime capturedAt)
|
||||
{
|
||||
Console.WriteLine($"Generating {count} sample {feedType} advisories...");
|
||||
|
||||
var rawDir = Path.Combine(fixtureDir, "raw");
|
||||
var advisories = GenerateSampleAdvisories(feedType, count);
|
||||
|
||||
// Write as NDJSON
|
||||
var rawPath = Path.Combine(rawDir, $"{feedType}_sample.ndjson");
|
||||
await using var writer = File.CreateText(rawPath);
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(advisory));
|
||||
}
|
||||
|
||||
// Compute hash
|
||||
var sha256 = await ComputeSha256Async(rawPath);
|
||||
|
||||
// Create meta
|
||||
var meta = new FeedSnapshotMeta
|
||||
{
|
||||
Id = $"feed-{feedType.ToLowerInvariant()}-{count}",
|
||||
FeedType = feedType,
|
||||
Source = "generated-sample",
|
||||
ConcelierEndpoint = null,
|
||||
Count = count,
|
||||
CapturedAt = capturedAt.ToString("O"),
|
||||
Sha256 = sha256,
|
||||
RefreshPolicy = "manual",
|
||||
Notes = "Generated sample data for offline testing. Replace with real feed data when Concelier is available.",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "meta.json");
|
||||
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(metaPath, metaJson);
|
||||
|
||||
Console.WriteLine($"✓ Sample feed generated: {fixtureDir}");
|
||||
Console.WriteLine($" Advisories: {count}");
|
||||
Console.WriteLine($" SHA-256: {sha256}");
|
||||
}
|
||||
|
||||
private static List<object> GenerateSampleAdvisories(string feedType, int count)
|
||||
{
|
||||
var advisories = new List<object>();
|
||||
var ecosystems = new[] { "PyPI", "npm", "Go", "Maven", "NuGet", "RubyGems", "crates.io" };
|
||||
var severities = new[] { "CRITICAL", "HIGH", "MEDIUM", "LOW" };
|
||||
|
||||
for (int i = 1; i <= count; i++)
|
||||
{
|
||||
var ecosystem = ecosystems[i % ecosystems.Length];
|
||||
var severity = severities[i % severities.Length];
|
||||
|
||||
advisories.Add(feedType.ToUpperInvariant() switch
|
||||
{
|
||||
"OSV" => new
|
||||
{
|
||||
id = $"OSV-SAMPLE-{i:D4}",
|
||||
summary = $"Sample vulnerability {i} in {ecosystem} package",
|
||||
details = $"This is a sample {severity.ToLowerInvariant()} vulnerability for testing purposes.",
|
||||
affected = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
package = new { ecosystem = ecosystem, name = $"sample-package-{i}" },
|
||||
ranges = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "ECOSYSTEM",
|
||||
events = new object[]
|
||||
{
|
||||
new { introduced = "0" },
|
||||
new { @fixed = $"1.{i}.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
severity = new[] { new { type = "CVSS_V3", score = $"{6.0 + (i % 4)}.{i % 10}" } },
|
||||
published = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
modified = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
"GHSA" => new
|
||||
{
|
||||
ghsaId = $"GHSA-sample-{i:D4}",
|
||||
summary = $"Sample GHSA vulnerability {i}",
|
||||
severity = severity,
|
||||
cvss = new { score = 6.0 + (i % 4), vectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N" },
|
||||
publishedAt = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
updatedAt = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
"NVD" => new
|
||||
{
|
||||
cve = new
|
||||
{
|
||||
id = $"CVE-2024-{10000 + i}",
|
||||
sourceIdentifier = "sample@stellaops.dev",
|
||||
published = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
lastModified = DateTime.UtcNow.ToString("O"),
|
||||
descriptions = new[]
|
||||
{
|
||||
new { lang = "en", value = $"Sample NVD vulnerability {i} for testing." }
|
||||
},
|
||||
metrics = new
|
||||
{
|
||||
cvssMetricV31 = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cvssData = new
|
||||
{
|
||||
version = "3.1",
|
||||
baseScore = 6.0 + (i % 4),
|
||||
baseSeverity = severity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => new
|
||||
{
|
||||
id = $"SAMPLE-{feedType.ToUpperInvariant()}-{i:D4}",
|
||||
type = feedType,
|
||||
severity = severity,
|
||||
created = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return advisories;
|
||||
}
|
||||
|
||||
private static async Task NormalizeFeedAsync(JsonDocument doc, string outputPath, string feedType)
|
||||
{
|
||||
await using var writer = File.CreateText(outputPath);
|
||||
|
||||
// Handle different feed response structures
|
||||
JsonElement items;
|
||||
if (doc.RootElement.TryGetProperty("items", out items) ||
|
||||
doc.RootElement.TryGetProperty("advisories", out items) ||
|
||||
doc.RootElement.TryGetProperty("vulnerabilities", out items) ||
|
||||
doc.RootElement.TryGetProperty("data", out items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
await writer.WriteLineAsync(item.GetRawText());
|
||||
}
|
||||
}
|
||||
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
await writer.WriteLineAsync(item.GetRawText());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single object
|
||||
await writer.WriteLineAsync(doc.RootElement.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||
return "sha256:" + BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for captured feed snapshots.
|
||||
/// </summary>
|
||||
internal class FeedSnapshotMeta
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string FeedType { get; set; } = string.Empty;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? ConcelierEndpoint { get; set; }
|
||||
public int Count { get; set; }
|
||||
public string CapturedAt { get; set; } = string.Empty;
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public string RefreshPolicy { get; set; } = "manual";
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
}
|
||||
277
src/__Tests/Tools/FixtureHarvester/Commands/OciPinCommand.cs
Normal file
277
src/__Tests/Tools/FixtureHarvester/Commands/OciPinCommand.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
// <copyright file="OciPinCommand.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Testing.FixtureHarvester.Models;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// OCI Pin command - retrieve and pin OCI image digests for deterministic testing.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-004)
|
||||
/// </summary>
|
||||
internal static class OciPinCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute the OCI pin command to capture image digest metadata.
|
||||
/// </summary>
|
||||
internal static async Task ExecuteAsync(string imageRef, string? output, bool verify)
|
||||
{
|
||||
Console.WriteLine($"Pinning OCI image: {imageRef}");
|
||||
|
||||
// Parse image reference
|
||||
var (registry, repository, tag) = ParseImageRef(imageRef);
|
||||
Console.WriteLine($" Registry: {registry}");
|
||||
Console.WriteLine($" Repository: {repository}");
|
||||
Console.WriteLine($" Tag: {tag}");
|
||||
|
||||
// Get manifest digest via OCI Distribution API
|
||||
var manifestDigest = await GetManifestDigestAsync(registry, repository, tag);
|
||||
if (string.IsNullOrEmpty(manifestDigest))
|
||||
{
|
||||
Console.WriteLine("ERROR: Could not retrieve manifest digest.");
|
||||
Console.WriteLine(" Ensure the image exists and you have access to the registry.");
|
||||
Console.WriteLine(" For private registries, authenticate with: docker login <registry>");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($" Manifest Digest: {manifestDigest}");
|
||||
|
||||
// Build pinned reference
|
||||
var pinnedRef = $"{registry}/{repository}@{manifestDigest}";
|
||||
Console.WriteLine($" Pinned Reference: {pinnedRef}");
|
||||
|
||||
// Fetch config digest (for verification)
|
||||
var configDigest = await GetConfigDigestAsync(registry, repository, manifestDigest);
|
||||
Console.WriteLine($" Config Digest: {configDigest ?? "N/A"}");
|
||||
|
||||
// Create fixture metadata
|
||||
var fixtureId = SanitizeId($"oci-{repository.Replace('/', '-')}-{tag}");
|
||||
var fixtureDir = Path.Combine(output ?? "src/__Tests/fixtures/oci", fixtureId);
|
||||
Directory.CreateDirectory(fixtureDir);
|
||||
|
||||
var meta = new OciFixtureMeta
|
||||
{
|
||||
Id = fixtureId,
|
||||
ImageReference = imageRef,
|
||||
PinnedReference = pinnedRef,
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Tag = tag,
|
||||
ManifestDigest = manifestDigest,
|
||||
ConfigDigest = configDigest,
|
||||
PinnedAt = DateTime.UtcNow.ToString("O"),
|
||||
Verified = verify && await VerifyDigestAsync(registry, repository, manifestDigest),
|
||||
Notes = $"OCI image pinned for deterministic testing",
|
||||
RefreshPolicy = "manual",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "oci-pin.json");
|
||||
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
});
|
||||
await File.WriteAllTextAsync(metaPath, metaJson);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"✓ OCI image pinned: {fixtureDir}");
|
||||
Console.WriteLine($" Metadata: {metaPath}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Usage in tests:");
|
||||
Console.WriteLine($" // Use pinned digest reference:");
|
||||
Console.WriteLine($" var imageRef = \"{pinnedRef}\";");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Next steps:");
|
||||
Console.WriteLine("1. Add to fixtures.manifest.yml under 'oci' section");
|
||||
Console.WriteLine("2. Run: fixture-harvester validate --path src/__Tests/fixtures");
|
||||
}
|
||||
|
||||
private static (string registry, string repository, string tag) ParseImageRef(string imageRef)
|
||||
{
|
||||
var tag = "latest";
|
||||
var repository = imageRef;
|
||||
var registry = "docker.io";
|
||||
|
||||
// Extract tag
|
||||
var tagIndex = imageRef.LastIndexOf(':');
|
||||
var slashAfterTag = tagIndex > 0 ? imageRef.IndexOf('/', tagIndex) : -1;
|
||||
if (tagIndex > 0 && slashAfterTag < 0 && !imageRef.Substring(tagIndex + 1).Contains('/'))
|
||||
{
|
||||
tag = imageRef.Substring(tagIndex + 1);
|
||||
repository = imageRef.Substring(0, tagIndex);
|
||||
}
|
||||
|
||||
// Extract registry
|
||||
var firstSlash = repository.IndexOf('/');
|
||||
if (firstSlash > 0)
|
||||
{
|
||||
var possibleRegistry = repository.Substring(0, firstSlash);
|
||||
if (possibleRegistry.Contains('.') || possibleRegistry.Contains(':') || possibleRegistry == "localhost")
|
||||
{
|
||||
registry = possibleRegistry;
|
||||
repository = repository.Substring(firstSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Docker Hub library images
|
||||
if (registry == "docker.io" && !repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
|
||||
return (registry, repository, tag);
|
||||
}
|
||||
|
||||
private static async Task<string?> GetManifestDigestAsync(string registry, string repository, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
|
||||
// Handle Docker Hub auth token
|
||||
if (registry == "docker.io")
|
||||
{
|
||||
var tokenUrl = $"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repository}:pull";
|
||||
var tokenResponse = await client.GetStringAsync(tokenUrl);
|
||||
var tokenDoc = JsonDocument.Parse(tokenResponse);
|
||||
var token = tokenDoc.RootElement.GetProperty("token").GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
// Request manifest with digest header
|
||||
var registryHost = registry == "docker.io" ? "registry-1.docker.io" : registry;
|
||||
var manifestUrl = $"https://{registryHost}/v2/{repository}/manifests/{tag}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Head, manifestUrl);
|
||||
// Accept multiple manifest types
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.list.v2+json"));
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// Get Docker-Content-Digest header
|
||||
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues))
|
||||
{
|
||||
return digestValues.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" Warning: Could not fetch manifest: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> GetConfigDigestAsync(string registry, string repository, string manifestDigest)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
|
||||
// Handle Docker Hub auth
|
||||
if (registry == "docker.io")
|
||||
{
|
||||
var tokenUrl = $"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repository}:pull";
|
||||
var tokenResponse = await client.GetStringAsync(tokenUrl);
|
||||
var tokenDoc = JsonDocument.Parse(tokenResponse);
|
||||
var token = tokenDoc.RootElement.GetProperty("token").GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
var registryHost = registry == "docker.io" ? "registry-1.docker.io" : registry;
|
||||
var manifestUrl = $"https://{registryHost}/v2/{repository}/manifests/{manifestDigest}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(content);
|
||||
|
||||
// Get config digest from manifest
|
||||
if (doc.RootElement.TryGetProperty("config", out var configElement) &&
|
||||
configElement.TryGetProperty("digest", out var digestElement))
|
||||
{
|
||||
return digestElement.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> VerifyDigestAsync(string registry, string repository, string manifestDigest)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
|
||||
if (registry == "docker.io")
|
||||
{
|
||||
var tokenUrl = $"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repository}:pull";
|
||||
var tokenResponse = await client.GetStringAsync(tokenUrl);
|
||||
var tokenDoc = JsonDocument.Parse(tokenResponse);
|
||||
var token = tokenDoc.RootElement.GetProperty("token").GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
var registryHost = registry == "docker.io" ? "registry-1.docker.io" : registry;
|
||||
var manifestUrl = $"https://{registryHost}/v2/{repository}/manifests/{manifestDigest}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json"));
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var content = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// Compute SHA256 of manifest content
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(content);
|
||||
var computedDigest = "sha256:" + BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
|
||||
|
||||
return computedDigest == manifestDigest;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeId(string input)
|
||||
{
|
||||
return new string(input.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for pinned OCI image fixtures.
|
||||
/// </summary>
|
||||
internal class OciFixtureMeta
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ImageReference { get; set; } = string.Empty;
|
||||
public string PinnedReference { get; set; } = string.Empty;
|
||||
public string Registry { get; set; } = string.Empty;
|
||||
public string Repository { get; set; } = string.Empty;
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
public string ManifestDigest { get; set; } = string.Empty;
|
||||
public string? ConfigDigest { get; set; }
|
||||
public string PinnedAt { get; set; } = string.Empty;
|
||||
public bool Verified { get; set; }
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
public string RefreshPolicy { get; set; } = "manual";
|
||||
}
|
||||
507
src/__Tests/Tools/FixtureHarvester/Commands/SbomGoldenCommand.cs
Normal file
507
src/__Tests/Tools/FixtureHarvester/Commands/SbomGoldenCommand.cs
Normal file
@@ -0,0 +1,507 @@
|
||||
// <copyright file="SbomGoldenCommand.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM Golden command - generate SBOM golden fixtures from minimal container images.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-007)
|
||||
/// </summary>
|
||||
internal static class SbomGoldenCommand
|
||||
{
|
||||
private static readonly Dictionary<string, GoldenImageDefinition> KnownImages = new()
|
||||
{
|
||||
["alpine-minimal"] = new()
|
||||
{
|
||||
Id = "sbom-golden-alpine-minimal",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Minimal Alpine Linux (~5MB, ~14 packages)",
|
||||
ExpectedPackages = 14,
|
||||
Format = "cyclonedx",
|
||||
},
|
||||
["debian-slim"] = new()
|
||||
{
|
||||
Id = "sbom-golden-debian-slim",
|
||||
ImageRef = "debian:bookworm-slim",
|
||||
Description = "Debian Bookworm slim (~80MB, ~90 packages)",
|
||||
ExpectedPackages = 90,
|
||||
Format = "cyclonedx",
|
||||
},
|
||||
["distroless-static"] = new()
|
||||
{
|
||||
Id = "sbom-golden-distroless",
|
||||
ImageRef = "gcr.io/distroless/static-debian12:nonroot",
|
||||
Description = "Google Distroless static (minimal, ~2MB)",
|
||||
ExpectedPackages = 5,
|
||||
Format = "cyclonedx",
|
||||
},
|
||||
["scratch-go"] = new()
|
||||
{
|
||||
Id = "sbom-golden-scratch",
|
||||
ImageRef = "scratch",
|
||||
Description = "Empty scratch image (0 packages, filesystem only)",
|
||||
ExpectedPackages = 0,
|
||||
Format = "cyclonedx",
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Execute the SBOM golden command to generate golden SBOM fixtures.
|
||||
/// </summary>
|
||||
internal static async Task ExecuteAsync(string image, string? format, string? scanner, string? output)
|
||||
{
|
||||
Console.WriteLine($"Generating SBOM golden fixture for: {image}");
|
||||
|
||||
var outputDir = output ?? "src/__Tests/fixtures/sbom";
|
||||
var sbomFormat = format ?? "cyclonedx";
|
||||
var scannerTool = scanner ?? "syft";
|
||||
|
||||
if (image.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ListKnownImages();
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine("Generating all known golden SBOMs...\n");
|
||||
foreach (var imageDef in KnownImages.Values)
|
||||
{
|
||||
await GenerateGoldenAsync(imageDef, outputDir, sbomFormat, scannerTool);
|
||||
Console.WriteLine();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KnownImages.TryGetValue(image.ToLowerInvariant(), out var knownImage))
|
||||
{
|
||||
await GenerateGoldenAsync(knownImage, outputDir, sbomFormat, scannerTool);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Custom image reference
|
||||
var customImage = new GoldenImageDefinition
|
||||
{
|
||||
Id = $"sbom-golden-custom-{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
ImageRef = image,
|
||||
Description = $"Custom image: {image}",
|
||||
ExpectedPackages = -1, // Unknown
|
||||
Format = sbomFormat,
|
||||
};
|
||||
await GenerateGoldenAsync(customImage, outputDir, sbomFormat, scannerTool);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ListKnownImages()
|
||||
{
|
||||
Console.WriteLine("Known golden SBOM images:");
|
||||
Console.WriteLine();
|
||||
foreach (var (key, image) in KnownImages)
|
||||
{
|
||||
Console.WriteLine($" {key}");
|
||||
Console.WriteLine($" Image: {image.ImageRef}");
|
||||
Console.WriteLine($" Description: {image.Description}");
|
||||
Console.WriteLine($" Expected packages: ~{image.ExpectedPackages}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" fixture-harvester sbom-golden <image-key>");
|
||||
Console.WriteLine(" fixture-harvester sbom-golden <custom-image-ref>");
|
||||
Console.WriteLine(" fixture-harvester sbom-golden all");
|
||||
}
|
||||
|
||||
private static async Task GenerateGoldenAsync(GoldenImageDefinition imageDef, string outputDir, string format, string scanner)
|
||||
{
|
||||
Console.WriteLine($"Generating: {imageDef.Id}");
|
||||
Console.WriteLine($" Image: {imageDef.ImageRef}");
|
||||
Console.WriteLine($" Format: {format}");
|
||||
Console.WriteLine($" Scanner: {scanner}");
|
||||
|
||||
var fixtureDir = Path.Combine(outputDir, imageDef.Id);
|
||||
Directory.CreateDirectory(fixtureDir);
|
||||
Directory.CreateDirectory(Path.Combine(fixtureDir, "raw"));
|
||||
Directory.CreateDirectory(Path.Combine(fixtureDir, "expected"));
|
||||
|
||||
// Check if scanner is available
|
||||
var scannerPath = await FindScannerAsync(scanner);
|
||||
string? sbomContent;
|
||||
string? imageDigest = null;
|
||||
int packageCount = 0;
|
||||
|
||||
if (scannerPath != null)
|
||||
{
|
||||
Console.WriteLine($" Using scanner: {scannerPath}");
|
||||
|
||||
// Pull image first to get digest
|
||||
imageDigest = await PullAndGetDigestAsync(imageDef.ImageRef);
|
||||
if (imageDigest != null)
|
||||
{
|
||||
Console.WriteLine($" Image digest: {imageDigest}");
|
||||
}
|
||||
|
||||
// Run scanner
|
||||
sbomContent = await RunScannerAsync(scannerPath, imageDef.ImageRef, format);
|
||||
if (sbomContent != null)
|
||||
{
|
||||
packageCount = CountPackages(sbomContent, format);
|
||||
Console.WriteLine($" Package count: {packageCount}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" WARNING: Scanner '{scanner}' not found. Generating sample SBOM...");
|
||||
sbomContent = GenerateSampleSbom(imageDef, format);
|
||||
packageCount = imageDef.ExpectedPackages;
|
||||
}
|
||||
|
||||
if (sbomContent == null)
|
||||
{
|
||||
Console.WriteLine(" ERROR: Failed to generate SBOM.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write SBOM
|
||||
var sbomFilename = $"{imageDef.Id}.{GetFormatExtension(format)}";
|
||||
var sbomPath = Path.Combine(fixtureDir, "raw", sbomFilename);
|
||||
await File.WriteAllTextAsync(sbomPath, sbomContent);
|
||||
|
||||
// Compute hash
|
||||
var sha256 = await ComputeSha256Async(sbomPath);
|
||||
Console.WriteLine($" SHA-256: {sha256}");
|
||||
|
||||
// Create expected outputs placeholder
|
||||
var expectedPath = Path.Combine(fixtureDir, "expected", "scan-result.json");
|
||||
var expectedContent = new
|
||||
{
|
||||
imageRef = imageDef.ImageRef,
|
||||
imageDigest = imageDigest,
|
||||
packageCount = packageCount,
|
||||
format = format,
|
||||
generated = DateTime.UtcNow.ToString("O"),
|
||||
note = "Replace with actual scan expectations after baseline verification",
|
||||
};
|
||||
await File.WriteAllTextAsync(expectedPath, JsonSerializer.Serialize(expectedContent, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
// Create metadata
|
||||
var meta = new SbomGoldenMeta
|
||||
{
|
||||
Id = imageDef.Id,
|
||||
ImageRef = imageDef.ImageRef,
|
||||
ImageDigest = imageDigest,
|
||||
Description = imageDef.Description,
|
||||
Format = format,
|
||||
Scanner = scanner,
|
||||
PackageCount = packageCount,
|
||||
GeneratedAt = DateTime.UtcNow.ToString("O"),
|
||||
SbomFile = sbomFilename,
|
||||
Sha256 = sha256,
|
||||
RefreshPolicy = "manual",
|
||||
Notes = $"Golden SBOM for deterministic testing. Regenerate only when baseline changes.",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "meta.json");
|
||||
await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Console.WriteLine($" ✓ SBOM: {sbomPath}");
|
||||
Console.WriteLine($" ✓ Metadata: {metaPath}");
|
||||
Console.WriteLine($" ✓ Expected: {expectedPath}");
|
||||
}
|
||||
|
||||
private static async Task<string?> FindScannerAsync(string scanner)
|
||||
{
|
||||
var commands = scanner.ToLowerInvariant() switch
|
||||
{
|
||||
"syft" => new[] { "syft", "syft.exe" },
|
||||
"trivy" => new[] { "trivy", "trivy.exe" },
|
||||
"grype" => new[] { "grype", "grype.exe" },
|
||||
_ => new[] { scanner, $"{scanner}.exe" },
|
||||
};
|
||||
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "where" : "which",
|
||||
Arguments = cmd,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return output.Split('\n').FirstOrDefault()?.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to next option
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<string?> PullAndGetDigestAsync(string imageRef)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pull image
|
||||
var pullPsi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = $"pull {imageRef}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var pullProcess = Process.Start(pullPsi);
|
||||
if (pullProcess != null)
|
||||
{
|
||||
await pullProcess.WaitForExitAsync();
|
||||
}
|
||||
|
||||
// Get digest
|
||||
var inspectPsi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = $"inspect --format=\"{{{{.RepoDigests}}}}\" {imageRef}",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var inspectProcess = Process.Start(inspectPsi);
|
||||
if (inspectProcess != null)
|
||||
{
|
||||
var output = await inspectProcess.StandardOutput.ReadToEndAsync();
|
||||
await inspectProcess.WaitForExitAsync();
|
||||
|
||||
// Parse digest from output like [image@sha256:abc123]
|
||||
var match = System.Text.RegularExpressions.Regex.Match(output, @"sha256:[a-f0-9]{64}");
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Docker not available
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<string?> RunScannerAsync(string scannerPath, string imageRef, string format)
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = scannerPath.Contains("syft", StringComparison.OrdinalIgnoreCase)
|
||||
? $"{imageRef} -o {format}-json"
|
||||
: scannerPath.Contains("trivy", StringComparison.OrdinalIgnoreCase)
|
||||
? $"image --format {format} {imageRef}"
|
||||
: $"{imageRef} --format {format}";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = scannerPath,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Scanner failed
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GenerateSampleSbom(GoldenImageDefinition imageDef, string format)
|
||||
{
|
||||
if (format.Contains("spdx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GenerateSpdxSample(imageDef);
|
||||
}
|
||||
|
||||
return GenerateCycloneDxSample(imageDef);
|
||||
}
|
||||
|
||||
private static string GenerateCycloneDxSample(GoldenImageDefinition imageDef)
|
||||
{
|
||||
var components = new List<object>();
|
||||
for (int i = 0; i < Math.Max(1, imageDef.ExpectedPackages); i++)
|
||||
{
|
||||
components.Add(new
|
||||
{
|
||||
type = "library",
|
||||
name = $"sample-package-{i + 1}",
|
||||
version = $"1.{i}.0",
|
||||
purl = $"pkg:apk/alpine/sample-package-{i + 1}@1.{i}.0",
|
||||
});
|
||||
}
|
||||
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
serialNumber = $"urn:uuid:{Guid.NewGuid()}",
|
||||
version = 1,
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
tools = new[]
|
||||
{
|
||||
new { vendor = "StellaOps", name = "FixtureHarvester", version = "1.0.0" }
|
||||
},
|
||||
component = new
|
||||
{
|
||||
type = "container",
|
||||
name = imageDef.ImageRef.Split(':')[0].Split('/').Last(),
|
||||
version = imageDef.ImageRef.Contains(':') ? imageDef.ImageRef.Split(':').Last() : "latest",
|
||||
purl = $"pkg:oci/{imageDef.ImageRef.Replace(':', '@')}",
|
||||
},
|
||||
},
|
||||
components = components,
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(sbom, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string GenerateSpdxSample(GoldenImageDefinition imageDef)
|
||||
{
|
||||
var packages = new List<object>();
|
||||
for (int i = 0; i < Math.Max(1, imageDef.ExpectedPackages); i++)
|
||||
{
|
||||
packages.Add(new
|
||||
{
|
||||
SPDXID = $"SPDXRef-Package-{i + 1}",
|
||||
name = $"sample-package-{i + 1}",
|
||||
versionInfo = $"1.{i}.0",
|
||||
downloadLocation = "NOASSERTION",
|
||||
filesAnalyzed = false,
|
||||
});
|
||||
}
|
||||
|
||||
var sbom = new
|
||||
{
|
||||
spdxVersion = "SPDX-2.3",
|
||||
dataLicense = "CC0-1.0",
|
||||
SPDXID = "SPDXRef-DOCUMENT",
|
||||
name = imageDef.Id,
|
||||
documentNamespace = $"https://stellaops.dev/spdx/{imageDef.Id}",
|
||||
creationInfo = new
|
||||
{
|
||||
created = DateTime.UtcNow.ToString("O"),
|
||||
creators = new[] { "Tool: StellaOps-FixtureHarvester-1.0.0" },
|
||||
},
|
||||
packages = packages,
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(sbom, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static int CountPackages(string sbomContent, string format)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(sbomContent);
|
||||
|
||||
if (format.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("components", out var components))
|
||||
{
|
||||
return components.GetArrayLength();
|
||||
}
|
||||
}
|
||||
else if (format.Contains("spdx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("packages", out var packages))
|
||||
{
|
||||
return packages.GetArrayLength();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parse error
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string GetFormatExtension(string format)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" or "cyclonedx-json" => "cdx.json",
|
||||
"spdx" or "spdx-json" => "spdx.json",
|
||||
_ => "json",
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||
return "sha256:" + BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal class GoldenImageDefinition
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ImageRef { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ExpectedPackages { get; set; }
|
||||
public string Format { get; set; } = "cyclonedx";
|
||||
}
|
||||
|
||||
internal class SbomGoldenMeta
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ImageRef { get; set; } = string.Empty;
|
||||
public string? ImageDigest { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string Scanner { get; set; } = string.Empty;
|
||||
public int PackageCount { get; set; }
|
||||
public string GeneratedAt { get; set; } = string.Empty;
|
||||
public string SbomFile { get; set; } = string.Empty;
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public string RefreshPolicy { get; set; } = "manual";
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
}
|
||||
436
src/__Tests/Tools/FixtureHarvester/Commands/VexSourceCommand.cs
Normal file
436
src/__Tests/Tools/FixtureHarvester/Commands/VexSourceCommand.cs
Normal file
@@ -0,0 +1,436 @@
|
||||
// <copyright file="VexSourceCommand.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// VEX Source command - acquire OpenVEX and CSAF samples for deterministic testing.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-006)
|
||||
/// </summary>
|
||||
internal static class VexSourceCommand
|
||||
{
|
||||
private static readonly Dictionary<string, VexSourceDefinition> KnownSources = new()
|
||||
{
|
||||
["openvex-examples"] = new()
|
||||
{
|
||||
Id = "vex-openvex-examples",
|
||||
Description = "Official OpenVEX specification examples",
|
||||
Urls = new[]
|
||||
{
|
||||
"https://raw.githubusercontent.com/openvex/examples/main/csaf/vex_container.json",
|
||||
"https://raw.githubusercontent.com/openvex/examples/main/csaf/vex_single_product.json",
|
||||
},
|
||||
Format = "openvex",
|
||||
},
|
||||
["csaf-redhat"] = new()
|
||||
{
|
||||
Id = "vex-csaf-redhat-sample",
|
||||
Description = "Red Hat CSAF VEX document samples",
|
||||
Urls = new[]
|
||||
{
|
||||
"https://access.redhat.com/security/data/csaf/v2/advisories/2024/rhsa-2024_0001.json",
|
||||
},
|
||||
Format = "csaf",
|
||||
},
|
||||
["alpine-secdb"] = new()
|
||||
{
|
||||
Id = "vex-alpine-secdb",
|
||||
Description = "Alpine Linux security database sample",
|
||||
Urls = new[]
|
||||
{
|
||||
"https://secdb.alpinelinux.org/v3.19/main.json",
|
||||
},
|
||||
Format = "alpine",
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Execute the VEX source command to download VEX/CSAF samples.
|
||||
/// </summary>
|
||||
internal static async Task ExecuteAsync(string source, string? customUrl, string? output)
|
||||
{
|
||||
Console.WriteLine($"Sourcing VEX documents: {source}");
|
||||
|
||||
var outputDir = output ?? "src/__Tests/fixtures/vex";
|
||||
|
||||
if (source.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ListKnownSources();
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine("Fetching all known VEX sources...\n");
|
||||
foreach (var sourceDef in KnownSources.Values)
|
||||
{
|
||||
await FetchSourceAsync(sourceDef, outputDir);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Generate sample OpenVEX
|
||||
await GenerateSampleOpenVexAsync(outputDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(customUrl))
|
||||
{
|
||||
var customSource = new VexSourceDefinition
|
||||
{
|
||||
Id = $"vex-custom-{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Description = $"Custom VEX source from {customUrl}",
|
||||
Urls = new[] { customUrl },
|
||||
Format = DetectFormat(customUrl),
|
||||
};
|
||||
await FetchSourceAsync(customSource, outputDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (KnownSources.TryGetValue(source.ToLowerInvariant(), out var knownSource))
|
||||
{
|
||||
await FetchSourceAsync(knownSource, outputDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Unknown source: {source}");
|
||||
Console.WriteLine("Use --list to see available sources or --url to specify a custom URL.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ListKnownSources()
|
||||
{
|
||||
Console.WriteLine("Known VEX sources:");
|
||||
Console.WriteLine();
|
||||
foreach (var (key, source) in KnownSources)
|
||||
{
|
||||
Console.WriteLine($" {key}");
|
||||
Console.WriteLine($" Format: {source.Format}");
|
||||
Console.WriteLine($" Description: {source.Description}");
|
||||
Console.WriteLine($" URLs: {source.Urls.Length}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" fixture-harvester vex <source-name>");
|
||||
Console.WriteLine(" fixture-harvester vex --url <custom-url>");
|
||||
Console.WriteLine(" fixture-harvester vex all");
|
||||
}
|
||||
|
||||
private static async Task FetchSourceAsync(VexSourceDefinition source, string outputDir)
|
||||
{
|
||||
Console.WriteLine($"Fetching: {source.Id}");
|
||||
Console.WriteLine($" Format: {source.Format}");
|
||||
Console.WriteLine($" Description: {source.Description}");
|
||||
|
||||
var fixtureDir = Path.Combine(outputDir, source.Id);
|
||||
Directory.CreateDirectory(fixtureDir);
|
||||
Directory.CreateDirectory(Path.Combine(fixtureDir, "raw"));
|
||||
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps-FixtureHarvester/1.0");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var fetchedFiles = new List<FetchedFile>();
|
||||
|
||||
foreach (var url in source.Urls)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($" Fetching: {url}");
|
||||
var response = await client.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($" WARNING: {response.StatusCode} - skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var filename = Path.GetFileName(new Uri(url).LocalPath);
|
||||
if (string.IsNullOrEmpty(filename) || filename == "/")
|
||||
{
|
||||
filename = $"vex_{DateTime.UtcNow:yyyyMMddHHmmss}.json";
|
||||
}
|
||||
|
||||
var rawPath = Path.Combine(fixtureDir, "raw", filename);
|
||||
await File.WriteAllTextAsync(rawPath, content);
|
||||
|
||||
var sha256 = await ComputeSha256Async(rawPath);
|
||||
fetchedFiles.Add(new FetchedFile
|
||||
{
|
||||
Filename = filename,
|
||||
Url = url,
|
||||
Sha256 = sha256,
|
||||
Size = new FileInfo(rawPath).Length,
|
||||
});
|
||||
|
||||
Console.WriteLine($" ✓ {filename} ({sha256.Substring(0, 20)}...)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERROR: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedFiles.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" WARNING: No files fetched. Generating sample...");
|
||||
await GenerateSampleVexAsync(source.Format, fixtureDir);
|
||||
fetchedFiles.Add(new FetchedFile
|
||||
{
|
||||
Filename = $"sample_{source.Format}.json",
|
||||
Url = "generated",
|
||||
Sha256 = await ComputeSha256Async(Path.Combine(fixtureDir, "raw", $"sample_{source.Format}.json")),
|
||||
Size = new FileInfo(Path.Combine(fixtureDir, "raw", $"sample_{source.Format}.json")).Length,
|
||||
});
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
var meta = new VexFixtureMeta
|
||||
{
|
||||
Id = source.Id,
|
||||
Format = source.Format,
|
||||
Description = source.Description,
|
||||
FetchedAt = DateTime.UtcNow.ToString("O"),
|
||||
Files = fetchedFiles,
|
||||
RefreshPolicy = "quarterly",
|
||||
Notes = $"Fetched from {source.Urls.Length} source URL(s)",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "meta.json");
|
||||
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(metaPath, metaJson);
|
||||
|
||||
Console.WriteLine($" ✓ Metadata: {metaPath}");
|
||||
Console.WriteLine($" ✓ Total files: {fetchedFiles.Count}");
|
||||
}
|
||||
|
||||
private static async Task GenerateSampleVexAsync(string format, string fixtureDir)
|
||||
{
|
||||
var samplePath = Path.Combine(fixtureDir, "raw", $"sample_{format}.json");
|
||||
|
||||
var sample = format.ToLowerInvariant() switch
|
||||
{
|
||||
"openvex" => GenerateOpenVexSample(),
|
||||
"csaf" => GenerateCsafSample(),
|
||||
_ => GenerateOpenVexSample(),
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(samplePath, JsonSerializer.Serialize(sample, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static async Task GenerateSampleOpenVexAsync(string outputDir)
|
||||
{
|
||||
Console.WriteLine("Generating sample OpenVEX fixtures...");
|
||||
|
||||
var fixtureDir = Path.Combine(outputDir, "vex-openvex-samples");
|
||||
Directory.CreateDirectory(fixtureDir);
|
||||
Directory.CreateDirectory(Path.Combine(fixtureDir, "raw"));
|
||||
|
||||
var samples = new[]
|
||||
{
|
||||
("not_affected.json", GenerateOpenVexNotAffected()),
|
||||
("affected_fixed.json", GenerateOpenVexAffectedFixed()),
|
||||
("under_investigation.json", GenerateOpenVexUnderInvestigation()),
|
||||
};
|
||||
|
||||
var fetchedFiles = new List<FetchedFile>();
|
||||
|
||||
foreach (var (filename, content) in samples)
|
||||
{
|
||||
var path = Path.Combine(fixtureDir, "raw", filename);
|
||||
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(content, new JsonSerializerOptions { WriteIndented = true }));
|
||||
var sha256 = await ComputeSha256Async(path);
|
||||
fetchedFiles.Add(new FetchedFile
|
||||
{
|
||||
Filename = filename,
|
||||
Url = "generated",
|
||||
Sha256 = sha256,
|
||||
Size = new FileInfo(path).Length,
|
||||
});
|
||||
Console.WriteLine($" ✓ {filename}");
|
||||
}
|
||||
|
||||
var meta = new VexFixtureMeta
|
||||
{
|
||||
Id = "vex-openvex-samples",
|
||||
Format = "openvex",
|
||||
Description = "Generated OpenVEX samples covering all status types",
|
||||
FetchedAt = DateTime.UtcNow.ToString("O"),
|
||||
Files = fetchedFiles,
|
||||
RefreshPolicy = "manual",
|
||||
Notes = "Generated samples for testing. Replace with real VEX documents when available.",
|
||||
};
|
||||
|
||||
var metaPath = Path.Combine(fixtureDir, "meta.json");
|
||||
await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Console.WriteLine($"✓ Sample OpenVEX fixtures: {fixtureDir}");
|
||||
}
|
||||
|
||||
private static object GenerateOpenVexSample() => new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/sample-001",
|
||||
author = "StellaOps Fixture Harvester",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-0001" },
|
||||
products = new[] { new { @id = "pkg:oci/sample-image@sha256:abc123" } },
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_in_execute_path",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexNotAffected() => new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/not-affected-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1001" },
|
||||
products = new[] { new { @id = "pkg:oci/test-image@sha256:not-affected-digest" } },
|
||||
status = "not_affected",
|
||||
justification = "component_not_present",
|
||||
impact_statement = "The vulnerable component is not included in this image.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexAffectedFixed() => new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/fixed-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1002" },
|
||||
products = new[] { new { @id = "pkg:oci/test-image@sha256:fixed-digest" } },
|
||||
status = "fixed",
|
||||
action_statement = "Update to version 2.0.0 or later.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexUnderInvestigation() => new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/investigation-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1003" },
|
||||
products = new[] { new { @id = "pkg:oci/test-image@sha256:investigating-digest" } },
|
||||
status = "under_investigation",
|
||||
impact_statement = "Analysis in progress. Update expected within 48 hours.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateCsafSample() => new
|
||||
{
|
||||
document = new
|
||||
{
|
||||
category = "csaf_vex",
|
||||
csaf_version = "2.0",
|
||||
title = "Sample CSAF VEX Document",
|
||||
publisher = new
|
||||
{
|
||||
category = "vendor",
|
||||
name = "StellaOps Test",
|
||||
@namespace = "https://stellaops.dev",
|
||||
},
|
||||
tracking = new
|
||||
{
|
||||
id = "STELLA-VEX-2024-001",
|
||||
status = "final",
|
||||
version = "1.0.0",
|
||||
initial_release_date = DateTime.UtcNow.ToString("O"),
|
||||
current_release_date = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
},
|
||||
vulnerabilities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cve = "CVE-2024-0001",
|
||||
product_status = new
|
||||
{
|
||||
known_not_affected = new[] { "CSAFPID-0001" },
|
||||
},
|
||||
threats = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
category = "impact",
|
||||
details = "The vulnerable code is not reachable in this product configuration.",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static string DetectFormat(string url)
|
||||
{
|
||||
if (url.Contains("openvex", StringComparison.OrdinalIgnoreCase)) return "openvex";
|
||||
if (url.Contains("csaf", StringComparison.OrdinalIgnoreCase)) return "csaf";
|
||||
if (url.Contains("secdb", StringComparison.OrdinalIgnoreCase)) return "alpine";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||
return "sha256:" + BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal class VexSourceDefinition
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string[] Urls { get; set; } = Array.Empty<string>();
|
||||
public string Format { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal class FetchedFile
|
||||
{
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
public string Url { get; set; } = string.Empty;
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
||||
internal class VexFixtureMeta
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string FetchedAt { get; set; } = string.Empty;
|
||||
public List<FetchedFile> Files { get; set; } = new();
|
||||
public string RefreshPolicy { get; set; } = "quarterly";
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
}
|
||||
254
src/__Tests/Tools/FixtureHarvester/FeedSnapshotCommandTests.cs
Normal file
254
src/__Tests/Tools/FixtureHarvester/FeedSnapshotCommandTests.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
// <copyright file="FeedSnapshotCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for FeedSnapshotCommand.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-005)
|
||||
/// </summary>
|
||||
public sealed class FeedSnapshotCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
|
||||
public FeedSnapshotCommandTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"fixture-harvester-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OSV", "/api/v1/feeds/osv/advisories")]
|
||||
[InlineData("GHSA", "/api/v1/feeds/ghsa/advisories")]
|
||||
[InlineData("NVD", "/api/v1/feeds/nvd/advisories")]
|
||||
[InlineData("EPSS", "/api/v1/feeds/epss/scores")]
|
||||
[InlineData("KEV", "/api/v1/feeds/kev/catalog")]
|
||||
[InlineData("OVAL", "/api/v1/feeds/oval/definitions")]
|
||||
public void GetFeedEndpoint_ReturnsCorrectPath(string feedType, string expectedEndpoint)
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = GetFeedEndpointTestHelper(feedType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedEndpoint, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFeedEndpoint_UnknownType_ReturnsGenericPath()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = GetFeedEndpointTestHelper("CUSTOM");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("/api/v1/feeds/custom/advisories", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSampleAdvisories_OSV_ReturnsValidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var count = 5;
|
||||
|
||||
// Act
|
||||
var advisories = GenerateSampleAdvisoriesTestHelper("OSV", count);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(count, advisories.Count);
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory);
|
||||
Assert.Contains("OSV-SAMPLE", json);
|
||||
Assert.Contains("affected", json);
|
||||
Assert.Contains("severity", json);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSampleAdvisories_GHSA_ReturnsValidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var count = 3;
|
||||
|
||||
// Act
|
||||
var advisories = GenerateSampleAdvisoriesTestHelper("GHSA", count);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(count, advisories.Count);
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory);
|
||||
Assert.Contains("ghsaId", json);
|
||||
Assert.Contains("GHSA-sample", json);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSampleAdvisories_NVD_ReturnsValidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var count = 3;
|
||||
|
||||
// Act
|
||||
var advisories = GenerateSampleAdvisoriesTestHelper("NVD", count);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(count, advisories.Count);
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory);
|
||||
Assert.Contains("cve", json);
|
||||
Assert.Contains("CVE-2024", json);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSampleAdvisories_DistributesEcosystems()
|
||||
{
|
||||
// Arrange
|
||||
var count = 10;
|
||||
|
||||
// Act
|
||||
var advisories = GenerateSampleAdvisoriesTestHelper("OSV", count);
|
||||
var json = string.Join("\n", advisories.Select(a => JsonSerializer.Serialize(a)));
|
||||
|
||||
// Assert - should have multiple ecosystems across 10 advisories
|
||||
var ecosystemCount = new[] { "PyPI", "npm", "Go", "Maven", "NuGet" }
|
||||
.Count(e => json.Contains(e));
|
||||
Assert.True(ecosystemCount >= 3, "Should distribute across at least 3 ecosystems");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSampleAdvisories_DistributesSeverities()
|
||||
{
|
||||
// Arrange
|
||||
var count = 10;
|
||||
|
||||
// Act
|
||||
var advisories = GenerateSampleAdvisoriesTestHelper("OSV", count);
|
||||
var json = string.Join("\n", advisories.Select(a => JsonSerializer.Serialize(a)));
|
||||
|
||||
// Assert - should have multiple severities
|
||||
var severityCount = new[] { "CRITICAL", "HIGH", "MEDIUM", "LOW" }
|
||||
.Count(s => json.Contains(s));
|
||||
Assert.True(severityCount >= 2, "Should distribute across at least 2 severity levels");
|
||||
}
|
||||
|
||||
// Helper that mirrors internal logic
|
||||
private static string GetFeedEndpointTestHelper(string feedType)
|
||||
{
|
||||
return feedType.ToUpperInvariant() switch
|
||||
{
|
||||
"OSV" => "/api/v1/feeds/osv/advisories",
|
||||
"GHSA" => "/api/v1/feeds/ghsa/advisories",
|
||||
"NVD" => "/api/v1/feeds/nvd/advisories",
|
||||
"EPSS" => "/api/v1/feeds/epss/scores",
|
||||
"KEV" => "/api/v1/feeds/kev/catalog",
|
||||
"OVAL" => "/api/v1/feeds/oval/definitions",
|
||||
_ => $"/api/v1/feeds/{feedType.ToLowerInvariant()}/advisories",
|
||||
};
|
||||
}
|
||||
|
||||
private static List<object> GenerateSampleAdvisoriesTestHelper(string feedType, int count)
|
||||
{
|
||||
var advisories = new List<object>();
|
||||
var ecosystems = new[] { "PyPI", "npm", "Go", "Maven", "NuGet", "RubyGems", "crates.io" };
|
||||
var severities = new[] { "CRITICAL", "HIGH", "MEDIUM", "LOW" };
|
||||
|
||||
for (int i = 1; i <= count; i++)
|
||||
{
|
||||
var ecosystem = ecosystems[i % ecosystems.Length];
|
||||
var severity = severities[i % severities.Length];
|
||||
|
||||
advisories.Add(feedType.ToUpperInvariant() switch
|
||||
{
|
||||
"OSV" => new
|
||||
{
|
||||
id = $"OSV-SAMPLE-{i:D4}",
|
||||
summary = $"Sample vulnerability {i} in {ecosystem} package",
|
||||
details = $"This is a sample {severity.ToLowerInvariant()} vulnerability for testing purposes.",
|
||||
affected = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
package = new { ecosystem = ecosystem, name = $"sample-package-{i}" },
|
||||
ranges = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "ECOSYSTEM",
|
||||
events = new object[]
|
||||
{
|
||||
new { introduced = "0" },
|
||||
new { @fixed = $"1.{i}.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
severity = new[] { new { type = "CVSS_V3", score = $"{6.0 + (i % 4)}.{i % 10}" } },
|
||||
published = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
modified = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
"GHSA" => new
|
||||
{
|
||||
ghsaId = $"GHSA-sample-{i:D4}",
|
||||
summary = $"Sample GHSA vulnerability {i}",
|
||||
severity = severity,
|
||||
cvss = new { score = 6.0 + (i % 4), vectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N" },
|
||||
publishedAt = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
updatedAt = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
"NVD" => new
|
||||
{
|
||||
cve = new
|
||||
{
|
||||
id = $"CVE-2024-{10000 + i}",
|
||||
sourceIdentifier = "sample@stellaops.dev",
|
||||
published = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
lastModified = DateTime.UtcNow.ToString("O"),
|
||||
descriptions = new[]
|
||||
{
|
||||
new { lang = "en", value = $"Sample NVD vulnerability {i} for testing." }
|
||||
},
|
||||
metrics = new
|
||||
{
|
||||
cvssMetricV31 = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cvssData = new
|
||||
{
|
||||
version = "3.1",
|
||||
baseScore = 6.0 + (i % 4),
|
||||
baseSeverity = severity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => new
|
||||
{
|
||||
id = $"SAMPLE-{feedType.ToUpperInvariant()}-{i:D4}",
|
||||
type = feedType,
|
||||
severity = severity,
|
||||
created = DateTime.UtcNow.AddDays(-i).ToString("O"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return advisories;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
@@ -24,3 +24,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
174
src/__Tests/Tools/FixtureHarvester/OciPinCommandTests.cs
Normal file
174
src/__Tests/Tools/FixtureHarvester/OciPinCommandTests.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
// <copyright file="OciPinCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OciPinCommand.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-004)
|
||||
/// </summary>
|
||||
public sealed class OciPinCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
|
||||
public OciPinCommandTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"fixture-harvester-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_SimpleImage_ReturnsDockerHub()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("alpine:3.19");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("docker.io", result.Registry);
|
||||
Assert.Equal("library/alpine", result.Repository);
|
||||
Assert.Equal("3.19", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_ImageWithoutTag_DefaultsToLatest()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("nginx");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("docker.io", result.Registry);
|
||||
Assert.Equal("library/nginx", result.Repository);
|
||||
Assert.Equal("latest", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_NamespacedImage_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("myuser/myapp:v1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("docker.io", result.Registry);
|
||||
Assert.Equal("myuser/myapp", result.Repository);
|
||||
Assert.Equal("v1.0.0", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_CustomRegistry_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("ghcr.io/owner/repo:latest");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ghcr.io", result.Registry);
|
||||
Assert.Equal("owner/repo", result.Repository);
|
||||
Assert.Equal("latest", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_RegistryWithPort_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("localhost:5000/myimage:test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("localhost:5000", result.Registry);
|
||||
Assert.Equal("myimage", result.Repository);
|
||||
Assert.Equal("test", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageRef_GcrImage_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ParseImageRefTestHelper("gcr.io/distroless/static-debian12:nonroot");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gcr.io", result.Registry);
|
||||
Assert.Equal("distroless/static-debian12", result.Repository);
|
||||
Assert.Equal("nonroot", result.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeId_RemovesInvalidCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var input = "oci-my/image:v1.0+build";
|
||||
|
||||
// Act
|
||||
var result = SanitizeIdTestHelper(input);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("/", result);
|
||||
Assert.DoesNotContain(":", result);
|
||||
Assert.DoesNotContain("+", result);
|
||||
Assert.Contains("oci", result);
|
||||
Assert.Contains("image", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeId_PreservesValidCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var input = "valid-id_123";
|
||||
|
||||
// Act
|
||||
var result = SanitizeIdTestHelper(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("valid-id_123", result);
|
||||
}
|
||||
|
||||
// Helper methods that mirror the internal logic for testing
|
||||
private static (string Registry, string Repository, string Tag) ParseImageRefTestHelper(string imageRef)
|
||||
{
|
||||
var tag = "latest";
|
||||
var repository = imageRef;
|
||||
var registry = "docker.io";
|
||||
|
||||
// Extract tag
|
||||
var tagIndex = imageRef.LastIndexOf(':');
|
||||
var slashAfterTag = tagIndex > 0 ? imageRef.IndexOf('/', tagIndex) : -1;
|
||||
if (tagIndex > 0 && slashAfterTag < 0 && !imageRef.Substring(tagIndex + 1).Contains('/'))
|
||||
{
|
||||
tag = imageRef.Substring(tagIndex + 1);
|
||||
repository = imageRef.Substring(0, tagIndex);
|
||||
}
|
||||
|
||||
// Extract registry
|
||||
var firstSlash = repository.IndexOf('/');
|
||||
if (firstSlash > 0)
|
||||
{
|
||||
var possibleRegistry = repository.Substring(0, firstSlash);
|
||||
if (possibleRegistry.Contains('.') || possibleRegistry.Contains(':') || possibleRegistry == "localhost")
|
||||
{
|
||||
registry = possibleRegistry;
|
||||
repository = repository.Substring(firstSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Docker Hub library images
|
||||
if (registry == "docker.io" && !repository.Contains('/'))
|
||||
{
|
||||
repository = $"library/{repository}";
|
||||
}
|
||||
|
||||
return (registry, repository, tag);
|
||||
}
|
||||
|
||||
private static string SanitizeIdTestHelper(string input)
|
||||
{
|
||||
return new string(input.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray());
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,98 @@ internal static class Program
|
||||
regenCommand.AddOption(regenConfirmOption);
|
||||
regenCommand.SetHandler(RegenCommand.ExecuteAsync, regenFixtureOption, regenAllOption, regenConfirmOption);
|
||||
|
||||
// OCI Pin command (FH-004)
|
||||
var ociPinCommand = new Command("oci-pin", "Pin OCI image digests for deterministic testing");
|
||||
var ociImageOption = new Option<string>(
|
||||
"--image",
|
||||
description: "Image reference (e.g., alpine:3.19, myregistry.io/app:v1)") { IsRequired = true };
|
||||
var ociOutputOption = new Option<string>(
|
||||
"--output",
|
||||
description: "Output directory",
|
||||
getDefaultValue: () => "src/__Tests/fixtures/oci");
|
||||
var ociVerifyOption = new Option<bool>(
|
||||
"--verify",
|
||||
description: "Verify digest by re-fetching manifest",
|
||||
getDefaultValue: () => true);
|
||||
|
||||
ociPinCommand.AddOption(ociImageOption);
|
||||
ociPinCommand.AddOption(ociOutputOption);
|
||||
ociPinCommand.AddOption(ociVerifyOption);
|
||||
ociPinCommand.SetHandler(OciPinCommand.ExecuteAsync, ociImageOption, ociOutputOption, ociVerifyOption);
|
||||
|
||||
// Feed Snapshot command (FH-005)
|
||||
var feedSnapshotCommand = new Command("feed-snapshot", "Capture vulnerability feed snapshots");
|
||||
var feedTypeOption = new Option<string>(
|
||||
"--feed",
|
||||
description: "Feed type: osv, ghsa, nvd, epss, kev, oval") { IsRequired = true };
|
||||
var feedUrlOption = new Option<string>(
|
||||
"--url",
|
||||
description: "Concelier base URL",
|
||||
getDefaultValue: () => "http://localhost:5010");
|
||||
var feedCountOption = new Option<int>(
|
||||
"--count",
|
||||
description: "Number of advisories to capture",
|
||||
getDefaultValue: () => 30);
|
||||
var feedOutputOption = new Option<string>(
|
||||
"--output",
|
||||
description: "Output directory",
|
||||
getDefaultValue: () => "src/__Tests/fixtures/feeds");
|
||||
|
||||
feedSnapshotCommand.AddOption(feedTypeOption);
|
||||
feedSnapshotCommand.AddOption(feedUrlOption);
|
||||
feedSnapshotCommand.AddOption(feedCountOption);
|
||||
feedSnapshotCommand.AddOption(feedOutputOption);
|
||||
feedSnapshotCommand.SetHandler(FeedSnapshotCommand.ExecuteAsync, feedTypeOption, feedUrlOption, feedCountOption, feedOutputOption);
|
||||
|
||||
// VEX Source command (FH-006)
|
||||
var vexSourceCommand = new Command("vex", "Acquire OpenVEX and CSAF samples");
|
||||
var vexSourceArg = new Argument<string>(
|
||||
"source",
|
||||
description: "Source name (list, all, openvex-examples, csaf-redhat, alpine-secdb) or 'list' to see all");
|
||||
var vexCustomUrlOption = new Option<string>(
|
||||
"--url",
|
||||
description: "Custom VEX document URL");
|
||||
var vexOutputOption = new Option<string>(
|
||||
"--output",
|
||||
description: "Output directory",
|
||||
getDefaultValue: () => "src/__Tests/fixtures/vex");
|
||||
|
||||
vexSourceCommand.AddArgument(vexSourceArg);
|
||||
vexSourceCommand.AddOption(vexCustomUrlOption);
|
||||
vexSourceCommand.AddOption(vexOutputOption);
|
||||
vexSourceCommand.SetHandler(VexSourceCommand.ExecuteAsync, vexSourceArg, vexCustomUrlOption, vexOutputOption);
|
||||
|
||||
// SBOM Golden command (FH-007)
|
||||
var sbomGoldenCommand = new Command("sbom-golden", "Generate SBOM golden fixtures from container images");
|
||||
var sbomImageArg = new Argument<string>(
|
||||
"image",
|
||||
description: "Image key (list, all, alpine-minimal, debian-slim, distroless-static) or custom image ref");
|
||||
var sbomFormatOption = new Option<string>(
|
||||
"--format",
|
||||
description: "SBOM format: cyclonedx, spdx",
|
||||
getDefaultValue: () => "cyclonedx");
|
||||
var sbomScannerOption = new Option<string>(
|
||||
"--scanner",
|
||||
description: "Scanner tool: syft, trivy",
|
||||
getDefaultValue: () => "syft");
|
||||
var sbomOutputOption = new Option<string>(
|
||||
"--output",
|
||||
description: "Output directory",
|
||||
getDefaultValue: () => "src/__Tests/fixtures/sbom");
|
||||
|
||||
sbomGoldenCommand.AddArgument(sbomImageArg);
|
||||
sbomGoldenCommand.AddOption(sbomFormatOption);
|
||||
sbomGoldenCommand.AddOption(sbomScannerOption);
|
||||
sbomGoldenCommand.AddOption(sbomOutputOption);
|
||||
sbomGoldenCommand.SetHandler(SbomGoldenCommand.ExecuteAsync, sbomImageArg, sbomFormatOption, sbomScannerOption, sbomOutputOption);
|
||||
|
||||
rootCommand.AddCommand(harvestCommand);
|
||||
rootCommand.AddCommand(validateCommand);
|
||||
rootCommand.AddCommand(regenCommand);
|
||||
rootCommand.AddCommand(ociPinCommand);
|
||||
rootCommand.AddCommand(feedSnapshotCommand);
|
||||
rootCommand.AddCommand(vexSourceCommand);
|
||||
rootCommand.AddCommand(sbomGoldenCommand);
|
||||
|
||||
return await rootCommand.InvokeAsync(args);
|
||||
}
|
||||
|
||||
356
src/__Tests/Tools/FixtureHarvester/SbomGoldenCommandTests.cs
Normal file
356
src/__Tests/Tools/FixtureHarvester/SbomGoldenCommandTests.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
// <copyright file="SbomGoldenCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SbomGoldenCommand.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-007)
|
||||
/// </summary>
|
||||
public sealed class SbomGoldenCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
|
||||
public SbomGoldenCommandTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"fixture-harvester-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownImages_ContainsExpectedEntries()
|
||||
{
|
||||
// Arrange
|
||||
var expectedImages = new[] { "alpine-minimal", "debian-slim", "distroless-static", "scratch-go" };
|
||||
|
||||
// Act & Assert
|
||||
foreach (var image in expectedImages)
|
||||
{
|
||||
Assert.True(KnownImagesContainsTestHelper(image), $"Should contain {image}");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cyclonedx", "cdx.json")]
|
||||
[InlineData("cyclonedx-json", "cdx.json")]
|
||||
[InlineData("spdx", "spdx.json")]
|
||||
[InlineData("spdx-json", "spdx.json")]
|
||||
[InlineData("unknown", "json")]
|
||||
public void GetFormatExtension_ReturnsCorrectExtension(string format, string expectedExt)
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = GetFormatExtensionTestHelper(format);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedExt, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCycloneDxSample_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var imageDef = new TestGoldenImageDefinition
|
||||
{
|
||||
Id = "test-image",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Test image",
|
||||
ExpectedPackages = 5,
|
||||
};
|
||||
|
||||
// Act
|
||||
var sbom = GenerateCycloneDxSampleTestHelper(imageDef);
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("CycloneDX", json);
|
||||
Assert.Contains("specVersion", json);
|
||||
Assert.Contains("1.6", json);
|
||||
Assert.Contains("metadata", json);
|
||||
Assert.Contains("components", json);
|
||||
Assert.Contains("serialNumber", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCycloneDxSample_HasCorrectComponentCount()
|
||||
{
|
||||
// Arrange
|
||||
var imageDef = new TestGoldenImageDefinition
|
||||
{
|
||||
Id = "test-image",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Test image",
|
||||
ExpectedPackages = 10,
|
||||
};
|
||||
|
||||
// Act
|
||||
var sbom = GenerateCycloneDxSampleTestHelper(imageDef);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(sbom.components);
|
||||
Assert.Equal(10, sbom.components.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCycloneDxSample_ComponentsHavePurl()
|
||||
{
|
||||
// Arrange
|
||||
var imageDef = new TestGoldenImageDefinition
|
||||
{
|
||||
Id = "test-image",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Test image",
|
||||
ExpectedPackages = 3,
|
||||
};
|
||||
|
||||
// Act
|
||||
var sbom = GenerateCycloneDxSampleTestHelper(imageDef);
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("pkg:apk", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSpdxSample_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var imageDef = new TestGoldenImageDefinition
|
||||
{
|
||||
Id = "test-image",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Test image",
|
||||
ExpectedPackages = 5,
|
||||
};
|
||||
|
||||
// Act
|
||||
var sbom = GenerateSpdxSampleTestHelper(imageDef);
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("SPDX-2.3", json);
|
||||
Assert.Contains("CC0-1.0", json);
|
||||
Assert.Contains("SPDXRef-DOCUMENT", json);
|
||||
Assert.Contains("creationInfo", json);
|
||||
Assert.Contains("packages", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSpdxSample_PackagesHaveSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var imageDef = new TestGoldenImageDefinition
|
||||
{
|
||||
Id = "test-image",
|
||||
ImageRef = "alpine:3.19",
|
||||
Description = "Test image",
|
||||
ExpectedPackages = 3,
|
||||
};
|
||||
|
||||
// Act
|
||||
var sbom = GenerateSpdxSampleTestHelper(imageDef);
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("SPDXRef-Package", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountPackages_CycloneDx_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
components = new[]
|
||||
{
|
||||
new { name = "pkg1" },
|
||||
new { name = "pkg2" },
|
||||
new { name = "pkg3" },
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Act
|
||||
var count = CountPackagesTestHelper(json, "cyclonedx");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountPackages_Spdx_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = new
|
||||
{
|
||||
spdxVersion = "SPDX-2.3",
|
||||
packages = new[]
|
||||
{
|
||||
new { SPDXID = "SPDXRef-Package-1" },
|
||||
new { SPDXID = "SPDXRef-Package-2" },
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(sbom);
|
||||
|
||||
// Act
|
||||
var count = CountPackagesTestHelper(json, "spdx");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountPackages_InvalidJson_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = "not valid json";
|
||||
|
||||
// Act
|
||||
var count = CountPackagesTestHelper(invalidJson, "cyclonedx");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
// Helper types and methods
|
||||
private class TestGoldenImageDefinition
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ImageRef { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ExpectedPackages { get; set; }
|
||||
}
|
||||
|
||||
private static bool KnownImagesContainsTestHelper(string image)
|
||||
{
|
||||
var knownImages = new Dictionary<string, bool>
|
||||
{
|
||||
["alpine-minimal"] = true,
|
||||
["debian-slim"] = true,
|
||||
["distroless-static"] = true,
|
||||
["scratch-go"] = true,
|
||||
};
|
||||
return knownImages.ContainsKey(image.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string GetFormatExtensionTestHelper(string format)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" or "cyclonedx-json" => "cdx.json",
|
||||
"spdx" or "spdx-json" => "spdx.json",
|
||||
_ => "json",
|
||||
};
|
||||
}
|
||||
|
||||
private static dynamic GenerateCycloneDxSampleTestHelper(TestGoldenImageDefinition imageDef)
|
||||
{
|
||||
var components = new List<object>();
|
||||
for (int i = 0; i < Math.Max(1, imageDef.ExpectedPackages); i++)
|
||||
{
|
||||
components.Add(new
|
||||
{
|
||||
type = "library",
|
||||
name = $"sample-package-{i + 1}",
|
||||
version = $"1.{i}.0",
|
||||
purl = $"pkg:apk/alpine/sample-package-{i + 1}@1.{i}.0",
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
serialNumber = $"urn:uuid:{Guid.NewGuid()}",
|
||||
version = 1,
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
tools = new[]
|
||||
{
|
||||
new { vendor = "StellaOps", name = "FixtureHarvester", version = "1.0.0" }
|
||||
},
|
||||
component = new
|
||||
{
|
||||
type = "container",
|
||||
name = imageDef.ImageRef.Split(':')[0].Split('/').Last(),
|
||||
version = imageDef.ImageRef.Contains(':') ? imageDef.ImageRef.Split(':').Last() : "latest",
|
||||
purl = $"pkg:oci/{imageDef.ImageRef.Replace(':', '@')}",
|
||||
},
|
||||
},
|
||||
components = components,
|
||||
};
|
||||
}
|
||||
|
||||
private static dynamic GenerateSpdxSampleTestHelper(TestGoldenImageDefinition imageDef)
|
||||
{
|
||||
var packages = new List<object>();
|
||||
for (int i = 0; i < Math.Max(1, imageDef.ExpectedPackages); i++)
|
||||
{
|
||||
packages.Add(new
|
||||
{
|
||||
SPDXID = $"SPDXRef-Package-{i + 1}",
|
||||
name = $"sample-package-{i + 1}",
|
||||
versionInfo = $"1.{i}.0",
|
||||
downloadLocation = "NOASSERTION",
|
||||
filesAnalyzed = false,
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
spdxVersion = "SPDX-2.3",
|
||||
dataLicense = "CC0-1.0",
|
||||
SPDXID = "SPDXRef-DOCUMENT",
|
||||
name = imageDef.Id,
|
||||
documentNamespace = $"https://stellaops.dev/spdx/{imageDef.Id}",
|
||||
creationInfo = new
|
||||
{
|
||||
created = DateTime.UtcNow.ToString("O"),
|
||||
creators = new[] { "Tool: StellaOps-FixtureHarvester-1.0.0" },
|
||||
},
|
||||
packages = packages,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountPackagesTestHelper(string sbomContent, string format)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(sbomContent);
|
||||
|
||||
if (format.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("components", out var components))
|
||||
{
|
||||
return components.GetArrayLength();
|
||||
}
|
||||
}
|
||||
else if (format.Contains("spdx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("packages", out var packages))
|
||||
{
|
||||
return packages.GetArrayLength();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parse error
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
263
src/__Tests/Tools/FixtureHarvester/VexSourceCommandTests.cs
Normal file
263
src/__Tests/Tools/FixtureHarvester/VexSourceCommandTests.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
// <copyright file="VexSourceCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.FixtureHarvester.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for VexSourceCommand.
|
||||
/// @sprint SPRINT_20251229_004_LIB_fixture_harvester (FH-006)
|
||||
/// </summary>
|
||||
public sealed class VexSourceCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
|
||||
public VexSourceCommandTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"fixture-harvester-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.com/openvex/doc.json", "openvex")]
|
||||
[InlineData("https://access.redhat.com/security/data/csaf/v2/advisories/test.json", "csaf")]
|
||||
[InlineData("https://secdb.alpinelinux.org/v3.19/main.json", "alpine")]
|
||||
[InlineData("https://example.com/unknown/doc.json", "unknown")]
|
||||
public void DetectFormat_ReturnsCorrectFormat(string url, string expectedFormat)
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = DetectFormatTestHelper(url);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedFormat, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateOpenVexSample_HasRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sample = GenerateOpenVexSampleTestHelper();
|
||||
var json = JsonSerializer.Serialize(sample);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("https://openvex.dev/ns", json);
|
||||
Assert.Contains("statements", json);
|
||||
Assert.Contains("not_affected", json);
|
||||
Assert.Contains("vulnerability", json);
|
||||
Assert.Contains("products", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateOpenVexNotAffected_HasCorrectStatus()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sample = GenerateOpenVexNotAffectedTestHelper();
|
||||
var json = JsonSerializer.Serialize(sample);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("not_affected", json);
|
||||
Assert.Contains("component_not_present", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateOpenVexAffectedFixed_HasCorrectStatus()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sample = GenerateOpenVexAffectedFixedTestHelper();
|
||||
var json = JsonSerializer.Serialize(sample);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"fixed\"", json);
|
||||
Assert.Contains("action_statement", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateOpenVexUnderInvestigation_HasCorrectStatus()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sample = GenerateOpenVexUnderInvestigationTestHelper();
|
||||
var json = JsonSerializer.Serialize(sample);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("under_investigation", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCsafSample_HasRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sample = GenerateCsafSampleTestHelper();
|
||||
var json = JsonSerializer.Serialize(sample);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("csaf_vex", json);
|
||||
Assert.Contains("document", json);
|
||||
Assert.Contains("vulnerabilities", json);
|
||||
Assert.Contains("tracking", json);
|
||||
Assert.Contains("publisher", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownSources_ContainsExpectedEntries()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSources = new[] { "openvex-examples", "csaf-redhat", "alpine-secdb" };
|
||||
|
||||
// Act & Assert
|
||||
foreach (var source in expectedSources)
|
||||
{
|
||||
Assert.True(KnownSourcesContainsTestHelper(source), $"Should contain {source}");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods that mirror internal logic
|
||||
private static string DetectFormatTestHelper(string url)
|
||||
{
|
||||
if (url.Contains("openvex", StringComparison.OrdinalIgnoreCase)) return "openvex";
|
||||
if (url.Contains("csaf", StringComparison.OrdinalIgnoreCase)) return "csaf";
|
||||
if (url.Contains("secdb", StringComparison.OrdinalIgnoreCase)) return "alpine";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static object GenerateOpenVexSampleTestHelper() => new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/sample-001",
|
||||
author = "StellaOps Fixture Harvester",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-0001" },
|
||||
products = new[] { new { id = "pkg:oci/sample-image@sha256:abc123" } },
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_in_execute_path",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexNotAffectedTestHelper() => new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/not-affected-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1001" },
|
||||
products = new[] { new { id = "pkg:oci/test-image@sha256:not-affected-digest" } },
|
||||
status = "not_affected",
|
||||
justification = "component_not_present",
|
||||
impact_statement = "The vulnerable component is not included in this image.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexAffectedFixedTestHelper() => new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/fixed-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1002" },
|
||||
products = new[] { new { id = "pkg:oci/test-image@sha256:fixed-digest" } },
|
||||
status = "fixed",
|
||||
action_statement = "Update to version 2.0.0 or later.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateOpenVexUnderInvestigationTestHelper() => new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = "https://stellaops.dev/vex/investigation-001",
|
||||
author = "StellaOps Test",
|
||||
timestamp = DateTime.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = "CVE-2024-1003" },
|
||||
products = new[] { new { id = "pkg:oci/test-image@sha256:investigating-digest" } },
|
||||
status = "under_investigation",
|
||||
impact_statement = "Analysis in progress. Update expected within 48 hours.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static object GenerateCsafSampleTestHelper() => new
|
||||
{
|
||||
document = new
|
||||
{
|
||||
category = "csaf_vex",
|
||||
csaf_version = "2.0",
|
||||
title = "Sample CSAF VEX Document",
|
||||
publisher = new
|
||||
{
|
||||
category = "vendor",
|
||||
name = "StellaOps Test",
|
||||
@namespace = "https://stellaops.dev",
|
||||
},
|
||||
tracking = new
|
||||
{
|
||||
id = "STELLA-VEX-2024-001",
|
||||
status = "final",
|
||||
version = "1.0.0",
|
||||
initial_release_date = DateTime.UtcNow.ToString("O"),
|
||||
current_release_date = DateTime.UtcNow.ToString("O"),
|
||||
},
|
||||
},
|
||||
vulnerabilities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cve = "CVE-2024-0001",
|
||||
product_status = new
|
||||
{
|
||||
known_not_affected = new[] { "CSAFPID-0001" },
|
||||
},
|
||||
threats = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
category = "impact",
|
||||
details = "The vulnerable code is not reachable in this product configuration.",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
private static bool KnownSourcesContainsTestHelper(string source)
|
||||
{
|
||||
var knownSources = new Dictionary<string, bool>
|
||||
{
|
||||
["openvex-examples"] = true,
|
||||
["csaf-redhat"] = true,
|
||||
["alpine-secdb"] = true,
|
||||
};
|
||||
return knownSources.ContainsKey(source.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user