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,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