feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Store for imported policy pack bundles.
|
||||
/// </summary>
|
||||
public interface IPolicyPackBundleStore
|
||||
{
|
||||
Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of policy pack bundle store.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryPolicyPackBundleStore : IPolicyPackBundleStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ImportedPolicyPackBundle> _bundles = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles.TryGetValue(bundleId, out var bundle);
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ImportedPolicyPackBundle> bundles = _bundles.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
bundles = bundles.Where(b => string.Equals(b.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = bundles
|
||||
.OrderByDescending(b => b.ImportedAt)
|
||||
.ThenBy(b => b.BundleId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ImportedPolicyPackBundle>>(ordered);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
_bundles[bundle.BundleId] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles.TryRemove(bundleId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing policy pack bundles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
internal sealed class PolicyPackBundleImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IPolicyPackBundleStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyPackBundleImportService> _logger;
|
||||
|
||||
public PolicyPackBundleImportService(
|
||||
IPolicyPackBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyPackBundleImportService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a bundle for import and begins validation.
|
||||
/// </summary>
|
||||
public async Task<RegisterBundleResponse> RegisterBundleAsync(
|
||||
string tenantId,
|
||||
RegisterBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var importId = GenerateImportId();
|
||||
|
||||
_logger.LogInformation("Registering bundle import {ImportId} from {BundlePath} for tenant {TenantId}",
|
||||
importId, request.BundlePath, tenantId);
|
||||
|
||||
// Create initial entry in validating state
|
||||
var entry = new ImportedPolicyPackBundle(
|
||||
BundleId: importId,
|
||||
DomainId: BundleDomainIds.PolicyPacks,
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Validating,
|
||||
ExportCount: 0,
|
||||
ImportedAt: now.ToString("O"),
|
||||
Error: null,
|
||||
Bundle: null);
|
||||
|
||||
await _store.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Start async import process
|
||||
_ = ImportBundleAsync(tenantId, importId, request, cancellationToken);
|
||||
|
||||
return new RegisterBundleResponse(importId, BundleImportStatus.Validating);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a bundle import.
|
||||
/// </summary>
|
||||
public async Task<BundleStatusResponse?> GetBundleStatusAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundle = await _store.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BundleStatusResponse(
|
||||
BundleId: bundle.BundleId,
|
||||
DomainId: bundle.DomainId,
|
||||
Status: bundle.Status,
|
||||
ExportCount: bundle.ExportCount,
|
||||
ImportedAt: bundle.ImportedAt,
|
||||
Error: bundle.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists imported bundles for a tenant.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BundleStatusResponse>> ListBundlesAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundles = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return bundles.Select(b => new BundleStatusResponse(
|
||||
BundleId: b.BundleId,
|
||||
DomainId: b.DomainId,
|
||||
Status: b.Status,
|
||||
ExportCount: b.ExportCount,
|
||||
ImportedAt: b.ImportedAt,
|
||||
Error: b.Error)).ToList();
|
||||
}
|
||||
|
||||
private async Task ImportBundleAsync(
|
||||
string tenantId,
|
||||
string importId,
|
||||
RegisterBundleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting bundle import {ImportId}", importId);
|
||||
|
||||
// Update status to importing
|
||||
var current = await _store.GetAsync(importId, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _store.SaveAsync(current with { Status = BundleImportStatus.Importing }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Load and parse bundle
|
||||
var bundle = await LoadBundleAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Validate bundle
|
||||
ValidateBundle(bundle);
|
||||
|
||||
// Verify signatures if present
|
||||
if (bundle.Signature is not null)
|
||||
{
|
||||
await VerifySignatureAsync(bundle, request.TrustRootsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Verify export digests
|
||||
VerifyExportDigests(bundle);
|
||||
|
||||
// Mark as imported
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var imported = new ImportedPolicyPackBundle(
|
||||
BundleId: importId,
|
||||
DomainId: bundle.DomainId,
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Imported,
|
||||
ExportCount: bundle.Exports.Count,
|
||||
ImportedAt: now.ToString("O"),
|
||||
Error: null,
|
||||
Bundle: bundle);
|
||||
|
||||
await _store.SaveAsync(imported, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Bundle import {ImportId} completed successfully with {ExportCount} exports",
|
||||
importId, bundle.Exports.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Bundle import {ImportId} failed: {Error}", importId, ex.Message);
|
||||
|
||||
var failed = await _store.GetAsync(importId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (failed is not null)
|
||||
{
|
||||
await _store.SaveAsync(failed with
|
||||
{
|
||||
Status = BundleImportStatus.Failed,
|
||||
Error = ex.Message
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PolicyPackBundle> LoadBundleAsync(string bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Bundle file not found: {bundlePath}");
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
var bundle = JsonSerializer.Deserialize<PolicyPackBundle>(json, JsonOptions)
|
||||
?? throw new InvalidDataException("Failed to parse bundle JSON");
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static void ValidateBundle(PolicyPackBundle bundle)
|
||||
{
|
||||
if (bundle.SchemaVersion < 1)
|
||||
{
|
||||
throw new InvalidDataException("Invalid schema version");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundle.DomainId))
|
||||
{
|
||||
throw new InvalidDataException("Domain ID is required");
|
||||
}
|
||||
|
||||
if (bundle.Exports.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("Bundle must contain at least one export");
|
||||
}
|
||||
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(export.Key))
|
||||
{
|
||||
throw new InvalidDataException("Export key is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(export.ArtifactDigest))
|
||||
{
|
||||
throw new InvalidDataException($"Artifact digest is required for export '{export.Key}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task VerifySignatureAsync(PolicyPackBundle bundle, string? trustRootsPath, CancellationToken cancellationToken)
|
||||
{
|
||||
// Signature verification would integrate with the AirGap.Importer DsseVerifier
|
||||
// For now, log that signature is present
|
||||
_logger.LogInformation("Bundle signature present: algorithm={Algorithm}, keyId={KeyId}",
|
||||
bundle.Signature!.Algorithm, bundle.Signature.KeyId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void VerifyExportDigests(PolicyPackBundle bundle)
|
||||
{
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
// Verify digest format
|
||||
if (!export.ArtifactDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"Invalid digest format for export '{export.Key}': expected sha256: prefix");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verified export '{Key}' with digest {Digest}",
|
||||
export.Key, export.ArtifactDigest);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateImportId()
|
||||
{
|
||||
return $"import-{Guid.NewGuid():N}"[..20];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror bundle for policy packs per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackBundle(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
|
||||
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("displayName")] string? DisplayName,
|
||||
[property: JsonPropertyName("exports")] IReadOnlyList<PolicyPackExport> Exports,
|
||||
[property: JsonPropertyName("signature")] BundleSignature? Signature);
|
||||
|
||||
/// <summary>
|
||||
/// Export entry within a policy pack bundle.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackExport(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("querySignature")] string? QuerySignature,
|
||||
[property: JsonPropertyName("createdAt")] string CreatedAt,
|
||||
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
|
||||
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("sourceProviders")] IReadOnlyList<string>? SourceProviders,
|
||||
[property: JsonPropertyName("consensusRevision")] string? ConsensusRevision,
|
||||
[property: JsonPropertyName("policyRevisionId")] string? PolicyRevisionId,
|
||||
[property: JsonPropertyName("policyDigest")] string? PolicyDigest,
|
||||
[property: JsonPropertyName("consensusDigest")] string? ConsensusDigest,
|
||||
[property: JsonPropertyName("scoreDigest")] string? ScoreDigest,
|
||||
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata for signed exports.
|
||||
/// </summary>
|
||||
public sealed record AttestationDescriptor(
|
||||
[property: JsonPropertyName("predicateType")] string PredicateType,
|
||||
[property: JsonPropertyName("rekorLocation")] string? RekorLocation,
|
||||
[property: JsonPropertyName("envelopeDigest")] string? EnvelopeDigest,
|
||||
[property: JsonPropertyName("signedAt")] string SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle signature metadata.
|
||||
/// </summary>
|
||||
public sealed record BundleSignature(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("keyId")] string KeyId,
|
||||
[property: JsonPropertyName("provider")] string? Provider,
|
||||
[property: JsonPropertyName("signedAt")] string SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to register a bundle for import.
|
||||
/// </summary>
|
||||
public sealed record RegisterBundleRequest(
|
||||
[property: JsonPropertyName("bundlePath")] string BundlePath,
|
||||
[property: JsonPropertyName("trustRootsPath")] string? TrustRootsPath);
|
||||
|
||||
/// <summary>
|
||||
/// Response for bundle registration.
|
||||
/// </summary>
|
||||
public sealed record RegisterBundleResponse(
|
||||
[property: JsonPropertyName("importId")] string ImportId,
|
||||
[property: JsonPropertyName("status")] string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import status response.
|
||||
/// </summary>
|
||||
public sealed record BundleStatusResponse(
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("exportCount")] int ExportCount,
|
||||
[property: JsonPropertyName("importedAt")] string? ImportedAt,
|
||||
[property: JsonPropertyName("error")] string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Imported bundle catalog entry.
|
||||
/// </summary>
|
||||
public sealed record ImportedPolicyPackBundle(
|
||||
string BundleId,
|
||||
string DomainId,
|
||||
string TenantId,
|
||||
string Status,
|
||||
int ExportCount,
|
||||
string ImportedAt,
|
||||
string? Error,
|
||||
PolicyPackBundle? Bundle);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle import status values.
|
||||
/// </summary>
|
||||
public static class BundleImportStatus
|
||||
{
|
||||
public const string Validating = "validating";
|
||||
public const string Importing = "importing";
|
||||
public const string Imported = "imported";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Domain IDs per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class BundleDomainIds
|
||||
{
|
||||
public const string VexAdvisories = "vex-advisories";
|
||||
public const string VulnerabilityFeeds = "vulnerability-feeds";
|
||||
public const string PolicyPacks = "policy-packs";
|
||||
public const string SbomCatalog = "sbom-catalog";
|
||||
}
|
||||
Reference in New Issue
Block a user