save progress
This commit is contained in:
@@ -25,6 +25,12 @@ public sealed class ScannerWorkerMetrics
|
||||
private readonly Counter<long> _surfacePayloadPersisted;
|
||||
private readonly Histogram<double> _surfaceManifestPublishDurationMs;
|
||||
|
||||
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
|
||||
private readonly Counter<long> _secretsAnalysisCompleted;
|
||||
private readonly Counter<long> _secretsAnalysisFailed;
|
||||
private readonly Counter<long> _secretFindingsDetected;
|
||||
private readonly Histogram<double> _secretsAnalysisDurationMs;
|
||||
|
||||
public ScannerWorkerMetrics()
|
||||
{
|
||||
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
@@ -80,6 +86,21 @@ public sealed class ScannerWorkerMetrics
|
||||
"scanner_worker_surface_manifest_publish_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds to persist and publish surface manifests.");
|
||||
|
||||
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
|
||||
_secretsAnalysisCompleted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_analysis_completed_total",
|
||||
description: "Number of successfully completed secrets analysis runs.");
|
||||
_secretsAnalysisFailed = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_analysis_failed_total",
|
||||
description: "Number of secrets analysis runs that failed.");
|
||||
_secretFindingsDetected = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_secrets_findings_detected_total",
|
||||
description: "Number of secret findings detected.");
|
||||
_secretsAnalysisDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_secrets_analysis_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds for secrets analysis.");
|
||||
}
|
||||
|
||||
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
|
||||
@@ -343,4 +364,39 @@ public sealed class ScannerWorkerMetrics
|
||||
// Native analysis metrics are tracked via counters/histograms
|
||||
// This is a placeholder for when we add dedicated native analysis metrics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records successful secrets analysis completion.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public void RecordSecretsAnalysisCompleted(
|
||||
ScanJobContext context,
|
||||
int findingCount,
|
||||
int filesScanned,
|
||||
TimeSpan duration,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
|
||||
_secretsAnalysisCompleted.Add(1, tags);
|
||||
|
||||
if (findingCount > 0)
|
||||
{
|
||||
_secretFindingsDetected.Add(findingCount, tags);
|
||||
}
|
||||
|
||||
if (duration > TimeSpan.Zero)
|
||||
{
|
||||
_secretsAnalysisDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records secrets analysis failure.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public void RecordSecretsAnalysisFailed(ScanJobContext context, TimeProvider timeProvider)
|
||||
{
|
||||
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
|
||||
_secretsAnalysisFailed.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public VerdictPushOptions VerdictPush { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Options for secrets leak detection scanning.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public SecretsOptions Secrets { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
@@ -311,4 +317,43 @@ public sealed class ScannerWorkerOptions
|
||||
/// </summary>
|
||||
public bool AllowAnonymousFallback { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for secrets leak detection scanning.
|
||||
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
/// </summary>
|
||||
public sealed class SecretsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable secrets leak detection scanning.
|
||||
/// When disabled, the secrets scan stage will be skipped.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the secrets ruleset bundle directory.
|
||||
/// </summary>
|
||||
public string RulesetPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size in bytes to scan for secrets.
|
||||
/// Files larger than this will be skipped.
|
||||
/// </summary>
|
||||
public long MaxFileSizeBytes { get; set; } = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of files to scan per job.
|
||||
/// </summary>
|
||||
public int MaxFilesPerJob { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Enable entropy-based secret detection.
|
||||
/// </summary>
|
||||
public bool EnableEntropyDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum entropy threshold for high-entropy string detection.
|
||||
/// </summary>
|
||||
public double EntropyThreshold { get; set; } = 4.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public static class ScanStageNames
|
||||
// Sprint: SPRINT_20251226_014_BINIDX - Binary Vulnerability Lookup
|
||||
public const string BinaryLookup = "binary-lookup";
|
||||
|
||||
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
public const string ScanSecrets = "scan-secrets";
|
||||
|
||||
public static readonly IReadOnlyList<string> Ordered = new[]
|
||||
{
|
||||
IngestReplay,
|
||||
@@ -30,6 +33,7 @@ public static class ScanStageNames
|
||||
PullLayers,
|
||||
BuildFilesystem,
|
||||
ExecuteAnalyzers,
|
||||
ScanSecrets,
|
||||
BinaryLookup,
|
||||
EpssEnrichment,
|
||||
ComposeArtifacts,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Stage executor that scans filesystem for hardcoded secrets and credentials.
|
||||
/// </summary>
|
||||
internal sealed class SecretsAnalyzerStageExecutor : IScanStageExecutor
|
||||
{
|
||||
private static readonly string[] RootFsMetadataKeys =
|
||||
{
|
||||
"filesystem.rootfs",
|
||||
"rootfs.path",
|
||||
"scanner.rootfs",
|
||||
};
|
||||
|
||||
private readonly ISecretsAnalyzer _secretsAnalyzer;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptions<ScannerWorkerOptions> _options;
|
||||
private readonly ILogger<SecretsAnalyzerStageExecutor> _logger;
|
||||
|
||||
public SecretsAnalyzerStageExecutor(
|
||||
ISecretsAnalyzer secretsAnalyzer,
|
||||
ScannerWorkerMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ScannerWorkerOptions> options,
|
||||
ILogger<SecretsAnalyzerStageExecutor> logger)
|
||||
{
|
||||
_secretsAnalyzer = secretsAnalyzer ?? throw new ArgumentNullException(nameof(secretsAnalyzer));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string StageName => ScanStageNames.ScanSecrets;
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var secretsOptions = _options.Value.Secrets;
|
||||
if (!secretsOptions.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Secrets scanning is disabled; skipping stage.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file entries from analyzer stage
|
||||
if (!context.Analysis.TryGet<IReadOnlyList<ScanFileEntry>>(ScanAnalysisKeys.FileEntries, out var files) || files is null)
|
||||
{
|
||||
_logger.LogDebug("No file entries available; skipping secrets scan.");
|
||||
return;
|
||||
}
|
||||
|
||||
var rootfsPath = ResolveRootfsPath(context.Lease.Metadata);
|
||||
if (string.IsNullOrWhiteSpace(rootfsPath))
|
||||
{
|
||||
_logger.LogWarning("No rootfs path found in job metadata; skipping secrets scan for job {JobId}.", context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var startTime = _timeProvider.GetTimestamp();
|
||||
var allFindings = new List<SecretFinding>();
|
||||
|
||||
try
|
||||
{
|
||||
// Filter to text-like files only
|
||||
var textFiles = files
|
||||
.Where(f => ShouldScanFile(f))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scanning {FileCount} files for secrets in job {JobId}.",
|
||||
textFiles.Count,
|
||||
context.JobId);
|
||||
|
||||
foreach (var file in textFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(rootfsPath, file.Path.TrimStart('/'));
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content.Length == 0 || content.Length > secretsOptions.MaxFileSizeBytes)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var findings = await _secretsAnalyzer.AnalyzeAsync(
|
||||
content,
|
||||
file.Path,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (findings.Count > 0)
|
||||
{
|
||||
allFindings.AddRange(findings);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error scanning file {Path} for secrets: {Message}", file.Path, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTime);
|
||||
|
||||
// Store findings in analysis store
|
||||
var report = new SecretsAnalysisReport
|
||||
{
|
||||
JobId = context.JobId,
|
||||
ScanId = context.ScanId,
|
||||
Findings = allFindings.ToImmutableArray(),
|
||||
FilesScanned = textFiles.Count,
|
||||
RulesetVersion = _secretsAnalyzer.RulesetVersion,
|
||||
AnalyzedAtUtc = _timeProvider.GetUtcNow(),
|
||||
ElapsedMilliseconds = elapsed.TotalMilliseconds
|
||||
};
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.SecretFindings, report);
|
||||
context.Analysis.Set(ScanAnalysisKeys.SecretRulesetVersion, _secretsAnalyzer.RulesetVersion);
|
||||
|
||||
_metrics.RecordSecretsAnalysisCompleted(
|
||||
context,
|
||||
allFindings.Count,
|
||||
textFiles.Count,
|
||||
elapsed,
|
||||
_timeProvider);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Secrets scan completed for job {JobId}: {FindingCount} findings in {FileCount} files ({ElapsedMs:F0}ms).",
|
||||
context.JobId,
|
||||
allFindings.Count,
|
||||
textFiles.Count,
|
||||
elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Secrets scan cancelled for job {JobId}.", context.JobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecretsAnalysisFailed(context, _timeProvider);
|
||||
_logger.LogError(ex, "Secrets scan failed for job {JobId}: {Message}", context.JobId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldScanFile(ScanFileEntry file)
|
||||
{
|
||||
if (file is null || file.SizeBytes == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip binary files
|
||||
if (file.Kind is "elf" or "pe" or "mach-o" or "blob")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip very large files
|
||||
if (file.SizeBytes > 10 * 1024 * 1024)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(file.Path).ToLowerInvariant();
|
||||
|
||||
// Include common text/config file extensions
|
||||
return ext is ".json" or ".yaml" or ".yml" or ".xml" or ".properties" or ".conf" or ".config"
|
||||
or ".env" or ".ini" or ".toml" or ".cfg"
|
||||
or ".js" or ".ts" or ".jsx" or ".tsx" or ".mjs" or ".cjs"
|
||||
or ".py" or ".rb" or ".php" or ".go" or ".java" or ".cs" or ".rs" or ".swift" or ".kt"
|
||||
or ".sh" or ".bash" or ".zsh" or ".ps1" or ".bat" or ".cmd"
|
||||
or ".sql" or ".graphql" or ".gql"
|
||||
or ".tf" or ".tfvars" or ".hcl"
|
||||
or ".dockerfile" or ".dockerignore"
|
||||
or ".gitignore" or ".npmrc" or ".yarnrc" or ".pypirc"
|
||||
or ".pem" or ".key" or ".crt" or ".cer"
|
||||
or ".md" or ".txt" or ".log"
|
||||
|| string.IsNullOrEmpty(ext);
|
||||
}
|
||||
|
||||
private static string? ResolveRootfsPath(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in RootFsMetadataKeys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of secrets analysis for a scan job.
|
||||
/// </summary>
|
||||
public sealed record SecretsAnalysisReport
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required string ScanId { get; init; }
|
||||
public required ImmutableArray<SecretFinding> Findings { get; init; }
|
||||
public required int FilesScanned { get; init; }
|
||||
public required string RulesetVersion { get; init; }
|
||||
public required DateTimeOffset AnalyzedAtUtc { get; init; }
|
||||
public required double ElapsedMilliseconds { get; init; }
|
||||
}
|
||||
@@ -26,7 +26,9 @@ using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Worker.Processing.Entropy;
|
||||
using StellaOps.Scanner.Worker.Processing.Secrets;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Worker.Extensions;
|
||||
using StellaOps.Scanner.Worker.Processing.Surface;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
@@ -167,6 +169,18 @@ builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuild
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
|
||||
|
||||
// Secrets Leak Detection (Sprint: SPRINT_20251229_046_BE)
|
||||
if (workerOptions.Secrets.Enabled)
|
||||
{
|
||||
builder.Services.AddSecretsAnalyzer(options =>
|
||||
{
|
||||
options.RulesetPath = workerOptions.Secrets.RulesetPath;
|
||||
options.EnableEntropyDetection = workerOptions.Secrets.EnableEntropyDetection;
|
||||
options.EntropyThreshold = workerOptions.Secrets.EntropyThreshold;
|
||||
});
|
||||
builder.Services.AddSingleton<IScanStageExecutor, SecretsAnalyzerStageExecutor>();
|
||||
}
|
||||
|
||||
// Proof of Exposure (Sprint: SPRINT_3500_0001_0001_proof_of_exposure_mvp)
|
||||
builder.Services.AddOptions<StellaOps.Scanner.Core.Configuration.PoEConfiguration>()
|
||||
.BindConfiguration("PoE")
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Scanner Secrets Analyzer Guild Charter
|
||||
|
||||
## Mission
|
||||
|
||||
Detect accidentally committed secrets in container layers during scans using deterministic, DSSE-signed rule bundles. Ensure findings are reproducible, masked before output, and integrated with the Policy Engine for policy-driven decisions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Secret detection plugin implementing `ILayerAnalyzer`
|
||||
- Regex and entropy-based detection strategies
|
||||
- Rule bundle loading, verification, and execution
|
||||
- Payload masking engine
|
||||
- Evidence emission (`secret.leak`) for policy integration
|
||||
- Integration with Scanner Worker pipeline
|
||||
|
||||
## Required Reading
|
||||
|
||||
- `docs/modules/scanner/operations/secret-leak-detection.md` - Target specification
|
||||
- `docs/modules/scanner/design/surface-secrets.md` - Credential delivery (different from leak detection)
|
||||
- `docs/modules/scanner/architecture.md` - Scanner module architecture
|
||||
- `docs/modules/policy/secret-leak-detection-readiness.md` - Policy integration requirements
|
||||
- `docs/implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md` - Implementation sprint
|
||||
- `docs/implplan/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md` - Bundle infrastructure sprint
|
||||
- CLAUDE.md Section 8 (Code Quality & Determinism Rules)
|
||||
|
||||
## Working Agreement
|
||||
|
||||
1. **Status synchronisation**: Update task state in sprint file and local `TASKS.md` when starting or completing work.
|
||||
|
||||
2. **Determinism**:
|
||||
- Sort rules by ID for deterministic execution order
|
||||
- Use `CultureInfo.InvariantCulture` for all parsing
|
||||
- Inject `TimeProvider` for timestamps
|
||||
- Same inputs must produce same outputs
|
||||
|
||||
3. **Security posture**:
|
||||
- NEVER log secret payloads
|
||||
- Apply masking BEFORE any output or persistence
|
||||
- Verify bundle signatures on load
|
||||
- Enforce feature flag for gradual rollout
|
||||
|
||||
4. **Testing requirements**:
|
||||
- Unit tests for all detectors, masking, and rule loading
|
||||
- Integration tests with Scanner Worker
|
||||
- Golden fixture tests for determinism verification
|
||||
- Security tests ensuring secrets are not leaked
|
||||
|
||||
5. **Offline readiness**:
|
||||
- Support local bundle verification without network
|
||||
- Document Attestor mirror configuration
|
||||
- Ensure bundles ship with Offline Kit
|
||||
|
||||
## Key Interfaces
|
||||
|
||||
```csharp
|
||||
// Detection interface
|
||||
public interface ISecretDetector
|
||||
{
|
||||
string DetectorId { get; }
|
||||
ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// Masking interface
|
||||
public interface IPayloadMasker
|
||||
{
|
||||
string Mask(ReadOnlySpan<byte> payload, string? hint = null);
|
||||
}
|
||||
|
||||
// Bundle verification
|
||||
public interface IBundleVerifier
|
||||
{
|
||||
Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
VerificationOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `scanner.secret.finding_total` | Counter | Total findings by tenant, ruleId, severity |
|
||||
| `scanner.secret.scan_duration_seconds` | Histogram | Detection time per scan |
|
||||
| `scanner.secret.rules_loaded` | Gauge | Number of active rules |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
StellaOps.Scanner.Analyzers.Secrets/
|
||||
├── AGENTS.md # This file
|
||||
├── StellaOps.Scanner.Analyzers.Secrets.csproj
|
||||
├── Detectors/
|
||||
│ ├── ISecretDetector.cs
|
||||
│ ├── RegexDetector.cs
|
||||
│ ├── EntropyDetector.cs
|
||||
│ └── CompositeSecretDetector.cs
|
||||
├── Rules/
|
||||
│ ├── SecretRule.cs
|
||||
│ ├── SecretRuleset.cs
|
||||
│ └── RulesetLoader.cs
|
||||
├── Bundles/
|
||||
│ ├── BundleBuilder.cs
|
||||
│ ├── BundleVerifier.cs
|
||||
│ └── Schemas/
|
||||
├── Masking/
|
||||
│ ├── IPayloadMasker.cs
|
||||
│ └── PayloadMasker.cs
|
||||
├── Evidence/
|
||||
│ ├── SecretLeakEvidence.cs
|
||||
│ └── SecretFinding.cs
|
||||
├── SecretsAnalyzer.cs
|
||||
├── SecretsAnalyzerHost.cs
|
||||
├── SecretsAnalyzerOptions.cs
|
||||
└── ServiceCollectionExtensions.cs
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
See sprint files for current implementation status:
|
||||
- SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md
|
||||
- SPRINT_20260104_003_SCANNER_secret_rule_bundles.md
|
||||
- SPRINT_20260104_004_POLICY_secret_dsl_integration.md
|
||||
- SPRINT_20260104_005_AIRGAP_secret_offline_kit.md
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Secrets.Tests")]
|
||||
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Builds secrets detection rule bundles from individual rule files.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a bundle from individual rule files.
|
||||
/// </summary>
|
||||
Task<BundleArtifact> BuildAsync(
|
||||
BundleBuildOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle creation.
|
||||
/// </summary>
|
||||
public sealed record BundleBuildOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Directory where the bundle will be written.
|
||||
/// </summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version (e.g., "2026.01").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Paths to individual rule JSON files to include.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> RuleFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description for the bundle.
|
||||
/// </summary>
|
||||
public string Description { get; init; } = "StellaOps Secret Detection Rules";
|
||||
|
||||
/// <summary>
|
||||
/// Time provider for deterministic timestamps.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate rules during build.
|
||||
/// </summary>
|
||||
public bool ValidateRules { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail on validation warnings.
|
||||
/// </summary>
|
||||
public bool FailOnWarnings { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle creation.
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the manifest file.
|
||||
/// </summary>
|
||||
public required string ManifestPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the rules JSONL file.
|
||||
/// </summary>
|
||||
public required string RulesPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the rules file (lowercase hex).
|
||||
/// </summary>
|
||||
public required string RulesSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
public required int TotalRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of enabled rules in the bundle.
|
||||
/// </summary>
|
||||
public required int EnabledRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated manifest.
|
||||
/// </summary>
|
||||
public required BundleManifest Manifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle builder.
|
||||
/// </summary>
|
||||
public sealed class BundleBuilder : IBundleBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions RuleSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions RuleReaderOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
private readonly IRuleValidator _validator;
|
||||
private readonly ILogger<BundleBuilder> _logger;
|
||||
|
||||
public BundleBuilder(IRuleValidator validator, ILogger<BundleBuilder> logger)
|
||||
{
|
||||
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleArtifact> BuildAsync(
|
||||
BundleBuildOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var timeProvider = options.TimeProvider ?? TimeProvider.System;
|
||||
var createdAt = timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Building bundle {BundleId} v{Version} from {FileCount} rule files",
|
||||
options.BundleId,
|
||||
options.Version,
|
||||
options.RuleFiles.Count);
|
||||
|
||||
// Load and validate rules
|
||||
var rules = new List<SecretRule>();
|
||||
var validationErrors = new List<string>();
|
||||
var validationWarnings = new List<string>();
|
||||
|
||||
foreach (var ruleFile in options.RuleFiles)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(ruleFile))
|
||||
{
|
||||
validationErrors.Add($"Rule file not found: {ruleFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(ruleFile, ct).ConfigureAwait(false);
|
||||
var rule = JsonSerializer.Deserialize<SecretRule>(json, RuleReaderOptions);
|
||||
|
||||
if (rule is null)
|
||||
{
|
||||
validationErrors.Add($"Failed to deserialize rule from {ruleFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.ValidateRules)
|
||||
{
|
||||
var validation = _validator.Validate(rule);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
foreach (var error in validation.Errors)
|
||||
{
|
||||
validationErrors.Add($"{ruleFile}: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var warning in validation.Warnings)
|
||||
{
|
||||
validationWarnings.Add($"{ruleFile}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
rules.Add(rule);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
validationErrors.Add($"JSON parse error in {ruleFile}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle warnings
|
||||
if (validationWarnings.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle build has {WarningCount} warnings: {Warnings}",
|
||||
validationWarnings.Count,
|
||||
string.Join("; ", validationWarnings.Take(5)));
|
||||
|
||||
if (options.FailOnWarnings)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle build failed due to warnings: {string.Join("; ", validationWarnings)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle build failed with {validationErrors.Count} errors: {string.Join("; ", validationErrors)}");
|
||||
}
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No valid rules found to include in bundle.");
|
||||
}
|
||||
|
||||
// Sort rules by ID for deterministic output
|
||||
rules.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
|
||||
|
||||
// Ensure output directory exists
|
||||
Directory.CreateDirectory(options.OutputDirectory);
|
||||
|
||||
// Write rules JSONL file
|
||||
var rulesPath = Path.Combine(options.OutputDirectory, "secrets.ruleset.rules.jsonl");
|
||||
await WriteRulesJsonlAsync(rulesPath, rules, ct).ConfigureAwait(false);
|
||||
|
||||
// Compute SHA-256 of rules file
|
||||
var rulesSha256 = await ComputeFileSha256Async(rulesPath, ct).ConfigureAwait(false);
|
||||
|
||||
// Build manifest
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
SchemaVersion = "1.0",
|
||||
Id = options.BundleId,
|
||||
Version = options.Version,
|
||||
CreatedAt = createdAt,
|
||||
Description = options.Description,
|
||||
Rules = rules.Select(r => new BundleRuleSummary
|
||||
{
|
||||
Id = r.Id,
|
||||
Version = r.Version,
|
||||
Severity = r.Severity.ToString().ToLowerInvariant(),
|
||||
Enabled = r.Enabled
|
||||
}).ToImmutableArray(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesFile = "secrets.ruleset.rules.jsonl",
|
||||
RulesSha256 = rulesSha256,
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled)
|
||||
}
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(options.OutputDirectory, "secrets.ruleset.manifest.json");
|
||||
await WriteManifestAsync(manifestPath, manifest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle {BundleId} v{Version} created with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
options.BundleId,
|
||||
options.Version,
|
||||
rules.Count,
|
||||
rules.Count(r => r.Enabled));
|
||||
|
||||
return new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = rulesPath,
|
||||
RulesSha256 = rulesSha256,
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled),
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WriteRulesJsonlAsync(
|
||||
string path,
|
||||
IReadOnlyList<SecretRule> rules,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
useAsync: true);
|
||||
|
||||
await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var json = JsonSerializer.Serialize(rule, RuleSerializerOptions);
|
||||
await writer.WriteLineAsync(json).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteManifestAsync(
|
||||
string path,
|
||||
BundleManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(manifest, ManifestSerializerOptions);
|
||||
await File.WriteAllTextAsync(path, json, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileSha256Async(string path, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
useAsync: true);
|
||||
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the manifest of a secrets detection rule bundle.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public sealed record BundleManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for the bundle (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version using CalVer (e.g., "2026.01").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Summary of rules included in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rules")]
|
||||
public ImmutableArray<BundleRuleSummary> Rules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Integrity information for the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrity")]
|
||||
public required BundleIntegrity Integrity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public BundleSignatures? Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a rule included in the bundle manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleRuleSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule version (SemVer).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule severity level.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the rule is enabled by default.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Integrity information for bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleIntegrity
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the rules file within the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesFile")]
|
||||
public string RulesFile { get; init; } = "secrets.ruleset.rules.jsonl";
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the rules file (lowercase hex).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesSha256")]
|
||||
public required string RulesSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalRules")]
|
||||
public required int TotalRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of enabled rules in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabledRules")]
|
||||
public required int EnabledRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature references for the bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleSignatures
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the DSSE envelope file within the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public string DsseEnvelope { get; init; } = "secrets.ruleset.dsse.json";
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing (for informational purposes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry ID (if applicable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Signs secrets detection rule bundles using DSSE envelopes.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a bundle artifact producing a DSSE envelope.
|
||||
/// </summary>
|
||||
Task<BundleSigningResult> SignAsync(
|
||||
BundleArtifact artifact,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle signing.
|
||||
/// </summary>
|
||||
public sealed record BundleSigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier for the signature.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "HMAC-SHA256", "ES256").
|
||||
/// </summary>
|
||||
public string Algorithm { get; init; } = "HMAC-SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC signing (base64 or hex encoded).
|
||||
/// Required for HMAC-SHA256 algorithm.
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload type for the DSSE envelope.
|
||||
/// </summary>
|
||||
public string PayloadType { get; init; } = "application/vnd.stellaops.secrets-ruleset+json";
|
||||
|
||||
/// <summary>
|
||||
/// Time provider for deterministic timestamps.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle signing.
|
||||
/// </summary>
|
||||
public sealed record BundleSigningResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the generated DSSE envelope file.
|
||||
/// </summary>
|
||||
public required string EnvelopePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated DSSE envelope.
|
||||
/// </summary>
|
||||
public required DsseEnvelope Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated manifest with signature information.
|
||||
/// </summary>
|
||||
public required BundleManifest UpdatedManifest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure for bundle signatures.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64url-encoded payload.
|
||||
/// </summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload type URI.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the PAE.
|
||||
/// </summary>
|
||||
public required ImmutableArray<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64url-encoded signature bytes.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle signing using HMAC-SHA256.
|
||||
/// </summary>
|
||||
public sealed class BundleSigner : IBundleSigner
|
||||
{
|
||||
private const string DssePrefix = "DSSEv1";
|
||||
|
||||
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ILogger<BundleSigner> _logger;
|
||||
|
||||
public BundleSigner(ILogger<BundleSigner> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleSigningResult> SignAsync(
|
||||
BundleArtifact artifact,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var timeProvider = options.TimeProvider ?? TimeProvider.System;
|
||||
var signedAt = timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signing bundle {BundleId} v{Version} with key {KeyId}",
|
||||
artifact.Manifest.Id,
|
||||
artifact.Manifest.Version,
|
||||
options.KeyId);
|
||||
|
||||
// Read manifest as payload
|
||||
var manifestJson = await File.ReadAllBytesAsync(artifact.ManifestPath, ct).ConfigureAwait(false);
|
||||
|
||||
// Encode payload as base64url
|
||||
var payloadBase64 = ToBase64Url(manifestJson);
|
||||
|
||||
// Build PAE (Pre-Authentication Encoding)
|
||||
var pae = BuildPae(options.PayloadType, manifestJson);
|
||||
|
||||
// Sign the PAE
|
||||
var signature = await SignPaeAsync(pae, options, ct).ConfigureAwait(false);
|
||||
var signatureBase64 = ToBase64Url(signature);
|
||||
|
||||
// Build DSSE envelope
|
||||
var envelope = new DsseEnvelope
|
||||
{
|
||||
Payload = payloadBase64,
|
||||
PayloadType = options.PayloadType,
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature
|
||||
{
|
||||
Sig = signatureBase64,
|
||||
KeyId = options.KeyId
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Write envelope to file
|
||||
var bundleDir = Path.GetDirectoryName(artifact.ManifestPath)!;
|
||||
var envelopePath = Path.Combine(bundleDir, "secrets.ruleset.dsse.json");
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, EnvelopeSerializerOptions);
|
||||
await File.WriteAllTextAsync(envelopePath, envelopeJson, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
|
||||
// Update manifest with signature info
|
||||
var updatedManifest = artifact.Manifest with
|
||||
{
|
||||
Signatures = new BundleSignatures
|
||||
{
|
||||
DsseEnvelope = "secrets.ruleset.dsse.json",
|
||||
KeyId = options.KeyId,
|
||||
SignedAt = signedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Rewrite manifest with signature info
|
||||
var updatedManifestJson = JsonSerializer.Serialize(updatedManifest, EnvelopeSerializerOptions);
|
||||
await File.WriteAllTextAsync(artifact.ManifestPath, updatedManifestJson, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle signed successfully. Envelope: {EnvelopePath}",
|
||||
envelopePath);
|
||||
|
||||
return new BundleSigningResult
|
||||
{
|
||||
EnvelopePath = envelopePath,
|
||||
Envelope = envelope,
|
||||
UpdatedManifest = updatedManifest
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<byte[]> SignPaeAsync(
|
||||
byte[] pae,
|
||||
BundleSigningOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!options.Algorithm.Equals("HMAC-SHA256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException($"Algorithm '{options.Algorithm}' is not supported. Use HMAC-SHA256.");
|
||||
}
|
||||
|
||||
var secret = await LoadSecretAsync(options, ct).ConfigureAwait(false);
|
||||
if (secret is null || secret.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Shared secret is required for HMAC-SHA256 signing.");
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
return hmac.ComputeHash(pae);
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> LoadSecretAsync(BundleSigningOptions options, CancellationToken ct)
|
||||
{
|
||||
// Try file first
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecretFile) && File.Exists(options.SharedSecretFile))
|
||||
{
|
||||
var content = (await File.ReadAllTextAsync(options.SharedSecretFile, ct).ConfigureAwait(false)).Trim();
|
||||
return DecodeSecret(content);
|
||||
}
|
||||
|
||||
// Then inline secret
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecret))
|
||||
{
|
||||
return DecodeSecret(options.SharedSecret);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] DecodeSecret(string value)
|
||||
{
|
||||
// Try base64 first
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Not base64
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0 && IsHexString(value))
|
||||
{
|
||||
return Convert.FromHexString(value);
|
||||
}
|
||||
|
||||
// Treat as raw UTF-8
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds DSSE v1 Pre-Authentication Encoding.
|
||||
/// Format: "DSSEv1" SP LEN(type) SP type SP LEN(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenStr = typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var payloadLenStr = payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
// Calculate total size
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
|
||||
|
||||
var totalSize = prefixBytes.Length + 1 // prefix + SP
|
||||
+ typeLenBytes.Length + 1 // type len + SP
|
||||
+ typeBytes.Length + 1 // type + SP
|
||||
+ payloadLenBytes.Length + 1 // payload len + SP
|
||||
+ payload.Length;
|
||||
|
||||
var pae = new byte[totalSize];
|
||||
var offset = 0;
|
||||
|
||||
// DSSEv1
|
||||
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
|
||||
offset += prefixBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// type length
|
||||
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
|
||||
offset += typeLenBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// type
|
||||
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// payload length
|
||||
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
|
||||
offset += payloadLenBytes.Length;
|
||||
pae[offset++] = 0x20; // SP
|
||||
|
||||
// payload
|
||||
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies secrets detection rule bundle signatures and integrity.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IBundleVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a bundle's DSSE signature and integrity.
|
||||
/// </summary>
|
||||
Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// URL of the attestor service for online verification.
|
||||
/// </summary>
|
||||
public string? AttestorUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Rekor transparency log proof.
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs. If empty, any key is accepted.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? TrustedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC verification (base64 or hex encoded).
|
||||
/// </summary>
|
||||
public string? SharedSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SharedSecretFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify file integrity (SHA-256).
|
||||
/// </summary>
|
||||
public bool VerifyIntegrity { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip signature verification (integrity only).
|
||||
/// </summary>
|
||||
public bool SkipSignatureVerification { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the bundle is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version that was verified.
|
||||
/// </summary>
|
||||
public string? BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle ID that was verified.
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that signed the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry ID (if available).
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int? RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors encountered.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ValidationErrors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (non-fatal).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ValidationWarnings { get; init; } = [];
|
||||
|
||||
public static BundleVerificationResult Success(BundleManifest manifest, string? keyId) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
SignedAt = manifest.Signatures?.SignedAt,
|
||||
SignerKeyId = keyId ?? manifest.Signatures?.KeyId,
|
||||
RekorLogId = manifest.Signatures?.RekorLogId,
|
||||
RuleCount = manifest.Integrity.TotalRules
|
||||
};
|
||||
|
||||
public static BundleVerificationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationErrors = [.. errors]
|
||||
};
|
||||
|
||||
public static BundleVerificationResult Failure(IEnumerable<string> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationErrors = [.. errors]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle verification.
|
||||
/// </summary>
|
||||
public sealed class BundleVerifier : IBundleVerifier
|
||||
{
|
||||
private const string DssePrefix = "DSSEv1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly ILogger<BundleVerifier> _logger;
|
||||
|
||||
public BundleVerifier(ILogger<BundleVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BundleVerificationResult> VerifyAsync(
|
||||
string bundleDirectory,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDirectory);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
_logger.LogDebug("Verifying bundle at {BundleDir}", bundleDirectory);
|
||||
|
||||
// Check directory exists
|
||||
if (!Directory.Exists(bundleDirectory))
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Bundle directory not found: {bundleDirectory}");
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(bundleDirectory, "secrets.ruleset.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Manifest not found: {manifestPath}");
|
||||
}
|
||||
|
||||
BundleManifest manifest;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions)!;
|
||||
if (manifest is null)
|
||||
{
|
||||
return BundleVerificationResult.Failure("Failed to parse manifest.");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return BundleVerificationResult.Failure($"Invalid manifest JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded manifest: {BundleId} v{Version} with {RuleCount} rules",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
manifest.Integrity.TotalRules);
|
||||
|
||||
// Verify file integrity
|
||||
if (options.VerifyIntegrity)
|
||||
{
|
||||
var rulesPath = Path.Combine(bundleDirectory, manifest.Integrity.RulesFile);
|
||||
if (!File.Exists(rulesPath))
|
||||
{
|
||||
errors.Add($"Rules file not found: {rulesPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var actualHash = await ComputeFileSha256Async(rulesPath, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(actualHash, manifest.Integrity.RulesSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Rules file integrity check failed. Expected: {manifest.Integrity.RulesSha256}, Actual: {actualHash}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Rules file integrity verified.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (!options.SkipSignatureVerification)
|
||||
{
|
||||
if (manifest.Signatures is null)
|
||||
{
|
||||
errors.Add("Bundle is not signed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var envelopePath = Path.Combine(bundleDirectory, manifest.Signatures.DsseEnvelope);
|
||||
if (!File.Exists(envelopePath))
|
||||
{
|
||||
errors.Add($"DSSE envelope not found: {envelopePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var signatureResult = await VerifySignatureAsync(
|
||||
manifestPath,
|
||||
envelopePath,
|
||||
options,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!signatureResult.IsValid)
|
||||
{
|
||||
errors.AddRange(signatureResult.Errors);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check trusted key IDs
|
||||
if (options.TrustedKeyIds is { Count: > 0 } trustedKeys)
|
||||
{
|
||||
if (signatureResult.KeyId is null || !trustedKeys.Contains(signatureResult.KeyId))
|
||||
{
|
||||
errors.Add($"Signature key '{signatureResult.KeyId}' is not in the trusted keys list.");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Signature verified with key: {KeyId}", signatureResult.KeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Rekor requirement
|
||||
if (options.RequireRekorProof)
|
||||
{
|
||||
if (manifest.Signatures?.RekorLogId is null)
|
||||
{
|
||||
errors.Add("Rekor transparency log proof is required but not present.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: Implement Rekor verification via Attestor client
|
||||
warnings.Add("Rekor verification not yet implemented; proof present but not verified.");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle verification failed for {BundleId} v{Version}: {Errors}",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
string.Join("; ", errors));
|
||||
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
ValidationErrors = [.. errors],
|
||||
ValidationWarnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Bundle verified: {BundleId} v{Version} ({RuleCount} rules)",
|
||||
manifest.Id,
|
||||
manifest.Version,
|
||||
manifest.Integrity.TotalRules);
|
||||
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
BundleId = manifest.Id,
|
||||
BundleVersion = manifest.Version,
|
||||
SignedAt = manifest.Signatures?.SignedAt,
|
||||
SignerKeyId = manifest.Signatures?.KeyId,
|
||||
RekorLogId = manifest.Signatures?.RekorLogId,
|
||||
RuleCount = manifest.Integrity.TotalRules,
|
||||
ValidationWarnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignatureVerificationResult> VerifySignatureAsync(
|
||||
string manifestPath,
|
||||
string envelopePath,
|
||||
BundleVerificationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load envelope
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, ct).ConfigureAwait(false);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, JsonOptions);
|
||||
|
||||
if (envelope is null || envelope.Signatures.IsDefaultOrEmpty)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Invalid or empty DSSE envelope.");
|
||||
}
|
||||
|
||||
// Decode payload - this is the original manifest (before signature was added)
|
||||
var payloadBytes = FromBase64Url(envelope.Payload);
|
||||
var payloadManifest = JsonSerializer.Deserialize<BundleManifest>(payloadBytes, JsonOptions);
|
||||
|
||||
if (payloadManifest is null)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Failed to parse envelope payload as manifest.");
|
||||
}
|
||||
|
||||
// Load current manifest and verify it matches the signed version (ignoring the Signatures field
|
||||
// which was added after signing)
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
var currentManifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions);
|
||||
|
||||
if (currentManifest is null)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Failed to parse current manifest.");
|
||||
}
|
||||
|
||||
// Compare all fields except Signatures (which is added after signing)
|
||||
if (!ManifestsMatchIgnoringSignatures(payloadManifest, currentManifest))
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Envelope payload does not match manifest content.");
|
||||
}
|
||||
|
||||
// Build PAE
|
||||
var pae = BuildPae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Verify each signature (at least one must be valid)
|
||||
var secret = await LoadSecretAsync(options, ct).ConfigureAwait(false);
|
||||
if (secret is null || secret.Length == 0)
|
||||
{
|
||||
return SignatureVerificationResult.Failure("Shared secret is required for signature verification.");
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var signatureBytes = FromBase64Url(sig.Sig);
|
||||
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
var expectedSignature = hmac.ComputeHash(pae);
|
||||
|
||||
if (CryptographicOperations.FixedTimeEquals(expectedSignature, signatureBytes))
|
||||
{
|
||||
return SignatureVerificationResult.Success(sig.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
return SignatureVerificationResult.Failure("Signature verification failed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SignatureVerificationResult.Failure($"Signature verification error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ManifestsMatchIgnoringSignatures(BundleManifest a, BundleManifest b)
|
||||
{
|
||||
// Compare all fields except Signatures
|
||||
return a.SchemaVersion == b.SchemaVersion
|
||||
&& a.Id == b.Id
|
||||
&& a.Version == b.Version
|
||||
&& a.CreatedAt == b.CreatedAt
|
||||
&& a.Description == b.Description
|
||||
&& a.Integrity.RulesFile == b.Integrity.RulesFile
|
||||
&& a.Integrity.RulesSha256 == b.Integrity.RulesSha256
|
||||
&& a.Integrity.TotalRules == b.Integrity.TotalRules
|
||||
&& a.Integrity.EnabledRules == b.Integrity.EnabledRules;
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> LoadSecretAsync(BundleVerificationOptions options, CancellationToken ct)
|
||||
{
|
||||
// Try file first
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecretFile) && File.Exists(options.SharedSecretFile))
|
||||
{
|
||||
var content = (await File.ReadAllTextAsync(options.SharedSecretFile, ct).ConfigureAwait(false)).Trim();
|
||||
return DecodeSecret(content);
|
||||
}
|
||||
|
||||
// Then inline secret
|
||||
if (!string.IsNullOrWhiteSpace(options.SharedSecret))
|
||||
{
|
||||
return DecodeSecret(options.SharedSecret);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] DecodeSecret(string value)
|
||||
{
|
||||
// Try base64 first
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Not base64
|
||||
}
|
||||
|
||||
// Try hex
|
||||
if (value.Length % 2 == 0 && IsHexString(value))
|
||||
{
|
||||
return Convert.FromHexString(value);
|
||||
}
|
||||
|
||||
// Treat as raw UTF-8
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenStr = typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var payloadLenStr = payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeLenStr);
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payloadLenStr);
|
||||
|
||||
var totalSize = prefixBytes.Length + 1
|
||||
+ typeLenBytes.Length + 1
|
||||
+ typeBytes.Length + 1
|
||||
+ payloadLenBytes.Length + 1
|
||||
+ payload.Length;
|
||||
|
||||
var pae = new byte[totalSize];
|
||||
var offset = 0;
|
||||
|
||||
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
|
||||
offset += prefixBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
|
||||
offset += typeLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
|
||||
offset += payloadLenBytes.Length;
|
||||
pae[offset++] = 0x20;
|
||||
|
||||
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileSha256Async(string path, CancellationToken ct)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SignatureVerificationResult(bool IsValid, string? KeyId, ImmutableArray<string> Errors)
|
||||
{
|
||||
public static SignatureVerificationResult Success(string? keyId) => new(true, keyId, []);
|
||||
public static SignatureVerificationResult Failure(params string[] errors) => new(false, null, [.. errors]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Validates secret detection rules against the schema requirements.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public interface IRuleValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a rule and returns validation errors, if any.
|
||||
/// </summary>
|
||||
RuleValidationResult Validate(SecretRule rule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule validation.
|
||||
/// </summary>
|
||||
public sealed record RuleValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rule is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors encountered.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (non-fatal).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
|
||||
public static RuleValidationResult Success() => new() { IsValid = true };
|
||||
|
||||
public static RuleValidationResult Failure(params string[] errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors]
|
||||
};
|
||||
|
||||
public static RuleValidationResult Failure(IEnumerable<string> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of rule validation.
|
||||
/// </summary>
|
||||
public sealed class RuleValidator : IRuleValidator
|
||||
{
|
||||
private static readonly Regex NamespacedIdPattern = new(
|
||||
@"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SemVerPattern = new(
|
||||
@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly ILogger<RuleValidator> _logger;
|
||||
|
||||
public RuleValidator(ILogger<RuleValidator> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public RuleValidationResult Validate(SecretRule rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Validate ID
|
||||
if (string.IsNullOrWhiteSpace(rule.Id))
|
||||
{
|
||||
errors.Add("Rule ID is required.");
|
||||
}
|
||||
else if (!NamespacedIdPattern.IsMatch(rule.Id))
|
||||
{
|
||||
errors.Add($"Rule ID '{rule.Id}' must be namespaced (e.g., 'stellaops.secrets.aws-key').");
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if (string.IsNullOrWhiteSpace(rule.Version))
|
||||
{
|
||||
errors.Add("Rule version is required.");
|
||||
}
|
||||
else if (!SemVerPattern.IsMatch(rule.Version))
|
||||
{
|
||||
errors.Add($"Rule version '{rule.Version}' must be valid SemVer.");
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (string.IsNullOrWhiteSpace(rule.Name))
|
||||
{
|
||||
warnings.Add("Rule name is recommended for documentation.");
|
||||
}
|
||||
|
||||
// Validate pattern for regex/composite rules
|
||||
if (rule.Type is SecretRuleType.Regex or SecretRuleType.Composite)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.Pattern))
|
||||
{
|
||||
errors.Add("Pattern is required for regex/composite rules.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate regex compiles
|
||||
_ = new Regex(rule.Pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (RegexParseException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern: {ex.Message}");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate entropy threshold for entropy rules
|
||||
if (rule.Type is SecretRuleType.Entropy or SecretRuleType.Composite)
|
||||
{
|
||||
if (rule.EntropyThreshold <= 0 || rule.EntropyThreshold > 8)
|
||||
{
|
||||
warnings.Add($"Entropy threshold {rule.EntropyThreshold} may be out of typical range (3.0-6.0).");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate min/max length
|
||||
if (rule.MinLength < 0)
|
||||
{
|
||||
errors.Add("MinLength cannot be negative.");
|
||||
}
|
||||
|
||||
if (rule.MaxLength < rule.MinLength)
|
||||
{
|
||||
errors.Add("MaxLength must be greater than or equal to MinLength.");
|
||||
}
|
||||
|
||||
// Validate file patterns if provided
|
||||
foreach (var pattern in rule.FilePatterns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
warnings.Add("Empty file pattern will be ignored.");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Rule {RuleId} validation failed: {Errors}", rule.Id, string.Join("; ", errors));
|
||||
return new RuleValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors],
|
||||
Warnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Rule {RuleId} validated with warnings: {Warnings}", rule.Id, string.Join("; ", warnings));
|
||||
}
|
||||
|
||||
return new RuleValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Errors = [],
|
||||
Warnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Combines multiple detection strategies for comprehensive secret detection.
|
||||
/// </summary>
|
||||
public sealed class CompositeSecretDetector : ISecretDetector
|
||||
{
|
||||
private readonly RegexDetector _regexDetector;
|
||||
private readonly EntropyDetector _entropyDetector;
|
||||
private readonly ILogger<CompositeSecretDetector> _logger;
|
||||
|
||||
public CompositeSecretDetector(
|
||||
RegexDetector regexDetector,
|
||||
EntropyDetector entropyDetector,
|
||||
ILogger<CompositeSecretDetector> logger)
|
||||
{
|
||||
_regexDetector = regexDetector ?? throw new ArgumentNullException(nameof(regexDetector));
|
||||
_entropyDetector = entropyDetector ?? throw new ArgumentNullException(nameof(entropyDetector));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "composite";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) => true;
|
||||
|
||||
public async ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SecretMatch>();
|
||||
|
||||
// Choose detector based on rule type
|
||||
switch (rule.Type)
|
||||
{
|
||||
case SecretRuleType.Regex:
|
||||
var regexMatches = await _regexDetector.DetectAsync(content, filePath, rule, ct);
|
||||
results.AddRange(regexMatches);
|
||||
break;
|
||||
|
||||
case SecretRuleType.Entropy:
|
||||
var entropyMatches = await _entropyDetector.DetectAsync(content, filePath, rule, ct);
|
||||
results.AddRange(entropyMatches);
|
||||
break;
|
||||
|
||||
case SecretRuleType.Composite:
|
||||
// Run both detectors and merge results
|
||||
var regexTask = _regexDetector.DetectAsync(content, filePath, rule, ct);
|
||||
var entropyTask = _entropyDetector.DetectAsync(content, filePath, rule, ct);
|
||||
|
||||
var regexResults = await regexTask;
|
||||
var entropyResults = await entropyTask;
|
||||
|
||||
// Add regex matches
|
||||
results.AddRange(regexResults);
|
||||
|
||||
// Add entropy matches, boosting confidence if they overlap with regex
|
||||
foreach (var entropyMatch in entropyResults)
|
||||
{
|
||||
var overlappingRegex = regexResults.FirstOrDefault(r =>
|
||||
r.LineNumber == entropyMatch.LineNumber &&
|
||||
OverlapsColumn(r, entropyMatch));
|
||||
|
||||
if (overlappingRegex is not null)
|
||||
{
|
||||
// Boost confidence for overlapping matches
|
||||
results.Add(entropyMatch with
|
||||
{
|
||||
ConfidenceScore = Math.Min(0.99, entropyMatch.ConfidenceScore + 0.1)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(entropyMatch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Deduplicate overlapping matches
|
||||
return DeduplicateMatches(results);
|
||||
}
|
||||
|
||||
private static bool OverlapsColumn(SecretMatch a, SecretMatch b)
|
||||
{
|
||||
return a.ColumnStart <= b.ColumnEnd && b.ColumnStart <= a.ColumnEnd;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SecretMatch> DeduplicateMatches(List<SecretMatch> matches)
|
||||
{
|
||||
if (matches.Count <= 1)
|
||||
{
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
matches.Sort((a, b) =>
|
||||
{
|
||||
var lineComp = a.LineNumber.CompareTo(b.LineNumber);
|
||||
return lineComp != 0 ? lineComp : a.ColumnStart.CompareTo(b.ColumnStart);
|
||||
});
|
||||
|
||||
var deduplicated = new List<SecretMatch>();
|
||||
SecretMatch? previous = null;
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (previous is null)
|
||||
{
|
||||
previous = match;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this match overlaps with the previous one
|
||||
if (match.LineNumber == previous.LineNumber && OverlapsColumn(previous, match))
|
||||
{
|
||||
// Keep the one with higher confidence
|
||||
if (match.ConfidenceScore > previous.ConfidenceScore)
|
||||
{
|
||||
previous = match;
|
||||
}
|
||||
// Otherwise keep previous
|
||||
}
|
||||
else
|
||||
{
|
||||
deduplicated.Add(previous);
|
||||
previous = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (previous is not null)
|
||||
{
|
||||
deduplicated.Add(previous);
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy for detecting high-entropy strings that may be secrets.
|
||||
/// </summary>
|
||||
public static class EntropyCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy in bits per character for the given data.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to analyze.</param>
|
||||
/// <returns>Entropy in bits per character (0.0 to 8.0 for byte data).</returns>
|
||||
public static double Calculate(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Count occurrences of each byte value
|
||||
Span<int> counts = stackalloc int[256];
|
||||
counts.Clear();
|
||||
|
||||
foreach (byte b in data)
|
||||
{
|
||||
counts[b]++;
|
||||
}
|
||||
|
||||
// Calculate entropy using Shannon's formula
|
||||
double entropy = 0.0;
|
||||
double length = data.Length;
|
||||
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
double probability = counts[i] / length;
|
||||
entropy -= probability * Math.Log2(probability);
|
||||
}
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Shannon entropy for a string.
|
||||
/// </summary>
|
||||
public static double Calculate(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// For character data, we calculate based on unique characters seen
|
||||
var counts = new Dictionary<char, int>();
|
||||
|
||||
foreach (char c in data)
|
||||
{
|
||||
counts.TryGetValue(c, out int count);
|
||||
counts[c] = count + 1;
|
||||
}
|
||||
|
||||
double entropy = 0.0;
|
||||
double length = data.Length;
|
||||
|
||||
foreach (var count in counts.Values)
|
||||
{
|
||||
double probability = count / length;
|
||||
entropy -= probability * Math.Log2(probability);
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be base64 encoded.
|
||||
/// </summary>
|
||||
public static bool IsBase64Like(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int validChars = 0;
|
||||
foreach (char c in data)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c is '+' or '/' or '=')
|
||||
{
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
return validChars >= data.Length * 0.9;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be hexadecimal.
|
||||
/// </summary>
|
||||
public static bool IsHexLike(ReadOnlySpan<char> data)
|
||||
{
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (char c in data)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a string is likely a secret based on entropy and charset.
|
||||
/// </summary>
|
||||
/// <param name="data">The string to check.</param>
|
||||
/// <param name="threshold">Minimum entropy threshold (default 4.5).</param>
|
||||
/// <returns>True if the string appears to be a high-entropy secret.</returns>
|
||||
public static bool IsLikelySecret(ReadOnlySpan<char> data, double threshold = 4.5)
|
||||
{
|
||||
if (data.Length < 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if it looks like a UUID (common false positive)
|
||||
if (LooksLikeUuid(data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entropy = Calculate(data);
|
||||
return entropy >= threshold;
|
||||
}
|
||||
|
||||
private static bool LooksLikeUuid(ReadOnlySpan<char> data)
|
||||
{
|
||||
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars)
|
||||
if (data.Length == 36)
|
||||
{
|
||||
if (data[8] == '-' && data[13] == '-' && data[18] == '-' && data[23] == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// UUID without dashes: 32 hex chars
|
||||
if (data.Length == 32 && IsHexLike(data))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Entropy-based secret detector for high-entropy strings.
|
||||
/// </summary>
|
||||
public sealed class EntropyDetector : ISecretDetector
|
||||
{
|
||||
private readonly ILogger<EntropyDetector> _logger;
|
||||
|
||||
// Regex to find potential secret strings (alphanumeric with common secret characters)
|
||||
private static readonly Regex CandidatePattern = new(
|
||||
@"[A-Za-z0-9+/=_\-]{16,}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
public EntropyDetector(ILogger<EntropyDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "entropy";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) =>
|
||||
ruleType is SecretRuleType.Entropy or SecretRuleType.Composite;
|
||||
|
||||
public ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Decode content as UTF-8
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(content.Span);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var matches = new List<SecretMatch>();
|
||||
var lineStarts = ComputeLineStarts(text);
|
||||
var threshold = rule.EntropyThreshold > 0 ? rule.EntropyThreshold : 4.5;
|
||||
var minLength = rule.MinLength > 0 ? rule.MinLength : 16;
|
||||
var maxLength = rule.MaxLength > 0 ? rule.MaxLength : 1000;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Match candidate in CandidatePattern.Matches(text))
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var value = candidate.Value.AsSpan();
|
||||
|
||||
// Check length constraints
|
||||
if (value.Length < minLength || value.Length > maxLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip common false positives
|
||||
if (ShouldSkip(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate entropy
|
||||
var entropy = EntropyCalculator.Calculate(value);
|
||||
if (entropy < threshold)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (lineNumber, columnStart) = GetLineAndColumn(lineStarts, candidate.Index);
|
||||
var matchBytes = Encoding.UTF8.GetBytes(candidate.Value);
|
||||
|
||||
// Adjust confidence based on entropy level
|
||||
var confidenceScore = CalculateConfidence(entropy, threshold);
|
||||
|
||||
matches.Add(new SecretMatch
|
||||
{
|
||||
Rule = rule,
|
||||
FilePath = filePath,
|
||||
LineNumber = lineNumber,
|
||||
ColumnStart = columnStart,
|
||||
ColumnEnd = columnStart + candidate.Length - 1,
|
||||
RawMatch = matchBytes,
|
||||
ConfidenceScore = confidenceScore,
|
||||
DetectorId = DetectorId,
|
||||
Entropy = entropy
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Entropy detection timeout on file '{FilePath}'",
|
||||
filePath);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>(matches);
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(ReadOnlySpan<char> value)
|
||||
{
|
||||
// Skip UUIDs
|
||||
if (EntropyCalculator.IsHexLike(value) && value.Length == 32)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if it looks like a UUID with dashes
|
||||
if (value.Length == 36 && value[8] == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip common hash prefixes that aren't secrets
|
||||
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("md5:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if it's all the same character repeated
|
||||
char first = value[0];
|
||||
bool allSame = true;
|
||||
for (int i = 1; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != first)
|
||||
{
|
||||
allSame = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allSame)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(double entropy, double threshold)
|
||||
{
|
||||
// Scale confidence based on how far above threshold
|
||||
// entropy >= threshold + 1.5 => 0.95 (high)
|
||||
// entropy >= threshold + 0.5 => 0.75 (medium)
|
||||
// entropy >= threshold => 0.5 (low)
|
||||
var excess = entropy - threshold;
|
||||
return excess switch
|
||||
{
|
||||
>= 1.5 => 0.95,
|
||||
>= 0.5 => 0.75,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int> ComputeLineStarts(string text)
|
||||
{
|
||||
var lineStarts = new List<int> { 0 };
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lineStarts.Add(i + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
private static (int lineNumber, int column) GetLineAndColumn(List<int> lineStarts, int position)
|
||||
{
|
||||
int line = 1;
|
||||
for (int i = 1; i < lineStarts.Count; i++)
|
||||
{
|
||||
if (lineStarts[i] > position)
|
||||
{
|
||||
break;
|
||||
}
|
||||
line = i + 1;
|
||||
}
|
||||
|
||||
int lineStart = lineStarts[line - 1];
|
||||
int column = position - lineStart + 1;
|
||||
return (line, column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for secret detection strategies.
|
||||
/// Implementations must be thread-safe and deterministic.
|
||||
/// </summary>
|
||||
public interface ISecretDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this detector (e.g., "regex", "entropy").
|
||||
/// </summary>
|
||||
string DetectorId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Detects secrets in the provided content using the specified rule.
|
||||
/// </summary>
|
||||
/// <param name="content">The file content to scan.</param>
|
||||
/// <param name="filePath">The file path (for reporting).</param>
|
||||
/// <param name="rule">The rule to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matches found.</returns>
|
||||
ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this detector can handle the specified rule type.
|
||||
/// </summary>
|
||||
bool CanHandle(SecretRuleType ruleType);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Regex-based secret detector.
|
||||
/// </summary>
|
||||
public sealed class RegexDetector : ISecretDetector
|
||||
{
|
||||
private readonly ILogger<RegexDetector> _logger;
|
||||
|
||||
public RegexDetector(ILogger<RegexDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string DetectorId => "regex";
|
||||
|
||||
public bool CanHandle(SecretRuleType ruleType) =>
|
||||
ruleType is SecretRuleType.Regex or SecretRuleType.Composite;
|
||||
|
||||
public ValueTask<IReadOnlyList<SecretMatch>> DetectAsync(
|
||||
ReadOnlyMemory<byte> content,
|
||||
string filePath,
|
||||
SecretRule rule,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
if (regex is null)
|
||||
{
|
||||
_logger.LogWarning("Rule '{RuleId}' has invalid regex pattern", rule.Id);
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Decode content as UTF-8
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(content.Span);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
// Not valid UTF-8, skip
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
// Apply keyword pre-filter
|
||||
if (!rule.MightMatch(text.AsSpan()))
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>([]);
|
||||
}
|
||||
|
||||
var matches = new List<SecretMatch>();
|
||||
var lineStarts = ComputeLineStarts(text);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Match match in regex.Matches(text))
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (match.Length < rule.MinLength || match.Length > rule.MaxLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (lineNumber, columnStart) = GetLineAndColumn(lineStarts, match.Index);
|
||||
var matchBytes = Encoding.UTF8.GetBytes(match.Value);
|
||||
|
||||
matches.Add(new SecretMatch
|
||||
{
|
||||
Rule = rule,
|
||||
FilePath = filePath,
|
||||
LineNumber = lineNumber,
|
||||
ColumnStart = columnStart,
|
||||
ColumnEnd = columnStart + match.Length - 1,
|
||||
RawMatch = matchBytes,
|
||||
ConfidenceScore = MapConfidenceToScore(rule.Confidence),
|
||||
DetectorId = DetectorId
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Regex timeout for rule '{RuleId}' on file '{FilePath}'",
|
||||
rule.Id,
|
||||
filePath);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<SecretMatch>>(matches);
|
||||
}
|
||||
|
||||
private static List<int> ComputeLineStarts(string text)
|
||||
{
|
||||
var lineStarts = new List<int> { 0 };
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lineStarts.Add(i + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
private static (int lineNumber, int column) GetLineAndColumn(List<int> lineStarts, int position)
|
||||
{
|
||||
int line = 1;
|
||||
for (int i = 1; i < lineStarts.Count; i++)
|
||||
{
|
||||
if (lineStarts[i] > position)
|
||||
{
|
||||
break;
|
||||
}
|
||||
line = i + 1;
|
||||
}
|
||||
|
||||
int lineStart = lineStarts[line - 1];
|
||||
int column = position - lineStart + 1;
|
||||
return (line, column);
|
||||
}
|
||||
|
||||
private static double MapConfidenceToScore(SecretConfidence confidence) => confidence switch
|
||||
{
|
||||
SecretConfidence.Low => 0.5,
|
||||
SecretConfidence.Medium => 0.75,
|
||||
SecretConfidence.High => 0.95,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a potential secret match found by a detector.
|
||||
/// </summary>
|
||||
public sealed record SecretMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// The rule that produced this match.
|
||||
/// </summary>
|
||||
public required SecretRule Rule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The file path where the match was found.
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based line number of the match.
|
||||
/// </summary>
|
||||
public required int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column where the match starts.
|
||||
/// </summary>
|
||||
public required int ColumnStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column where the match ends.
|
||||
/// </summary>
|
||||
public required int ColumnEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw matched content (will be masked before output).
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> RawMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score from 0.0 to 1.0.
|
||||
/// </summary>
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detector that found this match.
|
||||
/// </summary>
|
||||
public required string DetectorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional entropy value if entropy-based detection was used.
|
||||
/// </summary>
|
||||
public double? Entropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the length of the matched content.
|
||||
/// </summary>
|
||||
public int MatchLength => RawMatch.Length;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated finding for storage in ScanAnalysisStore.
|
||||
/// </summary>
|
||||
public sealed record SecretFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this finding.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The evidence record.
|
||||
/// </summary>
|
||||
public required SecretLeakEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The scan that produced this finding.
|
||||
/// </summary>
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tenant that owns this finding.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The artifact digest (container image or other artifact).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new finding from evidence.
|
||||
/// </summary>
|
||||
public static SecretFinding Create(
|
||||
SecretLeakEvidence evidence,
|
||||
string scanId,
|
||||
string tenantId,
|
||||
string artifactDigest,
|
||||
Guid? id = null)
|
||||
{
|
||||
return new SecretFinding
|
||||
{
|
||||
Id = id ?? Guid.NewGuid(),
|
||||
Evidence = evidence,
|
||||
ScanId = scanId,
|
||||
TenantId = tenantId,
|
||||
ArtifactDigest = artifactDigest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for a detected secret leak.
|
||||
/// This record is emitted to the policy engine for decision-making.
|
||||
/// </summary>
|
||||
public sealed record SecretLeakEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// The evidence type identifier.
|
||||
/// </summary>
|
||||
public const string EvidenceType = "secret.leak";
|
||||
|
||||
/// <summary>
|
||||
/// The rule ID that produced this finding.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rule version.
|
||||
/// </summary>
|
||||
public required string RuleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The severity of the finding.
|
||||
/// </summary>
|
||||
public required SecretSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The confidence level of the finding.
|
||||
/// </summary>
|
||||
public required SecretConfidence Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The file path where the secret was found (relative to scan root).
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based line number.
|
||||
/// </summary>
|
||||
public required int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The 1-based column number.
|
||||
/// </summary>
|
||||
public int ColumnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The masked payload (e.g., "AKIA****B7"). Never contains the actual secret.
|
||||
/// </summary>
|
||||
public required string Mask { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle ID that contained the rule.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle version.
|
||||
/// </summary>
|
||||
public required string BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was detected.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detector that found this secret.
|
||||
/// </summary>
|
||||
public required string DetectorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entropy value if entropy-based detection was used.
|
||||
/// </summary>
|
||||
public double? Entropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the finding.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates evidence from a secret match and masker.
|
||||
/// </summary>
|
||||
public static SecretLeakEvidence FromMatch(
|
||||
SecretMatch match,
|
||||
IPayloadMasker masker,
|
||||
SecretRuleset ruleset,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(match);
|
||||
ArgumentNullException.ThrowIfNull(masker);
|
||||
ArgumentNullException.ThrowIfNull(ruleset);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var masked = masker.Mask(match.RawMatch.Span, match.Rule.MaskingHint);
|
||||
|
||||
return new SecretLeakEvidence
|
||||
{
|
||||
RuleId = match.Rule.Id,
|
||||
RuleVersion = match.Rule.Version,
|
||||
Severity = match.Rule.Severity,
|
||||
Confidence = MapScoreToConfidence(match.ConfidenceScore, match.Rule.Confidence),
|
||||
FilePath = match.FilePath,
|
||||
LineNumber = match.LineNumber,
|
||||
ColumnNumber = match.ColumnStart,
|
||||
Mask = masked,
|
||||
BundleId = ruleset.Id,
|
||||
BundleVersion = ruleset.Version,
|
||||
DetectedAt = timeProvider.GetUtcNow(),
|
||||
DetectorId = match.DetectorId,
|
||||
Entropy = match.Entropy
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretConfidence MapScoreToConfidence(double score, SecretConfidence ruleDefault)
|
||||
{
|
||||
// Adjust confidence based on detection score
|
||||
if (score >= 0.9)
|
||||
{
|
||||
return SecretConfidence.High;
|
||||
}
|
||||
if (score >= 0.7)
|
||||
{
|
||||
return SecretConfidence.Medium;
|
||||
}
|
||||
if (score >= 0.5)
|
||||
{
|
||||
return ruleDefault;
|
||||
}
|
||||
return SecretConfidence.Low;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Globalization;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.RegularExpressions;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for secret payload masking.
|
||||
/// </summary>
|
||||
public interface IPayloadMasker
|
||||
{
|
||||
/// <summary>
|
||||
/// Masks a secret payload, preserving prefix/suffix for identification.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw secret bytes.</param>
|
||||
/// <param name="hint">Optional masking hint (e.g., "prefix:4,suffix:2").</param>
|
||||
/// <returns>Masked string (e.g., "AKIA****B7").</returns>
|
||||
string Mask(ReadOnlySpan<byte> payload, string? hint = null);
|
||||
|
||||
/// <summary>
|
||||
/// Masks a secret string, preserving prefix/suffix for identification.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw secret string.</param>
|
||||
/// <param name="hint">Optional masking hint (e.g., "prefix:4,suffix:2").</param>
|
||||
/// <returns>Masked string (e.g., "AKIA****B7").</returns>
|
||||
string Mask(ReadOnlySpan<char> payload, string? hint = null);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of payload masking for secrets.
|
||||
/// </summary>
|
||||
public sealed class PayloadMasker : IPayloadMasker
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of characters to preserve at the start.
|
||||
/// </summary>
|
||||
public const int DefaultPrefixLength = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Default number of characters to preserve at the end.
|
||||
/// </summary>
|
||||
public const int DefaultSuffixLength = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of mask characters to use.
|
||||
/// </summary>
|
||||
public const int MaxMaskLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum output length for masked values.
|
||||
/// </summary>
|
||||
public const int MinOutputLength = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total characters to expose (prefix + suffix).
|
||||
/// </summary>
|
||||
public const int MaxExposedChars = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The character used for masking.
|
||||
/// </summary>
|
||||
public const char MaskChar = '*';
|
||||
|
||||
public string Mask(ReadOnlySpan<byte> payload, string? hint = null)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Try to decode as UTF-8
|
||||
try
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(payload);
|
||||
return Mask(text.AsSpan(), hint);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
// Not valid UTF-8, represent as hex
|
||||
var hex = Convert.ToHexString(payload);
|
||||
return Mask(hex.AsSpan(), hint);
|
||||
}
|
||||
}
|
||||
|
||||
public string Mask(ReadOnlySpan<char> payload, string? hint = null)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var (prefixLen, suffixLen) = ParseHint(hint);
|
||||
|
||||
// Enforce maximum exposed characters
|
||||
if (prefixLen + suffixLen > MaxExposedChars)
|
||||
{
|
||||
var ratio = (double)prefixLen / (prefixLen + suffixLen);
|
||||
prefixLen = (int)(MaxExposedChars * ratio);
|
||||
suffixLen = MaxExposedChars - prefixLen;
|
||||
}
|
||||
|
||||
// Handle short payloads
|
||||
if (payload.Length <= prefixLen + suffixLen)
|
||||
{
|
||||
// Too short to mask meaningfully, just return masked placeholder
|
||||
return new string(MaskChar, Math.Min(payload.Length, MinOutputLength));
|
||||
}
|
||||
|
||||
// Calculate mask length
|
||||
var middleLength = payload.Length - prefixLen - suffixLen;
|
||||
var maskLength = Math.Min(middleLength, MaxMaskLength);
|
||||
|
||||
// Build masked output
|
||||
var sb = new StringBuilder(prefixLen + maskLength + suffixLen);
|
||||
|
||||
// Prefix
|
||||
if (prefixLen > 0)
|
||||
{
|
||||
sb.Append(payload[..prefixLen]);
|
||||
}
|
||||
|
||||
// Mask
|
||||
sb.Append(MaskChar, maskLength);
|
||||
|
||||
// Suffix
|
||||
if (suffixLen > 0)
|
||||
{
|
||||
sb.Append(payload[^suffixLen..]);
|
||||
}
|
||||
|
||||
// Ensure minimum length
|
||||
while (sb.Length < MinOutputLength)
|
||||
{
|
||||
sb.Insert(prefixLen, MaskChar);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (int prefix, int suffix) ParseHint(string? hint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
return (DefaultPrefixLength, DefaultSuffixLength);
|
||||
}
|
||||
|
||||
int prefix = DefaultPrefixLength;
|
||||
int suffix = DefaultSuffixLength;
|
||||
|
||||
// Parse hint format: "prefix:4,suffix:2"
|
||||
var parts = hint.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var kv = part.Split(':', StringSplitOptions.TrimEntries);
|
||||
if (kv.Length != 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kv[0].Equals("prefix", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prefix = Math.Max(0, Math.Min(value, MaxExposedChars));
|
||||
}
|
||||
else if (kv[0].Equals("suffix", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
suffix = Math.Max(0, Math.Min(value, MaxExposedChars));
|
||||
}
|
||||
}
|
||||
|
||||
return (prefix, suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Contract for loading secret detection rulesets.
|
||||
/// </summary>
|
||||
public interface IRulesetLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a ruleset from a directory containing bundle files.
|
||||
/// </summary>
|
||||
/// <param name="bundlePath">Path to the bundle directory.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded ruleset.</returns>
|
||||
ValueTask<SecretRuleset> LoadAsync(string bundlePath, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a ruleset from a JSONL stream.
|
||||
/// </summary>
|
||||
/// <param name="rulesStream">Stream containing NDJSON rule definitions.</param>
|
||||
/// <param name="bundleId">The bundle identifier.</param>
|
||||
/// <param name="bundleVersion">The bundle version.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded ruleset.</returns>
|
||||
ValueTask<SecretRuleset> LoadFromJsonlAsync(
|
||||
Stream rulesStream,
|
||||
string bundleId,
|
||||
string bundleVersion,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Loads secret detection rulesets from bundle files.
|
||||
/// </summary>
|
||||
public sealed class RulesetLoader : IRulesetLoader
|
||||
{
|
||||
private readonly ILogger<RulesetLoader> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
|
||||
}
|
||||
};
|
||||
|
||||
public RulesetLoader(ILogger<RulesetLoader> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async ValueTask<SecretRuleset> LoadAsync(string bundlePath, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
throw new ArgumentException("Bundle path is required", nameof(bundlePath));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(bundlePath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Bundle directory not found: {bundlePath}");
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(bundlePath, "secrets.ruleset.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException("Bundle manifest not found", manifestPath);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse bundle manifest");
|
||||
|
||||
// Load rules
|
||||
var rulesPath = Path.Combine(bundlePath, "secrets.ruleset.rules.jsonl");
|
||||
if (!File.Exists(rulesPath))
|
||||
{
|
||||
throw new FileNotFoundException("Bundle rules file not found", rulesPath);
|
||||
}
|
||||
|
||||
await using var rulesStream = File.OpenRead(rulesPath);
|
||||
var ruleset = await LoadFromJsonlAsync(
|
||||
rulesStream,
|
||||
manifest.Id ?? "secrets.ruleset",
|
||||
manifest.Version ?? "unknown",
|
||||
ct);
|
||||
|
||||
// Verify integrity if digest is available
|
||||
if (!string.IsNullOrEmpty(manifest.Integrity?.RulesSha256))
|
||||
{
|
||||
rulesStream.Position = 0;
|
||||
var actualDigest = await ComputeSha256Async(rulesStream, ct);
|
||||
if (!string.Equals(actualDigest, manifest.Integrity.RulesSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Rules file integrity check failed. Expected: {manifest.Integrity.RulesSha256}, Actual: {actualDigest}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded secrets ruleset '{BundleId}' version {Version} with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
ruleset.Id,
|
||||
ruleset.Version,
|
||||
ruleset.Rules.Length,
|
||||
ruleset.EnabledRuleCount);
|
||||
|
||||
return ruleset;
|
||||
}
|
||||
|
||||
public async ValueTask<SecretRuleset> LoadFromJsonlAsync(
|
||||
Stream rulesStream,
|
||||
string bundleId,
|
||||
string bundleVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rulesStream);
|
||||
|
||||
var rules = new List<SecretRule>();
|
||||
using var reader = new StreamReader(rulesStream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
int lineNumber = 0;
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) is not null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lineNumber++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ruleJson = JsonSerializer.Deserialize<RuleJson>(line, JsonOptions);
|
||||
if (ruleJson is null)
|
||||
{
|
||||
_logger.LogWarning("Skipping null rule at line {LineNumber}", lineNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var rule = MapToRule(ruleJson);
|
||||
rules.Add(rule);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse rule at line {LineNumber}", lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rules by ID for deterministic ordering
|
||||
rules.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
|
||||
|
||||
return new SecretRuleset
|
||||
{
|
||||
Id = bundleId,
|
||||
Version = bundleVersion,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Rules = [.. rules]
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule MapToRule(RuleJson json)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = json.Id ?? throw new InvalidOperationException("Rule ID is required"),
|
||||
Version = json.Version ?? "1.0.0",
|
||||
Name = json.Name ?? json.Id ?? "Unknown",
|
||||
Description = json.Description ?? string.Empty,
|
||||
Type = ParseRuleType(json.Type),
|
||||
Pattern = json.Pattern ?? throw new InvalidOperationException("Rule pattern is required"),
|
||||
Severity = ParseSeverity(json.Severity),
|
||||
Confidence = ParseConfidence(json.Confidence),
|
||||
MaskingHint = json.MaskingHint,
|
||||
Keywords = json.Keywords?.ToImmutableArray() ?? [],
|
||||
FilePatterns = json.FilePatterns?.ToImmutableArray() ?? [],
|
||||
Enabled = json.Enabled ?? true,
|
||||
EntropyThreshold = json.EntropyThreshold ?? 4.5,
|
||||
MinLength = json.MinLength ?? 16,
|
||||
MaxLength = json.MaxLength ?? 1000,
|
||||
Metadata = json.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRuleType ParseRuleType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"regex" => SecretRuleType.Regex,
|
||||
"entropy" => SecretRuleType.Entropy,
|
||||
"composite" => SecretRuleType.Composite,
|
||||
_ => SecretRuleType.Regex
|
||||
};
|
||||
|
||||
private static SecretSeverity ParseSeverity(string? severity) => severity?.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => SecretSeverity.Low,
|
||||
"medium" => SecretSeverity.Medium,
|
||||
"high" => SecretSeverity.High,
|
||||
"critical" => SecretSeverity.Critical,
|
||||
_ => SecretSeverity.Medium
|
||||
};
|
||||
|
||||
private static SecretConfidence ParseConfidence(string? confidence) => confidence?.ToLowerInvariant() switch
|
||||
{
|
||||
"low" => SecretConfidence.Low,
|
||||
"medium" => SecretConfidence.Medium,
|
||||
"high" => SecretConfidence.High,
|
||||
_ => SecretConfidence.Medium
|
||||
};
|
||||
|
||||
private static async Task<string> ComputeSha256Async(Stream stream, CancellationToken ct)
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// JSON deserialization models
|
||||
private sealed class BundleManifest
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public IntegrityInfo? Integrity { get; set; }
|
||||
}
|
||||
|
||||
private sealed class IntegrityInfo
|
||||
{
|
||||
public string? RulesSha256 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RuleJson
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Pattern { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Confidence { get; set; }
|
||||
public string? MaskingHint { get; set; }
|
||||
public List<string>? Keywords { get; set; }
|
||||
public List<string>? FilePatterns { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public double? EntropyThreshold { get; set; }
|
||||
public int? MinLength { get; set; }
|
||||
public int? MaxLength { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for a secret detection finding.
|
||||
/// </summary>
|
||||
public enum SecretConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - may be a false positive.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - likely a real secret but requires verification.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - almost certainly a real secret.
|
||||
/// </summary>
|
||||
High = 2
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// A single secret detection rule defining patterns and metadata for identifying secrets.
|
||||
/// </summary>
|
||||
public sealed record SecretRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique rule identifier (e.g., "stellaops.secrets.aws-access-key").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule version in SemVer format (e.g., "1.0.0").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable rule name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of what this rule detects.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detection strategy type.
|
||||
/// </summary>
|
||||
public required SecretRuleType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The detection pattern (regex pattern for Regex type, entropy config for Entropy type).
|
||||
/// </summary>
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default severity for findings from this rule.
|
||||
/// </summary>
|
||||
public required SecretSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default confidence level for findings from this rule.
|
||||
/// </summary>
|
||||
public required SecretConfidence Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional masking hint (e.g., "prefix:4,suffix:2") for payload masking.
|
||||
/// </summary>
|
||||
public string? MaskingHint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-filter keywords for fast rejection of non-matching content.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Keywords { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Glob patterns for files this rule should be applied to.
|
||||
/// Empty means all text files.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> FilePatterns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this rule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum entropy threshold for entropy-based detection.
|
||||
/// Only used when Type is Entropy or Composite.
|
||||
/// </summary>
|
||||
public double EntropyThreshold { get; init; } = 4.5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum string length for entropy-based detection.
|
||||
/// </summary>
|
||||
public int MinLength { get; init; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum string length for detection (prevents matching entire files).
|
||||
/// </summary>
|
||||
public int MaxLength { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for the rule.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The compiled regex pattern, created lazily.
|
||||
/// </summary>
|
||||
private Regex? _compiledPattern;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compiled regex for this rule. Returns null if the pattern is invalid.
|
||||
/// </summary>
|
||||
public Regex? GetCompiledPattern()
|
||||
{
|
||||
if (Type == SecretRuleType.Entropy)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_compiledPattern is not null)
|
||||
{
|
||||
return _compiledPattern;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_compiledPattern = new Regex(
|
||||
Pattern,
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromSeconds(5));
|
||||
return _compiledPattern;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the content might match this rule based on keywords.
|
||||
/// Returns true if no keywords are defined or if any keyword is found.
|
||||
/// </summary>
|
||||
public bool MightMatch(ReadOnlySpan<char> content)
|
||||
{
|
||||
if (Keywords.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var keyword in Keywords)
|
||||
{
|
||||
if (content.Contains(keyword.AsSpan(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this rule should be applied to the given file path.
|
||||
/// </summary>
|
||||
public bool AppliesToFile(string filePath)
|
||||
{
|
||||
if (FilePatterns.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
foreach (var pattern in FilePatterns)
|
||||
{
|
||||
if (MatchesGlob(fileName, pattern) || MatchesGlob(filePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesGlob(string path, string pattern)
|
||||
{
|
||||
// Simple glob matching for common patterns
|
||||
if (pattern.StartsWith("**", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[2..].TrimStart('/').TrimStart('\\');
|
||||
if (suffix.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var extension = suffix[1..];
|
||||
return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.Contains(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var extension = pattern[1..];
|
||||
return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// The type of detection strategy used by a secret rule.
|
||||
/// </summary>
|
||||
public enum SecretRuleType
|
||||
{
|
||||
/// <summary>
|
||||
/// Regex-based pattern matching.
|
||||
/// </summary>
|
||||
Regex = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Shannon entropy-based detection for high-entropy strings.
|
||||
/// </summary>
|
||||
Entropy = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Combined regex and entropy detection.
|
||||
/// </summary>
|
||||
Composite = 2
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// A versioned collection of secret detection rules.
|
||||
/// </summary>
|
||||
public sealed record SecretRuleset
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format (e.g., "2026.01").
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this bundle was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rules in this bundle.
|
||||
/// </summary>
|
||||
public required ImmutableArray<SecretRule> Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the rules file for integrity verification.
|
||||
/// </summary>
|
||||
public string? Sha256Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description of this bundle.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets only the enabled rules from this bundle.
|
||||
/// </summary>
|
||||
public IEnumerable<SecretRule> EnabledRules => Rules.Where(r => r.Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of enabled rules.
|
||||
/// </summary>
|
||||
public int EnabledRuleCount => Rules.Count(r => r.Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty ruleset.
|
||||
/// </summary>
|
||||
public static SecretRuleset Empty { get; } = new()
|
||||
{
|
||||
Id = "empty",
|
||||
Version = "0.0",
|
||||
CreatedAt = DateTimeOffset.MinValue,
|
||||
Rules = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all rules in this bundle have valid patterns.
|
||||
/// </summary>
|
||||
/// <returns>A list of validation errors, empty if valid.</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
{
|
||||
errors.Add("Bundle ID is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Version))
|
||||
{
|
||||
errors.Add("Bundle version is required");
|
||||
}
|
||||
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.Id))
|
||||
{
|
||||
errors.Add("Rule ID is required");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(rule.Id))
|
||||
{
|
||||
errors.Add($"Duplicate rule ID: {rule.Id}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rule.Pattern))
|
||||
{
|
||||
errors.Add($"Rule '{rule.Id}' has no pattern");
|
||||
}
|
||||
|
||||
if (rule.Type is SecretRuleType.Regex or SecretRuleType.Composite)
|
||||
{
|
||||
if (rule.GetCompiledPattern() is null)
|
||||
{
|
||||
errors.Add($"Rule '{rule.Id}' has invalid regex pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets rules that apply to the specified file.
|
||||
/// </summary>
|
||||
public IEnumerable<SecretRule> GetRulesForFile(string filePath)
|
||||
{
|
||||
return EnabledRules.Where(r => r.AppliesToFile(filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for a secret detection rule.
|
||||
/// </summary>
|
||||
public enum SecretSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Low severity - informational or low-risk credentials.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity - credentials with limited scope or short lifespan.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High severity - production credentials with broad access.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Critical severity - highly privileged credentials requiring immediate action.
|
||||
/// </summary>
|
||||
Critical = 3
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer that detects accidentally committed secrets in container layers.
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
private readonly IOptions<SecretsAnalyzerOptions> _options;
|
||||
private readonly CompositeSecretDetector _detector;
|
||||
private readonly IPayloadMasker _masker;
|
||||
private readonly ILogger<SecretsAnalyzer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private SecretRuleset? _ruleset;
|
||||
|
||||
public SecretsAnalyzer(
|
||||
IOptions<SecretsAnalyzerOptions> options,
|
||||
CompositeSecretDetector detector,
|
||||
IPayloadMasker masker,
|
||||
ILogger<SecretsAnalyzer> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
|
||||
_masker = masker ?? throw new ArgumentNullException(nameof(masker));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public string Id => "secrets";
|
||||
public string DisplayName => "Secret Leak Detector";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the analyzer is enabled and has a valid ruleset.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _options.Value.Enabled && _ruleset is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently loaded ruleset.
|
||||
/// </summary>
|
||||
public SecretRuleset? Ruleset => _ruleset;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the ruleset to use for detection.
|
||||
/// Called by SecretsAnalyzerHost after loading the bundle.
|
||||
/// </summary>
|
||||
internal void SetRuleset(SecretRuleset ruleset)
|
||||
{
|
||||
_ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset));
|
||||
}
|
||||
|
||||
public async ValueTask AnalyzeAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Secrets analyzer is disabled or has no ruleset");
|
||||
return;
|
||||
}
|
||||
|
||||
var options = _options.Value;
|
||||
var findings = new List<SecretLeakEvidence>();
|
||||
var filesScanned = 0;
|
||||
|
||||
// Scan all text files in the root
|
||||
foreach (var filePath in EnumerateTextFiles(context.RootPath, options))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (findings.Count >= options.MaxFindingsPerScan)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Maximum findings limit ({MaxFindings}) reached, stopping scan",
|
||||
options.MaxFindingsPerScan);
|
||||
break;
|
||||
}
|
||||
|
||||
var fileFindings = await ScanFileAsync(context, filePath, options, cancellationToken);
|
||||
findings.AddRange(fileFindings);
|
||||
filesScanned++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Secrets scan complete: {FileCount} files scanned, {FindingCount} findings",
|
||||
filesScanned,
|
||||
findings.Count);
|
||||
|
||||
// Store findings in analysis store if available
|
||||
if (context.AnalysisStore is not null && findings.Count > 0)
|
||||
{
|
||||
await StoreFindings(context.AnalysisStore, findings, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<List<SecretLeakEvidence>> ScanFileAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string filePath,
|
||||
SecretsAnalyzerOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var findings = new List<SecretLeakEvidence>();
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (fileInfo.Length > options.MaxFileSizeBytes)
|
||||
{
|
||||
_logger.LogDebug("Skipping large file: {FilePath} ({Size} bytes)", filePath, fileInfo.Length);
|
||||
return findings;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var relativePath = context.GetRelativePath(filePath);
|
||||
|
||||
foreach (var rule in _ruleset!.GetRulesForFile(relativePath))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = await _detector.DetectAsync(content, relativePath, rule, ct);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
// Check confidence threshold
|
||||
var confidence = MapScoreToConfidence(match.ConfidenceScore);
|
||||
if (confidence < options.MinConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset, _timeProvider);
|
||||
findings.Add(evidence);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found secret: Rule={RuleId}, File={FilePath}:{Line}, Mask={Mask}",
|
||||
rule.Id,
|
||||
relativePath,
|
||||
match.LineNumber,
|
||||
evidence.Mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error scanning file: {FilePath}", filePath);
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateTextFiles(string rootPath, SecretsAnalyzerOptions options)
|
||||
{
|
||||
var searchOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System | FileAttributes.Hidden
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(rootPath, "*", searchOptions))
|
||||
{
|
||||
var extension = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
// Check exclusions
|
||||
if (options.ExcludeExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if directory is excluded
|
||||
var relativePath = Path.GetRelativePath(rootPath, file).Replace('\\', '/');
|
||||
if (IsExcludedDirectory(relativePath, options.ExcludeDirectories))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check inclusions if specified
|
||||
if (options.IncludeExtensions.Count > 0 && !options.IncludeExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExcludedDirectory(string relativePath, HashSet<string> patterns)
|
||||
{
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (MatchesGlobPattern(relativePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesGlobPattern(string path, string pattern)
|
||||
{
|
||||
if (pattern.StartsWith("**/", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[3..];
|
||||
if (suffix.EndsWith("/**", StringComparison.Ordinal))
|
||||
{
|
||||
var middle = suffix[..^3];
|
||||
return path.Contains(middle, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return path.StartsWith(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SecretConfidence MapScoreToConfidence(double score) => score switch
|
||||
{
|
||||
>= 0.9 => SecretConfidence.High,
|
||||
>= 0.7 => SecretConfidence.Medium,
|
||||
_ => SecretConfidence.Low
|
||||
};
|
||||
|
||||
private async ValueTask StoreFindings(
|
||||
object analysisStore,
|
||||
List<SecretLeakEvidence> findings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// TODO: Store findings in ScanAnalysisStore when interface is defined
|
||||
// For now, just log that we would store them
|
||||
_logger.LogDebug("Would store {Count} secret findings in analysis store", findings.Count);
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the lifecycle of the secrets analyzer.
|
||||
/// Loads and validates the rule bundle on startup with optional signature verification.
|
||||
/// Sprint: SPRINT_20260104_003_SCANNER - Secret Rule Bundles
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzerHost : IHostedService
|
||||
{
|
||||
private readonly SecretsAnalyzer _analyzer;
|
||||
private readonly IRulesetLoader _rulesetLoader;
|
||||
private readonly IBundleVerifier? _bundleVerifier;
|
||||
private readonly IOptions<SecretsAnalyzerOptions> _options;
|
||||
private readonly ILogger<SecretsAnalyzerHost> _logger;
|
||||
|
||||
public SecretsAnalyzerHost(
|
||||
SecretsAnalyzer analyzer,
|
||||
IRulesetLoader rulesetLoader,
|
||||
IOptions<SecretsAnalyzerOptions> options,
|
||||
ILogger<SecretsAnalyzerHost> logger,
|
||||
IBundleVerifier? bundleVerifier = null)
|
||||
{
|
||||
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
|
||||
_rulesetLoader = rulesetLoader ?? throw new ArgumentNullException(nameof(rulesetLoader));
|
||||
_bundleVerifier = bundleVerifier;
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle verification result from the last startup, if available.
|
||||
/// </summary>
|
||||
public BundleVerificationResult? LastVerificationResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the analyzer is enabled and has loaded successfully.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _analyzer.IsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the loaded bundle version, if available.
|
||||
/// </summary>
|
||||
public string? BundleVersion => _analyzer.Ruleset?.Version;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Secret leak detection is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Loading secrets rule bundle from {Path}", options.RulesetPath);
|
||||
|
||||
try
|
||||
{
|
||||
// Verify bundle signature if required
|
||||
if (options.RequireSignatureVerification || _bundleVerifier is not null)
|
||||
{
|
||||
await VerifyBundleAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
var ruleset = await _rulesetLoader.LoadAsync(options.RulesetPath, cancellationToken);
|
||||
|
||||
// Validate the ruleset
|
||||
var errors = ruleset.Validate();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"SecretsAnalyzerHost: Bundle validation failed with {ErrorCount} errors: {Errors}",
|
||||
errors.Count,
|
||||
string.Join(", ", errors));
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Secret detection bundle validation failed: {string.Join(", ", errors)}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the ruleset on the analyzer
|
||||
_analyzer.SetRuleset(ruleset);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SecretsAnalyzerHost: Loaded bundle '{BundleId}' version {Version} with {RuleCount} rules ({EnabledCount} enabled)",
|
||||
ruleset.Id,
|
||||
ruleset.Version,
|
||||
ruleset.Rules.Length,
|
||||
ruleset.EnabledRuleCount);
|
||||
}
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SecretsAnalyzerHost: Bundle directory not found, analyzer disabled");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SecretsAnalyzerHost: Bundle file not found, analyzer disabled");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "SecretsAnalyzerHost: Failed to load bundle");
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("SecretsAnalyzerHost: Shutting down");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task VerifyBundleAsync(SecretsAnalyzerOptions options, CancellationToken ct)
|
||||
{
|
||||
if (_bundleVerifier is null)
|
||||
{
|
||||
if (options.RequireSignatureVerification)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Signature verification is required but no IBundleVerifier is registered.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var verificationOptions = new BundleVerificationOptions
|
||||
{
|
||||
RequireRekorProof = options.RequireRekorProof,
|
||||
TrustedKeyIds = options.TrustedKeyIds.Count > 0 ? [.. options.TrustedKeyIds] : null,
|
||||
SharedSecret = options.SignatureSecret,
|
||||
SharedSecretFile = options.SignatureSecretFile,
|
||||
VerifyIntegrity = true,
|
||||
SkipSignatureVerification = !options.RequireSignatureVerification
|
||||
};
|
||||
|
||||
_logger.LogDebug("SecretsAnalyzerHost: Verifying bundle signature");
|
||||
|
||||
var result = await _bundleVerifier.VerifyAsync(
|
||||
options.RulesetPath,
|
||||
verificationOptions,
|
||||
ct);
|
||||
|
||||
LastVerificationResult = result;
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var errorMessage = $"Bundle verification failed: {string.Join("; ", result.ValidationErrors)}";
|
||||
_logger.LogError("SecretsAnalyzerHost: {Error}", errorMessage);
|
||||
|
||||
if (options.FailOnInvalidBundle)
|
||||
{
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
// Allow loading but log prominently
|
||||
_logger.LogWarning(
|
||||
"SecretsAnalyzerHost: Continuing with unverified bundle. " +
|
||||
"Set RequireSignatureVerification=true to enforce verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"SecretsAnalyzerHost: Bundle verified - signed by {KeyId} at {SignedAt}",
|
||||
result.SignerKeyId ?? "unknown",
|
||||
result.SignedAt?.ToString("o") ?? "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the secrets analyzer.
|
||||
/// </summary>
|
||||
public sealed class SecretsAnalyzerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:Analyzers:Secrets";
|
||||
|
||||
/// <summary>
|
||||
/// Enable secret leak detection (experimental feature).
|
||||
/// Default: false (opt-in).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the ruleset bundle directory.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string RulesetPath { get; set; } = "/opt/stellaops/plugins/scanner/analyzers/secrets";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence level to report findings.
|
||||
/// </summary>
|
||||
public SecretConfidence MinConfidence { get; set; } = SecretConfidence.Low;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum findings per scan (circuit breaker).
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int MaxFindingsPerScan { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum file size to scan in bytes.
|
||||
/// Files larger than this are skipped.
|
||||
/// </summary>
|
||||
[Range(1, 100 * 1024 * 1024)]
|
||||
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
/// <summary>
|
||||
/// Enable entropy-based detection.
|
||||
/// </summary>
|
||||
public bool EnableEntropyDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default entropy threshold (bits per character).
|
||||
/// </summary>
|
||||
[Range(3.0, 8.0)]
|
||||
public double EntropyThreshold { get; set; } = 4.5;
|
||||
|
||||
/// <summary>
|
||||
/// File extensions to scan. Empty means all text files.
|
||||
/// </summary>
|
||||
public HashSet<string> IncludeExtensions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// File extensions to exclude from scanning.
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludeExtensions { get; set; } =
|
||||
[
|
||||
".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".mp3", ".mp4", ".avi", ".mov", ".mkv",
|
||||
".ttf", ".woff", ".woff2", ".eot",
|
||||
".min.js", ".min.css"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Directories to exclude from scanning (glob patterns).
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludeDirectories { get; set; } =
|
||||
[
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/vendor/**",
|
||||
"**/__pycache__/**",
|
||||
"**/bin/**",
|
||||
"**/obj/**"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the scan if the bundle cannot be loaded.
|
||||
/// </summary>
|
||||
public bool FailOnInvalidBundle { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require DSSE signature verification for bundles.
|
||||
/// </summary>
|
||||
public bool RequireSignatureVerification { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for HMAC signature verification (base64 or hex).
|
||||
/// </summary>
|
||||
public string? SignatureSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to file containing the shared secret.
|
||||
/// </summary>
|
||||
public string? SignatureSecretFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs for signature verification.
|
||||
/// If empty, any key is accepted.
|
||||
/// </summary>
|
||||
public HashSet<string> TrustedKeyIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Rekor transparency log proof.
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering secrets analyzer services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the secrets analyzer services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSecretsAnalyzer(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register options
|
||||
services.AddOptions<SecretsAnalyzerOptions>()
|
||||
.Bind(configuration.GetSection(SecretsAnalyzerOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
RegisterCoreServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the secrets analyzer services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSecretsAnalyzer(
|
||||
this IServiceCollection services,
|
||||
Action<SecretsAnalyzerOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
// Register options
|
||||
services.AddOptions<SecretsAnalyzerOptions>()
|
||||
.Configure(configureOptions)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
RegisterCoreServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterCoreServices(IServiceCollection services)
|
||||
{
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<IPayloadMasker, PayloadMasker>();
|
||||
services.AddSingleton<IRulesetLoader, RulesetLoader>();
|
||||
|
||||
// Register detectors
|
||||
services.AddSingleton<RegexDetector>();
|
||||
services.AddSingleton<EntropyDetector>();
|
||||
services.AddSingleton<CompositeSecretDetector>();
|
||||
|
||||
// Register bundle infrastructure (Sprint: SPRINT_20260104_003_SCANNER)
|
||||
services.AddSingleton<IRuleValidator, RuleValidator>();
|
||||
services.AddSingleton<IBundleBuilder, BundleBuilder>();
|
||||
services.AddSingleton<IBundleSigner, BundleSigner>();
|
||||
services.AddSingleton<IBundleVerifier, BundleVerifier>();
|
||||
|
||||
// Register analyzer
|
||||
services.AddSingleton<SecretsAnalyzer>();
|
||||
services.AddSingleton<ILanguageAnalyzer>(sp => sp.GetRequiredService<SecretsAnalyzer>());
|
||||
|
||||
// Register hosted service
|
||||
services.AddSingleton<SecretsAnalyzerHost>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<SecretsAnalyzerHost>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.CallGraph.Binary;
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using StellaOps.Scanner.CallGraph.Go;
|
||||
@@ -40,6 +41,7 @@ public static class CallGraphServiceCollectionExtensions
|
||||
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>(); // Node.js/JavaScript via Babel
|
||||
services.AddSingleton<ICallGraphExtractor, PythonCallGraphExtractor>(); // Python via AST analysis
|
||||
services.AddSingleton<ICallGraphExtractor, GoCallGraphExtractor>(); // Go via SSA analysis
|
||||
services.AddSingleton<ICallGraphExtractor, BinaryCallGraphExtractor>(); // Native ELF/PE/Mach-O binaries
|
||||
|
||||
// Register the extractor registry for language-based lookup
|
||||
services.AddSingleton<ICallGraphExtractorRegistry, CallGraphExtractorRegistry>();
|
||||
|
||||
@@ -50,4 +50,8 @@ public static class ScanAnalysisKeys
|
||||
public const string VulnerabilityMatches = "analysis.poe.vulnerability.matches";
|
||||
public const string PoEResults = "analysis.poe.results";
|
||||
public const string PoEConfiguration = "analysis.poe.configuration";
|
||||
|
||||
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
|
||||
public const string SecretFindings = "analysis.secrets.findings";
|
||||
public const string SecretRulesetVersion = "analysis.secrets.ruleset.version";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleBuilderTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle building.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _outputDir;
|
||||
private readonly BundleBuilder _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"bundle-test-{Guid.NewGuid():N}");
|
||||
_sourceDir = Path.Combine(_tempDir, "sources");
|
||||
_outputDir = Path.Combine(_tempDir, "output");
|
||||
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
|
||||
var validator = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
_sut = new BundleBuilder(validator, NullLogger<BundleBuilder>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ValidRules_CreatesBundle()
|
||||
{
|
||||
// Arrange
|
||||
var rule1Path = CreateRuleFile("rule1.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule1",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 1",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var rule2Path = CreateRuleFile("rule2.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule2",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 2",
|
||||
Description = "Another test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[0-9]{8}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rule1Path, rule2Path },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "test-bundle",
|
||||
Version = "2026.01",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(artifact);
|
||||
Assert.Equal("test-bundle", artifact.Manifest.Id);
|
||||
Assert.Equal("2026.01", artifact.Manifest.Version);
|
||||
Assert.Equal(2, artifact.TotalRules);
|
||||
Assert.Equal(2, artifact.EnabledRules);
|
||||
Assert.True(File.Exists(artifact.ManifestPath));
|
||||
Assert.True(File.Exists(artifact.RulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SortsRulesById()
|
||||
{
|
||||
// Arrange
|
||||
var zebraPath = CreateRuleFile("z-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.zebra",
|
||||
Version = "1.0.0",
|
||||
Name = "Zebra Rule",
|
||||
Description = "Rule that should sort last",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "zebra",
|
||||
Severity = SecretSeverity.Low,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var alphaPath = CreateRuleFile("a-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.alpha",
|
||||
Version = "1.0.0",
|
||||
Name = "Alpha Rule",
|
||||
Description = "Rule that should sort first",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "alpha",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { zebraPath, alphaPath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "sorted-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - check the manifest rules array is sorted (manifest is already built)
|
||||
Assert.Equal(2, artifact.Manifest.Rules.Length);
|
||||
Assert.Equal("stellaops.secrets.alpha", artifact.Manifest.Rules[0].Id);
|
||||
Assert.Equal("stellaops.secrets.zebra", artifact.Manifest.Rules[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ComputesCorrectSha256()
|
||||
{
|
||||
// Arrange
|
||||
var rulePath = CreateRuleFile("rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.hash-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Hash Test",
|
||||
Description = "Rule for testing SHA-256 computation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test123",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "hash-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(artifact.RulesSha256);
|
||||
Assert.Matches("^[a-f0-9]{64}$", artifact.RulesSha256);
|
||||
|
||||
// Verify hash matches file content
|
||||
await using var stream = File.OpenRead(artifact.RulesPath);
|
||||
var hash = await System.Security.Cryptography.SHA256.HashDataAsync(stream, TestContext.Current.CancellationToken);
|
||||
var expectedHash = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
Assert.Equal(expectedHash, artifact.RulesSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_InvalidRule_ThrowsException()
|
||||
{
|
||||
// Arrange - create an invalid rule (id not properly namespaced)
|
||||
var invalidRulePath = Path.Combine(_sourceDir, "invalid-rule.json");
|
||||
var invalidRuleJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = "invalid", // Not namespaced with stellaops.secrets
|
||||
version = "1.0.0",
|
||||
name = "Invalid Rule",
|
||||
description = "This rule has an invalid ID",
|
||||
type = "regex",
|
||||
pattern = "test",
|
||||
severity = "medium",
|
||||
confidence = "medium"
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(invalidRulePath, invalidRuleJson, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { invalidRulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "invalid-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_EmptyRuleFiles_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = Array.Empty<string>(),
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "empty-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NonexistentRuleFile_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { Path.Combine(_tempDir, "nonexistent.json") },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "missing-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private string CreateRuleFile(string filename, SecretRule rule)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var path = Path.Combine(_sourceDir, filename);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleSignerTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle signing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleSignerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleSigner _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleSignerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"signer-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ValidArtifact_CreatesDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]), // 256-bit key
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(File.Exists(result.EnvelopePath));
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Single(result.Envelope.Signatures);
|
||||
Assert.Equal("test-key-001", result.Envelope.Signatures[0].KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UpdatesManifestWithSignatureInfo()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "signer-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.UpdatedManifest.Signatures);
|
||||
Assert.Equal("signer-key", result.UpdatedManifest.Signatures.KeyId);
|
||||
Assert.Equal("secrets.ruleset.dsse.json", result.UpdatedManifest.Signatures.DsseEnvelope);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.UpdatedManifest.Signatures.SignedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_EnvelopeContainsBase64UrlPayload()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Envelope.Payload);
|
||||
// Base64url should not contain +, /, or =
|
||||
Assert.DoesNotContain("+", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("/", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("=", result.Envelope.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSecretFile_LoadsSecret()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var secretFile = Path.Combine(_tempDir, "secret.key");
|
||||
var secret = Convert.ToBase64String(new byte[32]);
|
||||
await File.WriteAllTextAsync(secretFile, secret, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "file-key",
|
||||
SharedSecretFile = secretFile,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithoutSecret_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "no-secret-key",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UnsupportedAlgorithm_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
Algorithm = "ES256", // Not supported
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_HexEncodedSecret_Works()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var hexSecret = new string('a', 64); // 32 bytes as hex
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "hex-key",
|
||||
SharedSecret = hexSecret,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
private BundleArtifact CreateTestArtifact()
|
||||
{
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = new string('0', 64),
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_tempDir, "secrets.ruleset.manifest.json");
|
||||
var rulesPath = Path.Combine(_tempDir, "secrets.ruleset.rules.jsonl");
|
||||
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.WriteAllText(rulesPath, "{\"id\":\"test.rule\"}");
|
||||
|
||||
return new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = rulesPath,
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleVerifierTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleVerifier _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly byte[] _testSecret;
|
||||
|
||||
public BundleVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"verifier-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleVerifier(NullLogger<BundleVerifier>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_testSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(_testSecret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-bundle", result.BundleId);
|
||||
Assert.Equal("1.0.0", result.BundleVersion);
|
||||
Assert.Empty(result.ValidationErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedRulesFile_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Tamper with the rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.AppendAllTextAsync(rulesPath, "\n{\"id\":\"injected.rule\"}", TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("integrity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var wrongSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(wrongSecret);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(wrongSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("signature", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingManifest_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = Path.Combine(_tempDir, "missing-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("manifest", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NonexistentDirectory_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleVerificationOptions();
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(Path.Combine(_tempDir, "nonexistent"), options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SkipSignatureVerification_OnlyChecksIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateUnsignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SkipSignatureVerification = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UntrustedKeyId_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "other-trusted-key" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("trusted", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TrustedKeyId_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "test-key-001" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-key-001", result.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RequireRekorProof_ReturnsWarningWhenNotVerified()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleWithRekorAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
RequireRekorProof = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert (Rekor verification not implemented, should have warning)
|
||||
Assert.NotEmpty(result.ValidationWarnings);
|
||||
Assert.Contains(result.ValidationWarnings, w => w.Contains("Rekor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<string> CreateUnsignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempDir, $"bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
// Create rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
var ruleJson = JsonSerializer.Serialize(new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test",
|
||||
Description = "A test rule for verification tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
});
|
||||
await File.WriteAllTextAsync(rulesPath, ruleJson, ct);
|
||||
|
||||
// Compute hash
|
||||
await using var stream = File.OpenRead(rulesPath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
// Create manifest
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = hashHex,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateUnsignedBundleAsync(ct);
|
||||
|
||||
// Sign the bundle
|
||||
var signer = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var artifact = new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl"),
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
|
||||
await signer.SignAsync(artifact, new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TimeProvider = _timeProvider
|
||||
}, ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleWithRekorAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateSignedBundleAsync(ct);
|
||||
|
||||
// Update manifest to include Rekor log ID
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var updatedManifest = manifest with
|
||||
{
|
||||
Signatures = manifest.Signatures! with
|
||||
{
|
||||
RekorLogId = "rekor-log-entry-123456"
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(updatedManifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleValidatorTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for rule validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class RuleValidatorTests
|
||||
{
|
||||
private readonly RuleValidator _sut;
|
||||
|
||||
public RuleValidatorTests()
|
||||
{
|
||||
_sut = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid-id")] // No namespace separator (no dots)
|
||||
[InlineData("InvalidCase.rule")] // Starts with uppercase
|
||||
public void Validate_InvalidId_ReturnsError(string invalidId)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = invalidId,
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("ID"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("1.0")]
|
||||
[InlineData("v1.0.0")]
|
||||
public void Validate_InvalidVersion_ReturnsError(string invalidVersion)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = invalidVersion,
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("version", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[")]
|
||||
[InlineData("(unclosed")]
|
||||
[InlineData("(?invalid)")]
|
||||
public void Validate_InvalidRegex_ReturnsError(string invalidPattern)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = invalidPattern,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("regex", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPattern_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidEntropyRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 4.5
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithDefaultThreshold_ReturnsValid()
|
||||
{
|
||||
// Arrange - using the default entropy threshold (4.5) which is in valid range
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with default threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
// Default entropy threshold is 4.5, which is in the valid range
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - default threshold (4.5) is valid, no warnings expected
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithOutOfRangeThreshold_ReturnsWarning()
|
||||
{
|
||||
// Arrange - using an out-of-range threshold
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with extreme threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 0 // Zero triggers <= 0 warning condition
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - valid but with warning about unusual threshold
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("entropy", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EntropyCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calculate_EmptyString_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(string.Empty);
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_SingleCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("a");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_RepeatedCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("aaaaaaaaaa");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_TwoDistinctCharacters_ReturnsOne()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("ababababab");
|
||||
|
||||
entropy.Should().BeApproximately(1.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FourDistinctCharacters_ReturnsTwo()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("abcdabcdabcd");
|
||||
|
||||
entropy.Should().BeApproximately(2.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_HighEntropyString_ReturnsHighValue()
|
||||
{
|
||||
var highEntropyString = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(highEntropyString);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_LowEntropyPassword_ReturnsLowValue()
|
||||
{
|
||||
var lowEntropyString = "password";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(lowEntropyString);
|
||||
|
||||
entropy.Should().BeLessThan(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AwsAccessKeyPattern_ReturnsHighEntropy()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(awsKey);
|
||||
|
||||
entropy.Should().BeGreaterThan(3.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Base64String_ReturnsHighEntropy()
|
||||
{
|
||||
var base64 = "SGVsbG8gV29ybGQhIFRoaXMgaXMgYSB0ZXN0";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(base64);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsDeterministic()
|
||||
{
|
||||
var input = "TestString123!@#";
|
||||
|
||||
var entropy1 = EntropyCalculator.Calculate(input);
|
||||
var entropy2 = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy1.Should().Be(entropy2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0123456789", 3.32)]
|
||||
[InlineData("abcdefghij", 3.32)]
|
||||
[InlineData("ABCDEFGHIJ", 3.32)]
|
||||
public void Calculate_KnownPatterns_ReturnsExpectedEntropy(string input, double expectedEntropy)
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy.Should().BeApproximately(expectedEntropy, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PayloadMaskerTests
|
||||
{
|
||||
private readonly PayloadMasker _masker = new();
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptySpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<char>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ShortValue_ReturnsMaskChars()
|
||||
{
|
||||
// Values shorter than prefix+suffix get masked placeholder
|
||||
var result = _masker.Mask("abc".AsSpan());
|
||||
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_StandardValue_PreservesPrefixAndSuffix()
|
||||
{
|
||||
var result = _masker.Mask("1234567890".AsSpan());
|
||||
|
||||
// Default: 4 char prefix, 2 char suffix
|
||||
result.Should().StartWith("1234");
|
||||
result.Should().EndWith("90");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_AwsAccessKey_PreservesPrefix()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result = _masker.Mask(awsKey.AsSpan());
|
||||
|
||||
result.Should().StartWith("AKIA");
|
||||
result.Should().EndWith("LE");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_WithPrefixHint_UsesCustomPrefixLength()
|
||||
{
|
||||
var apiKey = "sk-proj-abcdefghijklmnop";
|
||||
|
||||
// MaxExposedChars is 6, so prefix:8 + suffix:2 gets scaled down
|
||||
var result = _masker.Mask(apiKey.AsSpan(), "prefix:4,suffix:2");
|
||||
|
||||
result.Should().StartWith("sk-p");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_LongValue_MasksMiddle()
|
||||
{
|
||||
var longSecret = "verylongsecretthatexceeds100characters" +
|
||||
"andshouldbemaskkedproperlywithoutexpo" +
|
||||
"singtheentirecontentstoanyoneviewingit";
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Should contain mask characters and be shorter than original
|
||||
result.Should().Contain("****");
|
||||
result.Length.Should().BeLessThan(longSecret.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_IsDeterministic()
|
||||
{
|
||||
var secret = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan());
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_NeverExposesFullSecret()
|
||||
{
|
||||
var secret = "supersecretkey123";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().NotBe(secret);
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("prefix:6,suffix:0")]
|
||||
[InlineData("prefix:0,suffix:6")]
|
||||
[InlineData("prefix:3,suffix:3")]
|
||||
public void Mask_WithVariousHints_RespectsTotalLimit(string hint)
|
||||
{
|
||||
var secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan(), hint);
|
||||
|
||||
var visibleChars = result.Replace("*", "").Length;
|
||||
visibleChars.Should().BeLessThanOrEqualTo(PayloadMasker.MaxExposedChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EnforcesMinOutputLength()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Length.Should().BeGreaterThanOrEqualTo(PayloadMasker.MinOutputLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ByteOverload_DecodesUtf8()
|
||||
{
|
||||
var text = "secretpassword123";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
|
||||
var result = _masker.Mask(bytes.AsSpan());
|
||||
|
||||
result.Should().Contain("****");
|
||||
result.Should().StartWith("secr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptyByteSpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<byte>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_InvalidHint_UsesDefaults()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan(), "invalid:hint:format");
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_UsesCorrectMaskChar()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().Contain(PayloadMasker.MaskChar.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_MaskLengthLimited()
|
||||
{
|
||||
var longSecret = new string('x', 100);
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Count mask characters
|
||||
var maskCount = result.Count(c => c == PayloadMasker.MaskChar);
|
||||
maskCount.Should().BeLessThanOrEqualTo(PayloadMasker.MaxMaskLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegexDetectorTests
|
||||
{
|
||||
private readonly RegexDetector _detector = new(NullLogger<RegexDetector>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void DetectorId_ReturnsRegex()
|
||||
{
|
||||
_detector.DetectorId.Should().Be("regex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_RegexType_ReturnsTrue()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Regex).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_EntropyType_ReturnsFalse()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Entropy).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_CompositeType_ReturnsTrue()
|
||||
{
|
||||
// RegexDetector handles both Regex and Composite types
|
||||
_detector.CanHandle(SecretRuleType.Composite).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("no aws key here");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_SingleMatch_ReturnsOne()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("aws_key = AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].Rule.Id.Should().Be("test-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_MultipleMatches_ReturnsAll()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"key1 = AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"key2 = AKIABCDEFGHIJKLMNOP1");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectLineNumber()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key\s*=\s*\S+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"# config file\n" +
|
||||
"debug = true\n" +
|
||||
"secret_key = mysecretvalue\n" +
|
||||
"port = 8080");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"config.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectColumn()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key");
|
||||
var content = Encoding.UTF8.GetBytes("config: secret_key = value");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
// "secret_key" starts at index 8 (0-based), column 9 (1-based)
|
||||
matches[0].ColumnStart.Should().Be(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_HandlesMultilineContent()
|
||||
{
|
||||
var rule = CreateRule(@"API_KEY\s*=\s*\w+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"line1\n" +
|
||||
"line2\n" +
|
||||
"API_KEY = abc123\n" +
|
||||
"line4\n" +
|
||||
"API_KEY = xyz789");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
matches[1].LineNumber.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_DisabledRule_StillProcesses()
|
||||
{
|
||||
// Note: The detector doesn't filter by Enabled status.
|
||||
// Filtering disabled rules is the caller's responsibility (e.g., SecretsAnalyzer)
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}", enabled: false);
|
||||
var content = Encoding.UTF8.GetBytes("AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Detector processes regardless of Enabled flag
|
||||
matches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_RespectsCancellation()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
var content = Encoding.UTF8.GetBytes("test content");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
// When cancellation is already requested, detector returns empty (doesn't throw)
|
||||
var matches = await _detector.DetectAsync(content.AsMemory(), "test.txt", rule, cts.Token);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_IncludesFilePath()
|
||||
{
|
||||
var rule = CreateRule(@"secret");
|
||||
var content = Encoding.UTF8.GetBytes("mysecret");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"path/to/file.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].FilePath.Should().Be("path/to/file.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_LargeFile_HandlesEfficiently()
|
||||
{
|
||||
var rule = CreateRule(@"SECRET_KEY");
|
||||
var lines = Enumerable.Range(0, 10000)
|
||||
.Select(i => i == 5000 ? "SECRET_KEY = value" : $"line {i}")
|
||||
.ToArray();
|
||||
var content = Encoding.UTF8.GetBytes(string.Join("\n", lines));
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"large.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(5001);
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern, bool enabled = true)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule for unit tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RulesetLoaderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RulesetLoader _loader;
|
||||
|
||||
public RulesetLoaderTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"secrets-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_loader = new RulesetLoader(NullLogger<RulesetLoader>.Instance, _timeProvider);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidBundle_LoadsRuleset()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-secrets");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingDirectory_ThrowsDirectoryNotFound()
|
||||
{
|
||||
var nonExistentPath = Path.Combine(_testDir, "does-not-exist");
|
||||
|
||||
await Assert.ThrowsAsync<DirectoryNotFoundException>(
|
||||
() => _loader.LoadAsync(nonExistentPath, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingManifest_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
"""{"id":"rule1","pattern":"test"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingRulesFile_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
"""{"id":"test","version":"1.0.0"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_InvalidIntegrity_ThrowsException()
|
||||
{
|
||||
await CreateBundleWithBadIntegrityAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SortsRulesById()
|
||||
{
|
||||
await CreateBundleWithUnorderedRulesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Select(r => r.Id).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsBlankLines()
|
||||
{
|
||||
await CreateBundleWithBlankLinesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidJsonLines()
|
||||
{
|
||||
await CreateBundleWithInvalidJsonAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
// JSONL processes each line independently - invalid lines are skipped but don't stop processing
|
||||
// So we get rule1 and rule2 (2 rules), with the invalid line skipped
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SetsCreatedAt()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ValidStream_LoadsRules()
|
||||
{
|
||||
var jsonl = """
|
||||
{"id":"rule1","version":"1.0","name":"Rule 1","type":"regex","pattern":"secret","severity":"high","confidence":"high"}
|
||||
{"id":"rule2","version":"1.0","name":"Rule 2","type":"regex","pattern":"password","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-bundle");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_DefaultValues_AppliedCorrectly()
|
||||
{
|
||||
var jsonl = """{"id":"minimal-rule","pattern":"test"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var rule = ruleset.Rules[0];
|
||||
rule.Version.Should().Be("1.0.0");
|
||||
rule.Enabled.Should().BeTrue();
|
||||
rule.Severity.Should().Be(SecretSeverity.Medium);
|
||||
rule.Confidence.Should().Be(SecretConfidence.Medium);
|
||||
rule.Type.Should().Be(SecretRuleType.Regex);
|
||||
rule.EntropyThreshold.Should().Be(4.5);
|
||||
rule.MinLength.Should().Be(16);
|
||||
rule.MaxLength.Should().Be(1000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("regex", SecretRuleType.Regex)]
|
||||
[InlineData("entropy", SecretRuleType.Entropy)]
|
||||
[InlineData("composite", SecretRuleType.Composite)]
|
||||
[InlineData("REGEX", SecretRuleType.Regex)]
|
||||
[InlineData("unknown", SecretRuleType.Regex)]
|
||||
public async Task LoadFromJsonlAsync_ParsesRuleType(string typeString, SecretRuleType expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","type":"{{{typeString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Type.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("low", SecretSeverity.Low)]
|
||||
[InlineData("medium", SecretSeverity.Medium)]
|
||||
[InlineData("high", SecretSeverity.High)]
|
||||
[InlineData("critical", SecretSeverity.Critical)]
|
||||
[InlineData("HIGH", SecretSeverity.High)]
|
||||
public async Task LoadFromJsonlAsync_ParsesSeverity(string severityString, SecretSeverity expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","severity":"{{{severityString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Severity.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesKeywords()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","keywords":["aws","key","secret"]}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Keywords.Should().BeEquivalentTo(["aws", "key", "secret"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesMetadata()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","metadata":{"source":"gitleaks","category":"api-key"}}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Metadata.Should().Contain("source", "gitleaks");
|
||||
ruleset.Rules[0].Metadata.Should().Contain("category", "api-key");
|
||||
}
|
||||
|
||||
private async Task CreateValidBundleAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"aws-key","version":"1.0","name":"AWS Access Key","type":"regex","pattern":"AKIA[0-9A-Z]{16}","severity":"critical","confidence":"high"}
|
||||
{"id":"generic-secret","version":"1.0","name":"Generic Secret","type":"regex","pattern":"secret[_-]?key","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
var hash = await ComputeHashAsync(rulesPath, ct);
|
||||
var manifest = $$$"""{"id":"test-secrets","version":"1.0.0","integrity":{"rulesSha256":"{{{hash}}}"}}""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBadIntegrityAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """{"id":"rule1","pattern":"test"}""";
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
// Use a known bad hash (clearly different from any real SHA-256)
|
||||
const string badHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var manifest = $$$"""{"id":"test","version":"1.0","integrity":{"rulesSha256":"{{{badHash}}}"}}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithUnorderedRulesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"z-rule","pattern":"z"}
|
||||
{"id":"a-rule","pattern":"a"}
|
||||
{"id":"m-rule","pattern":"m"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBlankLinesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"test1"}
|
||||
|
||||
{"id":"rule2","pattern":"test2"}
|
||||
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithInvalidJsonAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"valid"}
|
||||
not valid json at all
|
||||
{"id":"rule2","pattern":"also valid but will be skipped due to earlier error?"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCompiledPattern_ValidRegex_ReturnsRegex()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().NotBeNull();
|
||||
regex!.IsMatch("AKIAIOSFODNN7EXAMPLE").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_InvalidRegex_ReturnsNull()
|
||||
{
|
||||
var rule = CreateRule(@"[invalid(regex");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_IsCached()
|
||||
{
|
||||
var rule = CreateRule(@"test\d+");
|
||||
|
||||
var regex1 = rule.GetCompiledPattern();
|
||||
var regex2 = rule.GetCompiledPattern();
|
||||
|
||||
regex1.Should().BeSameAs(regex2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_EntropyType_ReturnsNull()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "entropy-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Rule",
|
||||
Description = "Test",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = string.Empty,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 4.5,
|
||||
MinLength = 16,
|
||||
MaxLength = 100,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_NoPatterns_MatchesAll()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.AppliesToFile("any/path/file.txt").Should().BeTrue();
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("secrets.yaml").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_WithExtensionPattern_FiltersByExtension()
|
||||
{
|
||||
var rule = CreateRuleWithFilePatterns(@"test", "*.json", "*.yaml");
|
||||
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("config.yaml").Should().BeTrue();
|
||||
rule.AppliesToFile("config.xml").Should().BeFalse();
|
||||
rule.AppliesToFile("config.txt").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_NoKeywords_ReturnsTrue()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.MightMatch("any content here".AsSpan()).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_WithKeywords_MatchesIfKeywordFound()
|
||||
{
|
||||
var rule = CreateRuleWithKeywords(@"test", "secret", "password");
|
||||
|
||||
rule.MightMatch("contains secret here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("contains password here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("no matching content".AsSpan()).Should().BeFalse();
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithFilePatterns(string pattern, params string[] filePatterns)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [..filePatterns],
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithKeywords(string pattern, params string[] keywords)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = [..keywords],
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRulesetTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnabledRuleCount_ReturnsCorrectCount()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllDisabled_ReturnsZero()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: false),
|
||||
CreateRule("rule2", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllEnabled_ReturnsTotal()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_ReturnsEnabledMatchingRules()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true),
|
||||
CreateRuleWithPattern("yaml-rule", "*.yaml", enabled: true),
|
||||
CreateRuleWithPattern("disabled-rule", "*.json", enabled: false));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.json").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("json-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_NoMatchingPatterns_ReturnsRulesWithNoPatterns()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("generic-rule", enabled: true),
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.xml").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("generic-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRuleset_ReturnsEmpty()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateIds_ReturnsError()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("same-id", enabled: true),
|
||||
CreateRule("same-id", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("Duplicate", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_ReturnsError()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "bad-regex",
|
||||
Version = "1.0.0",
|
||||
Name = "Bad Regex",
|
||||
Description = "Invalid regex pattern",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[invalid(regex",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var ruleset = new SecretRuleset
|
||||
{
|
||||
Id = "test",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [rule]
|
||||
};
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("bad-regex", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.Contains("invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRules_ReturnsOnlyEnabled()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: false),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
var enabled = ruleset.EnabledRules.ToList();
|
||||
|
||||
enabled.Should().HaveCount(2);
|
||||
enabled.Select(r => r.Id).Should().BeEquivalentTo(["rule1", "rule3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsEmptyRuleset()
|
||||
{
|
||||
var empty = SecretRuleset.Empty;
|
||||
|
||||
empty.Id.Should().Be("empty");
|
||||
empty.Version.Should().Be("0.0");
|
||||
empty.Rules.Should().BeEmpty();
|
||||
empty.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static SecretRuleset CreateRuleset(params SecretRule[] rules)
|
||||
{
|
||||
return new SecretRuleset
|
||||
{
|
||||
Id = "test-ruleset",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [..rules]
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string id, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithPattern(string id, string filePattern, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [filePattern],
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures/**/*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,469 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphDigestsTests.cs
|
||||
// Sprint: SPRINT_20260104_001_CLI
|
||||
// Description: Unit tests for call graph digest computation and determinism.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class CallGraphDigestsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
Assert.Equal(71, digest.Length); // "sha256:" (7) + 64 hex chars
|
||||
Assert.True(IsValidHex(digest[7..]), "Digest should be valid hex string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest3 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.Equal(digest2, digest3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EquivalentSnapshotsProduceSameDigest()
|
||||
{
|
||||
// Arrange - two separately created but equivalent snapshots
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow.AddMinutes(5), // Different timestamp
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because ExtractedAt is not part of the digest payload
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_DifferentNodesProduceDifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_NodeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - nodes in different order
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts nodes
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - edges in different order
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null),
|
||||
new CallGraphNode("node-c", "func_c", "test.c", 30, "pkg", Visibility.Internal, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts edges
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_WhitespaceIsTrimmed()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: " test-scan ",
|
||||
GraphDigest: "",
|
||||
Language: " native ",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(" node-a ", " func_a ", " test.c ", 10, " pkg ", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() trims whitespace
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ThrowsOnNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => CallGraphDigests.ComputeGraphDigest(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_HandlesEmptySnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_LanguageAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeExplanationAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, null)
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, CallEdgeExplanation.DirectCall())
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:main";
|
||||
|
||||
// Act
|
||||
var nodeId = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(nodeId);
|
||||
Assert.StartsWith("sha256:", nodeId, StringComparison.Ordinal);
|
||||
Assert.Equal(71, nodeId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:SSL_read";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId);
|
||||
var id3 = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id1, id2);
|
||||
Assert.Equal(id2, id3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_DifferentSymbolsProduceDifferentIds()
|
||||
{
|
||||
// Arrange
|
||||
var stableId1 = "native:func_a";
|
||||
var stableId2 = "native:func_b";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId1);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_CreatesConsistentFormat()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId("Native", "SSL_read");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_TrimsWhitespace()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId(" Native ", " SSL_read ");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMinimalSnapshot()
|
||||
{
|
||||
return new CallGraphSnapshot(
|
||||
ScanId: "test-scan-001",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "sha256:abc123",
|
||||
Symbol: "main",
|
||||
File: "main.c",
|
||||
Line: 1,
|
||||
Package: "test-binary",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.CliCommand,
|
||||
IsSink: false,
|
||||
SinkCategory: null
|
||||
)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray.Create("sha256:abc123"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsValidHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
return false;
|
||||
|
||||
foreach (char c in hex)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user