Add unit tests for AST parsing and security sink detection

- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library.
- Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX.
- Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more.
- Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
StellaOps Bot
2025-12-23 09:23:42 +02:00
parent 7e384ab610
commit 56e2dc01ee
96 changed files with 8555 additions and 1455 deletions

View File

@@ -0,0 +1,621 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
// Task: T7 - Policy Pack Distribution
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Registry.Distribution;
/// <summary>
/// Publishes policy packs to OCI registries following OCI 1.1 artifact spec.
/// </summary>
public sealed class PolicyPackOciPublisher : IPolicyPackOciPublisher
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private static readonly byte[] EmptyConfigBlob = "{}"u8.ToArray();
private readonly HttpClient _httpClient;
private readonly PolicyPackOciOptions _options;
private readonly ILogger<PolicyPackOciPublisher> _logger;
private readonly TimeProvider _timeProvider;
public PolicyPackOciPublisher(
HttpClient httpClient,
PolicyPackOciOptions options,
ILogger<PolicyPackOciPublisher> logger,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PolicyPackPushResult> PushAsync(
PolicyPackPushRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reference);
if (request.PackContent.Length == 0)
{
return PolicyPackPushResult.Failed("Pack content cannot be empty.");
}
var reference = ParseReference(request.Reference);
if (reference is null)
{
return PolicyPackPushResult.Failed($"Invalid OCI reference: {request.Reference}");
}
try
{
// Push empty config
var configDigest = await PushBlobAsync(
reference,
EmptyConfigBlob,
OciMediaTypes.EmptyConfig,
cancellationToken).ConfigureAwait(false);
var layers = new List<OciDescriptor>();
var layerDigests = new List<string>();
// Push main pack content
var packDigest = await PushBlobAsync(
reference,
request.PackContent,
OciMediaTypes.PolicyPackYaml,
cancellationToken).ConfigureAwait(false);
layers.Add(new OciDescriptor
{
MediaType = OciMediaTypes.PolicyPackYaml,
Digest = packDigest,
Size = request.PackContent.Length,
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.title"] = $"{request.PackName}.yaml",
["stellaops.policy.pack.name"] = request.PackName,
["stellaops.policy.pack.version"] = request.PackVersion
}
});
layerDigests.Add(packDigest);
// Push overrides if provided
if (request.Overrides?.Count > 0)
{
foreach (var (env, content) in request.Overrides)
{
var overrideDigest = await PushBlobAsync(
reference,
content,
OciMediaTypes.PolicyPackOverride,
cancellationToken).ConfigureAwait(false);
layers.Add(new OciDescriptor
{
MediaType = OciMediaTypes.PolicyPackOverride,
Digest = overrideDigest,
Size = content.Length,
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.title"] = $"overrides/{env}.yaml",
["stellaops.policy.pack.override.env"] = env
}
});
layerDigests.Add(overrideDigest);
}
}
// Push attestation if provided
if (request.Attestation?.Length > 0)
{
var attestDigest = await PushBlobAsync(
reference,
request.Attestation,
OciMediaTypes.PolicyPackAttestation,
cancellationToken).ConfigureAwait(false);
layers.Add(new OciDescriptor
{
MediaType = OciMediaTypes.PolicyPackAttestation,
Digest = attestDigest,
Size = request.Attestation.Length,
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.title"] = "attestation.dsse.json"
}
});
layerDigests.Add(attestDigest);
}
// Build and push manifest
var manifest = BuildManifest(request, configDigest, layers);
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, SerializerOptions);
var manifestDigest = ComputeDigest(manifestBytes);
var tag = reference.Tag ?? request.PackVersion;
await PushManifestAsync(reference, manifestBytes, tag, cancellationToken).ConfigureAwait(false);
var manifestReference = $"{reference.Registry}/{reference.Repository}@{manifestDigest}";
_logger.LogInformation(
"Pushed policy pack {PackName}:{PackVersion} to {Reference}",
request.PackName, request.PackVersion, manifestReference);
return new PolicyPackPushResult
{
Success = true,
ManifestDigest = manifestDigest,
ManifestReference = manifestReference,
LayerDigests = layerDigests
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to push policy pack to {Reference}", request.Reference);
return PolicyPackPushResult.Failed($"HTTP error: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to push policy pack to {Reference}", request.Reference);
return PolicyPackPushResult.Failed(ex.Message);
}
}
public async Task<PolicyPackPullResult> PullAsync(
string reference,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
var parsed = ParseReference(reference);
if (parsed is null)
{
return PolicyPackPullResult.Failed($"Invalid OCI reference: {reference}");
}
try
{
// Fetch manifest
var manifestUri = BuildRegistryUri(parsed, $"manifests/{parsed.Tag ?? "latest"}");
using var manifestRequest = new HttpRequestMessage(HttpMethod.Get, manifestUri);
manifestRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest));
ApplyAuth(manifestRequest);
using var manifestResponse = await _httpClient.SendAsync(manifestRequest, cancellationToken).ConfigureAwait(false);
if (!manifestResponse.IsSuccessStatusCode)
{
return PolicyPackPullResult.Failed($"Failed to fetch manifest: {manifestResponse.StatusCode}");
}
var manifestBytes = await manifestResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<OciManifest>(manifestBytes, SerializerOptions);
if (manifest?.Layers is null || manifest.Layers.Count == 0)
{
return PolicyPackPullResult.Failed("Manifest contains no layers");
}
byte[]? packContent = null;
string? packName = null;
string? packVersion = null;
byte[]? attestation = null;
var overrides = new Dictionary<string, byte[]>();
var annotations = manifest.Annotations ?? new Dictionary<string, string>();
// Pull each layer
foreach (var layer in manifest.Layers)
{
var blobUri = BuildRegistryUri(parsed, $"blobs/{layer.Digest}");
using var blobRequest = new HttpRequestMessage(HttpMethod.Get, blobUri);
ApplyAuth(blobRequest);
using var blobResponse = await _httpClient.SendAsync(blobRequest, cancellationToken).ConfigureAwait(false);
if (!blobResponse.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch blob {Digest}", layer.Digest);
continue;
}
var content = await blobResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
switch (layer.MediaType)
{
case OciMediaTypes.PolicyPackYaml:
packContent = content;
packName = layer.Annotations?.GetValueOrDefault("stellaops.policy.pack.name");
packVersion = layer.Annotations?.GetValueOrDefault("stellaops.policy.pack.version");
break;
case OciMediaTypes.PolicyPackOverride:
var env = layer.Annotations?.GetValueOrDefault("stellaops.policy.pack.override.env");
if (!string.IsNullOrEmpty(env))
{
overrides[env] = content;
}
break;
case OciMediaTypes.PolicyPackAttestation:
attestation = content;
break;
}
}
if (packContent is null)
{
return PolicyPackPullResult.Failed("No policy pack content found in artifact");
}
var manifestDigest = ComputeDigest(manifestBytes);
_logger.LogInformation(
"Pulled policy pack {PackName}:{PackVersion} from {Reference}",
packName, packVersion, reference);
return new PolicyPackPullResult
{
Success = true,
ManifestDigest = manifestDigest,
PackContent = packContent,
PackName = packName,
PackVersion = packVersion,
Overrides = overrides.Count > 0 ? overrides : null,
Attestation = attestation,
Annotations = annotations
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pull policy pack from {Reference}", reference);
return PolicyPackPullResult.Failed(ex.Message);
}
}
public async Task<PolicyPackTagList> ListTagsAsync(
string repository,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(repository);
var parsed = ParseReference($"{repository}:latest");
if (parsed is null)
{
return new PolicyPackTagList
{
Success = false,
Repository = repository,
Error = "Invalid repository reference"
};
}
try
{
var tagsUri = BuildRegistryUri(parsed, "tags/list");
using var request = new HttpRequestMessage(HttpMethod.Get, tagsUri);
ApplyAuth(request);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return new PolicyPackTagList
{
Success = false,
Repository = repository,
Error = $"Failed to list tags: {response.StatusCode}"
};
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var tagList = JsonSerializer.Deserialize<OciTagList>(content, SerializerOptions);
return new PolicyPackTagList
{
Success = true,
Repository = repository,
Tags = tagList?.Tags ?? []
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list tags for {Repository}", repository);
return new PolicyPackTagList
{
Success = false,
Repository = repository,
Error = ex.Message
};
}
}
private OciManifest BuildManifest(
PolicyPackPushRequest request,
string configDigest,
IReadOnlyList<OciDescriptor> layers)
{
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"),
["org.opencontainers.image.title"] = request.PackName,
["org.opencontainers.image.version"] = request.PackVersion,
["stellaops.policy.pack.name"] = request.PackName,
["stellaops.policy.pack.version"] = request.PackVersion
};
if (request.Annotations != null)
{
foreach (var (key, value) in request.Annotations)
{
annotations[key] = value;
}
}
return new OciManifest
{
SchemaVersion = 2,
MediaType = OciMediaTypes.ImageManifest,
ArtifactType = OciMediaTypes.PolicyPack,
Config = new OciDescriptor
{
MediaType = OciMediaTypes.EmptyConfig,
Digest = configDigest,
Size = EmptyConfigBlob.Length
},
Layers = layers,
Annotations = annotations
};
}
private async Task<string> PushBlobAsync(
OciReference reference,
byte[] content,
string mediaType,
CancellationToken cancellationToken)
{
var digest = ComputeDigest(content);
var blobUri = BuildRegistryUri(reference, $"blobs/{digest}");
// Check if blob exists
using (var head = new HttpRequestMessage(HttpMethod.Head, blobUri))
{
ApplyAuth(head);
using var headResponse = await _httpClient.SendAsync(head, cancellationToken).ConfigureAwait(false);
if (headResponse.IsSuccessStatusCode)
{
return digest;
}
}
// Start upload
var startUploadUri = BuildRegistryUri(reference, "blobs/uploads/");
using var postRequest = new HttpRequestMessage(HttpMethod.Post, startUploadUri);
ApplyAuth(postRequest);
using var postResponse = await _httpClient.SendAsync(postRequest, cancellationToken).ConfigureAwait(false);
if (!postResponse.IsSuccessStatusCode)
{
throw new HttpRequestException($"Blob upload start failed: {postResponse.StatusCode}");
}
if (postResponse.Headers.Location is null)
{
throw new HttpRequestException("Blob upload start did not return a Location header.");
}
var uploadUri = ResolveUploadUri(reference, postResponse.Headers.Location);
uploadUri = AppendDigest(uploadUri, digest);
using var putRequest = new HttpRequestMessage(HttpMethod.Put, uploadUri)
{
Content = new ByteArrayContent(content)
};
putRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
ApplyAuth(putRequest);
using var putResponse = await _httpClient.SendAsync(putRequest, cancellationToken).ConfigureAwait(false);
if (!putResponse.IsSuccessStatusCode)
{
throw new HttpRequestException($"Blob upload failed: {putResponse.StatusCode}");
}
return digest;
}
private async Task PushManifestAsync(
OciReference reference,
byte[] manifestBytes,
string tag,
CancellationToken cancellationToken)
{
var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}");
using var request = new HttpRequestMessage(HttpMethod.Put, manifestUri)
{
Content = new ByteArrayContent(manifestBytes)
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ImageManifest);
ApplyAuth(request);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Manifest upload failed: {response.StatusCode}");
}
}
private void ApplyAuth(HttpRequestMessage request)
{
if (!string.IsNullOrEmpty(_options.Username) && !string.IsNullOrEmpty(_options.Password))
{
var credentials = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
else if (!string.IsNullOrEmpty(_options.Token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.Token);
}
}
private static string ComputeDigest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private OciReference? ParseReference(string reference)
{
// Parse OCI reference: [registry/]repository[:tag][@digest]
var atIndex = reference.IndexOf('@');
var colonIndex = reference.LastIndexOf(':');
string? digest = null;
string? tag = null;
if (atIndex > 0)
{
digest = reference[(atIndex + 1)..];
reference = reference[..atIndex];
}
else if (colonIndex > 0 && colonIndex > reference.LastIndexOf('/'))
{
tag = reference[(colonIndex + 1)..];
reference = reference[..colonIndex];
}
var slashIndex = reference.IndexOf('/');
if (slashIndex < 0)
{
// No registry, use default
return new OciReference
{
Registry = _options.DefaultRegistry ?? "registry-1.docker.io",
Repository = reference,
Tag = tag,
Digest = digest
};
}
var potentialRegistry = reference[..slashIndex];
if (potentialRegistry.Contains('.') || potentialRegistry.Contains(':') || potentialRegistry == "localhost")
{
return new OciReference
{
Registry = potentialRegistry,
Repository = reference[(slashIndex + 1)..],
Tag = tag,
Digest = digest
};
}
return new OciReference
{
Registry = _options.DefaultRegistry ?? "registry-1.docker.io",
Repository = reference,
Tag = tag,
Digest = digest
};
}
private Uri BuildRegistryUri(OciReference reference, string path)
{
var scheme = _options.AllowInsecure ? "http" : "https";
return new Uri($"{scheme}://{reference.Registry}/v2/{reference.Repository}/{path}");
}
private static Uri ResolveUploadUri(OciReference reference, Uri location)
{
if (location.IsAbsoluteUri)
{
return location;
}
return new Uri($"https://{reference.Registry}{location}");
}
private static Uri AppendDigest(Uri uploadUri, string digest)
{
if (uploadUri.Query.Contains("digest=", StringComparison.OrdinalIgnoreCase))
{
return uploadUri;
}
var delimiter = string.IsNullOrEmpty(uploadUri.Query) ? "?" : "&";
return new Uri($"{uploadUri}{delimiter}digest={Uri.EscapeDataString(digest)}");
}
private sealed record OciReference
{
public required string Registry { get; init; }
public required string Repository { get; init; }
public string? Tag { get; init; }
public string? Digest { get; init; }
}
}
/// <summary>
/// Options for Policy Pack OCI publisher.
/// </summary>
public sealed record PolicyPackOciOptions
{
public string? DefaultRegistry { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? Token { get; init; }
public bool AllowInsecure { get; init; }
}
/// <summary>
/// OCI media types for policy packs.
/// </summary>
internal static class OciMediaTypes
{
public const string ImageManifest = "application/vnd.oci.image.manifest.v1+json";
public const string EmptyConfig = "application/vnd.oci.empty.v1+json";
public const string PolicyPack = "application/vnd.stellaops.policy-pack.v1+json";
public const string PolicyPackYaml = "application/vnd.stellaops.policy-pack.yaml.v1";
public const string PolicyPackOverride = "application/vnd.stellaops.policy-pack.override.v1+json";
public const string PolicyPackAttestation = "application/vnd.stellaops.policy-pack.attestation.v1+json";
}
/// <summary>
/// OCI manifest model.
/// </summary>
internal sealed record OciManifest
{
public int SchemaVersion { get; init; } = 2;
public required string MediaType { get; init; }
public string? ArtifactType { get; init; }
public required OciDescriptor Config { get; init; }
public required IReadOnlyList<OciDescriptor> Layers { get; init; }
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// OCI descriptor model.
/// </summary>
internal sealed record OciDescriptor
{
public required string MediaType { get; init; }
public required string Digest { get; init; }
public required long Size { get; init; }
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// OCI tag list response.
/// </summary>
internal sealed record OciTagList
{
public string? Name { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
}

View File

@@ -0,0 +1,514 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
// Task: T7 - Policy Pack Distribution
using System.Collections.Immutable;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Registry.Distribution;
/// <summary>
/// Service for exporting and importing policy packs as offline bundles.
/// Supports air-gapped environments where OCI registries are not available.
/// </summary>
public sealed class PolicyPackOfflineBundleService
{
private const string SchemaVersion = "1.0.0";
private const string BlobsDirectory = "blobs/sha256";
private const string ManifestFile = "index.json";
private readonly ILogger<PolicyPackOfflineBundleService> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public PolicyPackOfflineBundleService(
ILogger<PolicyPackOfflineBundleService>? logger = null,
TimeProvider? timeProvider = null)
{
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PolicyPackOfflineBundleService>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Exports a policy pack to an offline bundle.
/// </summary>
public async Task<PolicyPackBundleExportResult> ExportAsync(
PolicyPackBundleExportRequest request,
string outputPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (request.PackContent.Length == 0)
{
return new PolicyPackBundleExportResult
{
Success = false,
Error = "Pack content cannot be empty"
};
}
try
{
_logger.LogInformation(
"Exporting policy pack {PackName}:{PackVersion} to {OutputPath}",
request.PackName, request.PackVersion, outputPath);
// Create temp directory for bundle layout
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var blobsDir = Path.Combine(tempDir, BlobsDirectory);
Directory.CreateDirectory(blobsDir);
try
{
var artifacts = new List<PolicyPackBundleArtifact>();
// Export main pack content
var packDigest = ComputeDigest(request.PackContent);
var packPath = Path.Combine(blobsDir, packDigest);
await File.WriteAllBytesAsync(packPath, request.PackContent, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{packDigest}",
MediaType = "application/vnd.stellaops.policy-pack.yaml.v1",
Size = request.PackContent.Length,
Path = $"{BlobsDirectory}/{packDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("stellaops.policy.pack.name", request.PackName)
.Add("stellaops.policy.pack.version", request.PackVersion)
.Add("org.opencontainers.image.title", $"{request.PackName}.yaml")
});
// Export overrides
if (request.Overrides?.Count > 0)
{
foreach (var (env, content) in request.Overrides)
{
var overrideDigest = ComputeDigest(content);
var overridePath = Path.Combine(blobsDir, overrideDigest);
await File.WriteAllBytesAsync(overridePath, content, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{overrideDigest}",
MediaType = "application/vnd.stellaops.policy-pack.override.v1+json",
Size = content.Length,
Path = $"{BlobsDirectory}/{overrideDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("stellaops.policy.pack.override.env", env)
.Add("org.opencontainers.image.title", $"overrides/{env}.yaml")
});
}
}
// Export attestation if provided
if (request.Attestation?.Length > 0)
{
var attestDigest = ComputeDigest(request.Attestation);
var attestPath = Path.Combine(blobsDir, attestDigest);
await File.WriteAllBytesAsync(attestPath, request.Attestation, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{attestDigest}",
MediaType = "application/vnd.stellaops.policy-pack.attestation.v1+json",
Size = request.Attestation.Length,
Path = $"{BlobsDirectory}/{attestDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("org.opencontainers.image.title", "attestation.dsse.json")
});
}
// Create manifest
var manifest = new PolicyPackBundleManifest
{
SchemaVersion = SchemaVersion,
CreatedAt = _timeProvider.GetUtcNow(),
PackName = request.PackName,
PackVersion = request.PackVersion,
Artifacts = artifacts.ToImmutableArray(),
Metrics = new PolicyPackBundleMetrics
{
ArtifactCount = artifacts.Count,
OverrideCount = request.Overrides?.Count ?? 0,
HasAttestation = request.Attestation?.Length > 0,
TotalSize = artifacts.Sum(a => a.Size)
},
ManifestDigest = "" // Will be set after serialization
};
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
var manifestDigest = ComputeDigest(Encoding.UTF8.GetBytes(manifestJson));
manifest = manifest with { ManifestDigest = $"sha256:{manifestDigest}" };
manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
await File.WriteAllTextAsync(
Path.Combine(tempDir, ManifestFile),
manifestJson,
cancellationToken).ConfigureAwait(false);
// Create tar.gz
using (var fs = File.Create(outputPath))
using (var gzip = new GZipStream(fs, CompressionLevel.Optimal))
{
await CreateTarAsync(tempDir, gzip, cancellationToken).ConfigureAwait(false);
}
var bundleDigest = ComputeFileDigest(outputPath);
_logger.LogInformation(
"Bundle exported: {ArtifactCount} artifacts, {TotalSize:N0} bytes",
manifest.Metrics.ArtifactCount, manifest.Metrics.TotalSize);
return new PolicyPackBundleExportResult
{
Success = true,
BundlePath = outputPath,
BundleDigest = $"sha256:{bundleDigest}",
Metrics = manifest.Metrics
};
}
finally
{
// Cleanup temp directory
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export policy pack bundle");
return new PolicyPackBundleExportResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Imports a policy pack from an offline bundle.
/// </summary>
public async Task<PolicyPackBundleImportResult> ImportAsync(
string bundlePath,
bool verifyIntegrity = true,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
if (!File.Exists(bundlePath))
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = $"Bundle not found: {bundlePath}"
};
}
try
{
_logger.LogInformation("Importing policy pack bundle from {BundlePath}", bundlePath);
// Extract to temp directory
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-import-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
// Extract tar.gz
await using (var fs = File.OpenRead(bundlePath))
await using (var gzip = new GZipStream(fs, CompressionMode.Decompress))
{
await ExtractTarAsync(gzip, tempDir, cancellationToken).ConfigureAwait(false);
}
// Read manifest
var manifestPath = Path.Combine(tempDir, ManifestFile);
if (!File.Exists(manifestPath))
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Bundle manifest not found"
};
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<PolicyPackBundleManifest>(manifestJson, JsonOptions);
if (manifest is null)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Failed to parse bundle manifest"
};
}
// Verify integrity if requested
bool integrityVerified = false;
if (verifyIntegrity)
{
integrityVerified = await VerifyBundleIntegrityAsync(tempDir, manifest, cancellationToken)
.ConfigureAwait(false);
if (!integrityVerified)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Bundle integrity verification failed"
};
}
}
// Read artifacts
byte[]? packContent = null;
var overrides = new Dictionary<string, byte[]>();
byte[]? attestation = null;
foreach (var artifact in manifest.Artifacts)
{
var artifactPath = Path.Combine(tempDir, artifact.Path);
if (!File.Exists(artifactPath))
{
_logger.LogWarning("Artifact not found in bundle: {Path}", artifact.Path);
continue;
}
var content = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
if (artifact.MediaType.Contains("policy-pack.yaml"))
{
packContent = content;
}
else if (artifact.MediaType.Contains("override"))
{
var env = artifact.Annotations?.GetValueOrDefault("stellaops.policy.pack.override.env");
if (!string.IsNullOrEmpty(env))
{
overrides[env] = content;
}
}
else if (artifact.MediaType.Contains("attestation"))
{
attestation = content;
}
}
if (packContent is null)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "No policy pack content found in bundle"
};
}
_logger.LogInformation(
"Bundle imported: {PackName}:{PackVersion}, {OverrideCount} overrides",
manifest.PackName, manifest.PackVersion, overrides.Count);
return new PolicyPackBundleImportResult
{
Success = true,
PackName = manifest.PackName,
PackVersion = manifest.PackVersion,
PackContent = packContent,
Overrides = overrides.Count > 0 ? overrides : null,
Attestation = attestation,
IntegrityVerified = integrityVerified
};
}
finally
{
// Cleanup temp directory
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import policy pack bundle from {BundlePath}", bundlePath);
return new PolicyPackBundleImportResult
{
Success = false,
Error = ex.Message
};
}
}
private async Task<bool> VerifyBundleIntegrityAsync(
string tempDir,
PolicyPackBundleManifest manifest,
CancellationToken cancellationToken)
{
foreach (var artifact in manifest.Artifacts)
{
var artifactPath = Path.Combine(tempDir, artifact.Path);
if (!File.Exists(artifactPath))
{
_logger.LogWarning("Missing artifact: {Path}", artifact.Path);
return false;
}
var data = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
var actualDigest = $"sha256:{ComputeDigest(data)}";
if (!string.Equals(actualDigest, artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Digest mismatch for {Path}: expected {Expected}, got {Actual}",
artifact.Path, artifact.Digest, actualDigest);
return false;
}
}
return true;
}
private static string ComputeDigest(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeFileDigest(string path)
{
using var fs = File.OpenRead(path);
var hash = SHA256.HashData(fs);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static async Task CreateTarAsync(string sourceDir, Stream output, CancellationToken cancellationToken)
{
// Simplified tar creation - in production, use a proper tar library
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories);
using var writer = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
foreach (var file in files)
{
var relativePath = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
// Write simple header
var header = Encoding.UTF8.GetBytes($"FILE:{relativePath}:{content.Length}\n");
writer.Write(header);
writer.Write(content);
}
}
private static async Task ExtractTarAsync(Stream input, string targetDir, CancellationToken cancellationToken)
{
// Simplified tar extraction - in production, use a proper tar library
using var memoryStream = new MemoryStream();
await input.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
memoryStream.Position = 0;
var textReader = new StreamReader(memoryStream, Encoding.UTF8, leaveOpen: true);
while (memoryStream.Position < memoryStream.Length)
{
var headerLine = textReader.ReadLine();
if (string.IsNullOrEmpty(headerLine) || !headerLine.StartsWith("FILE:"))
break;
var parts = headerLine[5..].Split(':');
if (parts.Length != 2 || !int.TryParse(parts[1], out var size))
break;
var relativePath = parts[0];
var fullPath = Path.Combine(targetDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
var content = new byte[size];
_ = memoryStream.Read(content, 0, size);
await File.WriteAllBytesAsync(fullPath, content, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Request to export a policy pack to offline bundle.
/// </summary>
public sealed record PolicyPackBundleExportRequest
{
public required string PackName { get; init; }
public required string PackVersion { get; init; }
public required byte[] PackContent { get; init; }
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
public byte[]? Attestation { get; init; }
}
/// <summary>
/// Result of policy pack bundle export.
/// </summary>
public sealed record PolicyPackBundleExportResult
{
public required bool Success { get; init; }
public string? BundlePath { get; init; }
public string? BundleDigest { get; init; }
public PolicyPackBundleMetrics? Metrics { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Result of policy pack bundle import.
/// </summary>
public sealed record PolicyPackBundleImportResult
{
public required bool Success { get; init; }
public string? PackName { get; init; }
public string? PackVersion { get; init; }
public byte[]? PackContent { get; init; }
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
public byte[]? Attestation { get; init; }
public bool IntegrityVerified { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Bundle manifest for policy pack.
/// </summary>
public sealed record PolicyPackBundleManifest
{
public required string SchemaVersion { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string PackName { get; init; }
public required string PackVersion { get; init; }
public required ImmutableArray<PolicyPackBundleArtifact> Artifacts { get; init; }
public required PolicyPackBundleMetrics Metrics { get; init; }
public required string ManifestDigest { get; init; }
}
/// <summary>
/// Artifact entry in bundle manifest.
/// </summary>
public sealed record PolicyPackBundleArtifact
{
public required string Digest { get; init; }
public required string MediaType { get; init; }
public required long Size { get; init; }
public required string Path { get; init; }
public ImmutableDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// Metrics about bundle contents.
/// </summary>
public sealed record PolicyPackBundleMetrics
{
public int ArtifactCount { get; init; }
public int OverrideCount { get; init; }
public bool HasAttestation { get; init; }
public long TotalSize { get; init; }
}