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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -36,6 +36,8 @@ public sealed class ScannerWorkerOptions
public DeterminismOptions Determinism { get; } = new();
public VerdictPushOptions VerdictPush { get; } = new();
public sealed class QueueOptions
{
public int MaxAttempts { get; set; } = 5;
@@ -245,4 +247,68 @@ public sealed class ScannerWorkerOptions
/// </summary>
public bool AllowDeterministicFallback { get; set; } = true;
}
/// <summary>
/// Options for pushing verdicts as OCI referrer artifacts.
/// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
/// </summary>
public sealed class VerdictPushOptions
{
/// <summary>
/// Enable verdict pushing to OCI registries.
/// When disabled, the verdict push stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Default registry to push verdicts to (e.g., "registry.example.com").
/// </summary>
public string DefaultRegistry { get; set; } = string.Empty;
/// <summary>
/// Allow insecure HTTP connections to registries.
/// </summary>
public bool AllowInsecure { get; set; }
/// <summary>
/// Registry authentication settings.
/// </summary>
public VerdictPushAuthOptions Auth { get; } = new();
/// <summary>
/// Timeout for push operations.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum retry attempts for failed push operations.
/// </summary>
public int MaxRetries { get; set; } = 3;
}
/// <summary>
/// Authentication options for verdict push operations.
/// </summary>
public sealed class VerdictPushAuthOptions
{
/// <summary>
/// Username for basic authentication.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Password for basic authentication.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Bearer token for token-based authentication.
/// </summary>
public string? Token { get; set; }
/// <summary>
/// Allow fallback to anonymous access if credentials fail.
/// </summary>
public bool AllowAnonymousFallback { get; set; } = true;
}
}

View File

