feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotBundleWriter.cs
|
||||
// Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import)
|
||||
// Task: SEAL-003 - Create SnapshotBundleWriter
|
||||
// Description: Writes sealed knowledge snapshots to tar.gz bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using PolicySnapshotEntry = StellaOps.AirGap.Bundle.Models.PolicySnapshotEntry;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Writes sealed knowledge snapshots to tar.gz bundles with manifest and merkle root.
|
||||
/// </summary>
|
||||
public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a knowledge snapshot bundle from the specified contents.
|
||||
/// </summary>
|
||||
public async Task<SnapshotBundleResult> WriteAsync(
|
||||
SnapshotBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputPath);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var entries = new List<BundleEntry>();
|
||||
var manifest = new KnowledgeSnapshotManifest
|
||||
{
|
||||
BundleId = request.BundleId ?? Guid.NewGuid().ToString("N"),
|
||||
Name = request.Name ?? $"knowledge-{DateTime.UtcNow:yyyy-MM-dd}",
|
||||
Version = request.Version ?? "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SchemaVersion = "1.0.0"
|
||||
};
|
||||
|
||||
// Write advisories
|
||||
if (request.Advisories is { Count: > 0 })
|
||||
{
|
||||
var advisoriesDir = Path.Combine(tempDir, "advisories");
|
||||
Directory.CreateDirectory(advisoriesDir);
|
||||
|
||||
foreach (var advisory in request.Advisories)
|
||||
{
|
||||
var feedDir = Path.Combine(advisoriesDir, advisory.FeedId);
|
||||
Directory.CreateDirectory(feedDir);
|
||||
|
||||
var filePath = Path.Combine(feedDir, advisory.FileName);
|
||||
await File.WriteAllBytesAsync(filePath, advisory.Content, cancellationToken);
|
||||
|
||||
var relativePath = $"advisories/{advisory.FeedId}/{advisory.FileName}";
|
||||
var digest = ComputeSha256(advisory.Content);
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, advisory.Content.Length));
|
||||
manifest.Advisories.Add(new AdvisorySnapshotEntry
|
||||
{
|
||||
FeedId = advisory.FeedId,
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = advisory.Content.Length,
|
||||
SnapshotAt = advisory.SnapshotAt ?? DateTimeOffset.UtcNow,
|
||||
RecordCount = advisory.RecordCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write VEX statements
|
||||
if (request.VexStatements is { Count: > 0 })
|
||||
{
|
||||
var vexDir = Path.Combine(tempDir, "vex");
|
||||
Directory.CreateDirectory(vexDir);
|
||||
|
||||
foreach (var vex in request.VexStatements)
|
||||
{
|
||||
var sourceDir = Path.Combine(vexDir, vex.SourceId);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var filePath = Path.Combine(sourceDir, vex.FileName);
|
||||
await File.WriteAllBytesAsync(filePath, vex.Content, cancellationToken);
|
||||
|
||||
var relativePath = $"vex/{vex.SourceId}/{vex.FileName}";
|
||||
var digest = ComputeSha256(vex.Content);
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, vex.Content.Length));
|
||||
manifest.VexStatements.Add(new VexSnapshotEntry
|
||||
{
|
||||
SourceId = vex.SourceId,
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = vex.Content.Length,
|
||||
SnapshotAt = vex.SnapshotAt ?? DateTimeOffset.UtcNow,
|
||||
StatementCount = vex.StatementCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write policies
|
||||
if (request.Policies is { Count: > 0 })
|
||||
{
|
||||
var policiesDir = Path.Combine(tempDir, "policies");
|
||||
Directory.CreateDirectory(policiesDir);
|
||||
|
||||
foreach (var policy in request.Policies)
|
||||
{
|
||||
var filePath = Path.Combine(policiesDir, policy.FileName);
|
||||
await File.WriteAllBytesAsync(filePath, policy.Content, cancellationToken);
|
||||
|
||||
var relativePath = $"policies/{policy.FileName}";
|
||||
var digest = ComputeSha256(policy.Content);
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, policy.Content.Length));
|
||||
manifest.Policies.Add(new PolicySnapshotEntry
|
||||
{
|
||||
PolicyId = policy.PolicyId,
|
||||
Name = policy.Name,
|
||||
Version = policy.Version,
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = policy.Content.Length,
|
||||
Type = policy.Type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write trust roots
|
||||
if (request.TrustRoots is { Count: > 0 })
|
||||
{
|
||||
var trustDir = Path.Combine(tempDir, "trust");
|
||||
Directory.CreateDirectory(trustDir);
|
||||
|
||||
foreach (var trustRoot in request.TrustRoots)
|
||||
{
|
||||
var filePath = Path.Combine(trustDir, trustRoot.FileName);
|
||||
await File.WriteAllBytesAsync(filePath, trustRoot.Content, cancellationToken);
|
||||
|
||||
var relativePath = $"trust/{trustRoot.FileName}";
|
||||
var digest = ComputeSha256(trustRoot.Content);
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, trustRoot.Content.Length));
|
||||
manifest.TrustRoots.Add(new TrustRootSnapshotEntry
|
||||
{
|
||||
KeyId = trustRoot.KeyId,
|
||||
RelativePath = relativePath,
|
||||
Digest = digest,
|
||||
SizeBytes = trustRoot.Content.Length,
|
||||
Algorithm = trustRoot.Algorithm,
|
||||
ExpiresAt = trustRoot.ExpiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write time anchor
|
||||
if (request.TimeAnchor is not null)
|
||||
{
|
||||
var timeAnchorPath = Path.Combine(tempDir, "time-anchor.json");
|
||||
var timeAnchorJson = JsonSerializer.SerializeToUtf8Bytes(request.TimeAnchor, JsonOptions);
|
||||
await File.WriteAllBytesAsync(timeAnchorPath, timeAnchorJson, cancellationToken);
|
||||
|
||||
var digest = ComputeSha256(timeAnchorJson);
|
||||
entries.Add(new BundleEntry("time-anchor.json", digest, timeAnchorJson.Length));
|
||||
manifest.TimeAnchor = new TimeAnchorEntry
|
||||
{
|
||||
AnchorTime = request.TimeAnchor.AnchorTime,
|
||||
Source = request.TimeAnchor.Source,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Compute merkle root
|
||||
manifest.MerkleRoot = ComputeMerkleRoot(entries);
|
||||
manifest.TotalSizeBytes = entries.Sum(e => e.SizeBytes);
|
||||
manifest.EntryCount = entries.Count;
|
||||
|
||||
// Write manifest
|
||||
var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
await File.WriteAllBytesAsync(manifestPath, manifestJson, cancellationToken);
|
||||
|
||||
// Sign manifest if requested
|
||||
string? signingKeyId = null;
|
||||
string? signingAlgorithm = null;
|
||||
var signed = false;
|
||||
|
||||
if (request.Sign)
|
||||
{
|
||||
var signer = new SnapshotManifestSigner();
|
||||
var signResult = await signer.SignAsync(new ManifestSigningRequest
|
||||
{
|
||||
ManifestBytes = manifestJson,
|
||||
KeyFilePath = request.SigningKeyPath,
|
||||
KeyPassword = request.SigningKeyPassword
|
||||
}, cancellationToken);
|
||||
|
||||
if (signResult.Success && signResult.Envelope is not null)
|
||||
{
|
||||
var signaturePath = Path.Combine(tempDir, "manifest.sig");
|
||||
await File.WriteAllBytesAsync(signaturePath, signResult.Envelope, cancellationToken);
|
||||
signingKeyId = signResult.KeyId;
|
||||
signingAlgorithm = signResult.Algorithm;
|
||||
signed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create tar.gz bundle
|
||||
var outputPath = request.OutputPath;
|
||||
if (!outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
outputPath = $"{outputPath}.tar.gz";
|
||||
}
|
||||
|
||||
await CreateTarGzAsync(tempDir, outputPath, cancellationToken);
|
||||
|
||||
var bundleDigest = await ComputeFileDigestAsync(outputPath, cancellationToken);
|
||||
|
||||
return new SnapshotBundleResult
|
||||
{
|
||||
Success = true,
|
||||
OutputPath = outputPath,
|
||||
BundleId = manifest.BundleId,
|
||||
MerkleRoot = manifest.MerkleRoot,
|
||||
BundleDigest = bundleDigest,
|
||||
TotalSizeBytes = new FileInfo(outputPath).Length,
|
||||
EntryCount = entries.Count,
|
||||
CreatedAt = manifest.CreatedAt,
|
||||
Signed = signed,
|
||||
SigningKeyId = signingKeyId,
|
||||
SigningAlgorithm = signingAlgorithm
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temp directory
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(List<BundleEntry> entries)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var leaves = entries
|
||||
.OrderBy(e => e.Path, StringComparer.Ordinal)
|
||||
.Select(e => SHA256.HashData(Encoding.UTF8.GetBytes($"{e.Path}:{e.Digest}")))
|
||||
.ToArray();
|
||||
|
||||
while (leaves.Length > 1)
|
||||
{
|
||||
leaves = PairwiseHash(leaves).ToArray();
|
||||
}
|
||||
|
||||
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static IEnumerable<byte[]> PairwiseHash(byte[][] nodes)
|
||||
{
|
||||
for (var i = 0; i < nodes.Length; i += 2)
|
||||
{
|
||||
if (i + 1 >= nodes.Length)
|
||||
{
|
||||
yield return SHA256.HashData(nodes[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
|
||||
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
|
||||
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
|
||||
yield return SHA256.HashData(combined);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateTarGzAsync(string sourceDir, string outputPath, CancellationToken ct)
|
||||
{
|
||||
var outputDir = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
}
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal);
|
||||
await TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, includeBaseDirectory: false, ct);
|
||||
}
|
||||
|
||||
private sealed record BundleEntry(string Path, string Digest, long SizeBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot bundle writing.
|
||||
/// </summary>
|
||||
public interface ISnapshotBundleWriter
|
||||
{
|
||||
Task<SnapshotBundleResult> WriteAsync(
|
||||
SnapshotBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Request and Result Models
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating a knowledge snapshot bundle.
|
||||
/// </summary>
|
||||
public sealed record SnapshotBundleRequest
|
||||
{
|
||||
public required string OutputPath { get; init; }
|
||||
public string? BundleId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public List<AdvisoryContent> Advisories { get; init; } = [];
|
||||
public List<VexContent> VexStatements { get; init; } = [];
|
||||
public List<PolicyContent> Policies { get; init; } = [];
|
||||
public List<TrustRootContent> TrustRoots { get; init; } = [];
|
||||
public TimeAnchorContent? TimeAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign the manifest.
|
||||
/// </summary>
|
||||
public bool Sign { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to signing key file (PEM format).
|
||||
/// If null and Sign is true, an ephemeral key will be used.
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for encrypted signing key.
|
||||
/// </summary>
|
||||
public string? SigningKeyPassword { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryContent
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public DateTimeOffset? SnapshotAt { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexContent
|
||||
{
|
||||
public required string SourceId { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public DateTimeOffset? SnapshotAt { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyContent
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public string Type { get; init; } = "OpaRego";
|
||||
}
|
||||
|
||||
public sealed record TrustRootContent
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required byte[] Content { get; init; }
|
||||
public string Algorithm { get; init; } = "ES256";
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TimeAnchorContent
|
||||
{
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public string? TokenDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a knowledge snapshot bundle.
|
||||
/// </summary>
|
||||
public sealed record SnapshotBundleResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? OutputPath { get; init; }
|
||||
public string? BundleId { get; init; }
|
||||
public string? MerkleRoot { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public int EntryCount { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the manifest was signed.
|
||||
/// </summary>
|
||||
public bool Signed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used for signing.
|
||||
/// </summary>
|
||||
public string? SigningAlgorithm { get; init; }
|
||||
|
||||
public static SnapshotBundleResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user