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:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user