@@ -14,6 +14,9 @@ public static class ScanStageNames
public const string EmitReports = "emit-reports";
public const string Entropy = "entropy";
// Sprint: SPRINT_4300_0001_0001 - OCI Verdict Attestation Push
public const string PushVerdict = "push-verdict";
public static readonly IReadOnlyList<string> Ordered = new[]
{
IngestReplay,
@@ -25,6 +28,7 @@ public static class ScanStageNames
ComposeArtifacts,
Entropy,
EmitReports,
PushVerdict,
};
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// VerdictPushStageExecutor.cs
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
// Description: Stage executor for pushing verdicts as OCI referrer artifacts.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Storage.Oci;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Stage executor that pushes scan verdicts as OCI referrer artifacts.
/// This enables verdicts to be portable "ship tokens" attached to container images.
/// </summary>
public sealed class VerdictPushStageExecutor : IScanStageExecutor
{
private readonly VerdictOciPublisher _publisher;
private readonly ILogger<VerdictPushStageExecutor> _logger;
public VerdictPushStageExecutor(
VerdictOciPublisher publisher,
ILogger<VerdictPushStageExecutor> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.PushVerdict;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (!IsVerdictPushEnabled(context))
{
_logger.LogDebug("Verdict push disabled for job {JobId}; skipping.", context.JobId);
return;
}
var options = ResolveVerdictPushOptions(context);
if (options is null)
{
_logger.LogWarning("Verdict push enabled but required options missing for job {JobId}; skipping.", context.JobId);
return;
}
var envelope = ResolveVerdictEnvelope(context);
if (envelope is null)
{
_logger.LogWarning("No verdict envelope available for job {JobId}; skipping verdict push.", context.JobId);
return;
}
var request = new VerdictOciPublishRequest
{
Reference = options.RegistryReference,
ImageDigest = options.ImageDigest,
DsseEnvelopeBytes = envelope,
SbomDigest = options.SbomDigest,
FeedsDigest = options.FeedsDigest,
PolicyDigest = options.PolicyDigest,
Decision = options.Decision,
GraphRevisionId = options.GraphRevisionId,
ProofBundleDigest = options.ProofBundleDigest,
VerdictTimestamp = context.TimeProvider.GetUtcNow()
};
try
{
var result = await _publisher.PushAsync(request, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
_logger.LogInformation(
"Pushed verdict for job {JobId} to {Reference} with digest {ManifestDigest}.",
context.JobId,
request.Reference,
result.ManifestDigest);
// Store the push result in the analysis store for downstream consumers
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestDigest, result.ManifestDigest ?? string.Empty);
context.Analysis.Set(VerdictPushAnalysisKeys.VerdictManifestReference, result.ManifestReference ?? string.Empty);
}
else
{
_logger.LogError(
"Failed to push verdict for job {JobId}: {Error}",
context.JobId,
result.Error);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Exception during verdict push for job {JobId}.", context.JobId);
throw;
}
}
private static bool IsVerdictPushEnabled(ScanJobContext context)
{
// Check if verdict push is explicitly enabled via metadata
if (context.Lease.Metadata.TryGetValue(VerdictPushMetadataKeys.Enabled, out var enabledValue))
{
return string.Equals(enabledValue, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(enabledValue, "1", StringComparison.Ordinal);
}
return false;
}
private static VerdictPushOptions? ResolveVerdictPushOptions(ScanJobContext context)
{
var metadata = context.Lease.Metadata;
// Required: registry reference
if (!metadata.TryGetValue(VerdictPushMetadataKeys.RegistryReference, out var registryRef) ||
string.IsNullOrWhiteSpace(registryRef))
{
return null;
}
// Required: image digest
var imageDigest = ResolveImageDigest(context);
if (string.IsNullOrWhiteSpace(imageDigest))
{
return null;
}
// Required: decision
if (!metadata.TryGetValue(VerdictPushMetadataKeys.Decision, out var decision) ||
string.IsNullOrWhiteSpace(decision))
{
decision = "unknown";
}
return new VerdictPushOptions
{
RegistryReference = registryRef!,
ImageDigest = imageDigest,
SbomDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.SbomDigest) ?? "sha256:unknown",
FeedsDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.FeedsDigest) ?? "sha256:unknown",
PolicyDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.PolicyDigest) ?? "sha256:unknown",
Decision = decision,
GraphRevisionId = metadata.GetValueOrDefault(VerdictPushMetadataKeys.GraphRevisionId),
ProofBundleDigest = metadata.GetValueOrDefault(VerdictPushMetadataKeys.ProofBundleDigest)
};
}
private static string? ResolveImageDigest(ScanJobContext context)
{
var metadata = context.Lease.Metadata;
if (metadata.TryGetValue("image.digest", out var digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("imageDigest", out digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("scanner.image.digest", out digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
return null;
}
private static byte[]? ResolveVerdictEnvelope(ScanJobContext context)
{
// Try to get the verdict DSSE envelope from the analysis store
if (context.Analysis.TryGet<byte[]>(VerdictPushAnalysisKeys.VerdictDsseEnvelope, out var envelope) && envelope is not null)
{
return envelope;
}
// Fallback: try to get it from a known attestation payload
if (context.Analysis.TryGet<ReadOnlyMemory<byte>>(VerdictPushAnalysisKeys.VerdictDsseEnvelopeMemory, out var memory) && memory.Length > 0)
{
return memory.ToArray();
}
return null;
}
private sealed class VerdictPushOptions
{
public required string RegistryReference { get; init; }
public required string ImageDigest { get; init; }
public required string SbomDigest { get; init; }
public required string FeedsDigest { get; init; }
public required string PolicyDigest { get; init; }
public required string Decision { get; init; }
public string? GraphRevisionId { get; init; }
public string? ProofBundleDigest { get; init; }
}
}
/// <summary>
/// Metadata keys for verdict push configuration.
/// </summary>
public static class VerdictPushMetadataKeys
{
public const string Enabled = "verdict.push.enabled";
public const string RegistryReference = "verdict.push.registry";
public const string SbomDigest = "verdict.sbom.digest";
public const string FeedsDigest = "verdict.feeds.digest";
public const string PolicyDigest = "verdict.policy.digest";
public const string Decision = "verdict.decision";
public const string GraphRevisionId = "verdict.graph.revision.id";
public const string ProofBundleDigest = "verdict.proof.bundle.digest";
}
/// <summary>
/// Analysis store keys for verdict push results.
/// </summary>
public static class VerdictPushAnalysisKeys
{
public const string VerdictDsseEnvelope = "verdict.dsse.envelope";
public const string VerdictDsseEnvelopeMemory = "verdict.dsse.envelope.memory";
public const string VerdictManifestDigest = "verdict.push.manifest.digest";
public const string VerdictManifestReference = "verdict.push.manifest.reference";
}

View File

@@ -161,6 +161,33 @@ builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuild
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
// Verdict push infrastructure (Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push)
if (workerOptions.VerdictPush.Enabled)
{
builder.Services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<ScannerWorkerOptions>>().Value.VerdictPush;
return new StellaOps.Scanner.Storage.Oci.OciRegistryOptions
{
DefaultRegistry = opts.DefaultRegistry,
AllowInsecure = opts.AllowInsecure,
Auth = new StellaOps.Scanner.Storage.Oci.OciRegistryAuthOptions
{
Username = opts.Auth.Username,
Password = opts.Auth.Password,
Token = opts.Auth.Token,
AllowAnonymousFallback = opts.Auth.AllowAnonymousFallback
}
};
});
builder.Services.AddHttpClient<StellaOps.Scanner.Storage.Oci.OciArtifactPusher>(client =>
{
client.Timeout = workerOptions.VerdictPush.Timeout;
});
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Oci.VerdictOciPublisher>();
builder.Services.AddSingleton<IScanStageExecutor, Processing.VerdictPushStageExecutor>();
}
builder.Services.AddSingleton<ScannerWorkerHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ScannerWorkerHostedService>());