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