feat(secrets): Implement secret leak policies and signal binding
- Added `spl-secret-block@1.json` to block deployments with critical or high severity secret findings. - Introduced `spl-secret-warn@1.json` to warn on secret findings without blocking deployments. - Created `SecretSignalBinder.cs` to bind secret evidence to policy evaluation signals. - Developed unit tests for `SecretEvidenceContext` and `SecretSignalBinder` to ensure correct functionality. - Enhanced `SecretSignalContextExtensions` to integrate secret evidence into signal contexts.
This commit is contained in:
106
src/Policy/StellaOps.PolicyDsl/SecretSignalContextExtensions.cs
Normal file
106
src/Policy/StellaOps.PolicyDsl/SecretSignalContextExtensions.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretSignalContextExtensions.cs
|
||||
// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration)
|
||||
// Task: PSD-008 - Register predicates in PolicyDslRegistry (via signal context)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Secrets;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for integrating secret evidence with PolicyDsl SignalContext.
|
||||
/// </summary>
|
||||
public static class SecretSignalContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds secret evidence signals to the signal context.
|
||||
/// </summary>
|
||||
/// <param name="context">The signal context.</param>
|
||||
/// <param name="evidenceContext">The secret evidence context.</param>
|
||||
/// <returns>The signal context for chaining.</returns>
|
||||
public static SignalContext WithSecretEvidence(
|
||||
this SignalContext context,
|
||||
SecretEvidenceContext evidenceContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(evidenceContext);
|
||||
|
||||
// Add flat signals
|
||||
var signals = SecretSignalBinder.BindToSignals(evidenceContext);
|
||||
foreach (var (name, value) in signals)
|
||||
{
|
||||
context.SetSignal(name, value);
|
||||
}
|
||||
|
||||
// Add nested object for member access (secret.severity.high, etc.)
|
||||
var nested = SecretSignalBinder.BindToNestedObject(evidenceContext);
|
||||
context.SetSignal("secret", nested);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds secret evidence signals to the signal context builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The signal context builder.</param>
|
||||
/// <param name="evidenceContext">The secret evidence context.</param>
|
||||
/// <returns>The builder for chaining.</returns>
|
||||
public static SignalContextBuilder WithSecretEvidence(
|
||||
this SignalContextBuilder builder,
|
||||
SecretEvidenceContext evidenceContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(evidenceContext);
|
||||
|
||||
// Add flat signals
|
||||
var signals = SecretSignalBinder.BindToSignals(evidenceContext);
|
||||
foreach (var (name, value) in signals)
|
||||
{
|
||||
builder.WithSignal(name, value);
|
||||
}
|
||||
|
||||
// Add nested object for member access
|
||||
var nested = SecretSignalBinder.BindToNestedObject(evidenceContext);
|
||||
builder.WithSignal("secret", nested);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds secret evidence signals from a provider.
|
||||
/// </summary>
|
||||
/// <param name="builder">The signal context builder.</param>
|
||||
/// <param name="provider">The secret evidence provider.</param>
|
||||
/// <returns>The builder for chaining.</returns>
|
||||
public static SignalContextBuilder WithSecretEvidence(
|
||||
this SignalContextBuilder builder,
|
||||
ISecretEvidenceProvider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
return builder.WithSecretEvidence(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signal context builder with secret evidence.
|
||||
/// </summary>
|
||||
/// <param name="evidenceContext">The secret evidence context.</param>
|
||||
/// <returns>A new builder with secret signals.</returns>
|
||||
public static SignalContextBuilder CreateBuilderWithSecrets(SecretEvidenceContext evidenceContext)
|
||||
{
|
||||
return SignalContext.Builder().WithSecretEvidence(evidenceContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signal context with secret evidence.
|
||||
/// </summary>
|
||||
/// <param name="evidenceContext">The secret evidence context.</param>
|
||||
/// <returns>A new signal context with secret signals.</returns>
|
||||
public static SignalContext CreateContextWithSecrets(SecretEvidenceContext evidenceContext)
|
||||
{
|
||||
return CreateBuilderWithSecrets(evidenceContext).Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stellaops.io/policy/signals-schema@1.json",
|
||||
"title": "StellaOps Policy Signals v1",
|
||||
"description": "Defines available signals for policy condition evaluation",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"signalName": {
|
||||
"type": "string",
|
||||
"description": "A signal name for policy condition evaluation",
|
||||
"pattern": "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$",
|
||||
"examples": [
|
||||
"secret.has_finding",
|
||||
"secret.severity.critical",
|
||||
"secret.bundle.version",
|
||||
"reachability.state",
|
||||
"finding.severity"
|
||||
]
|
||||
},
|
||||
"secretSignals": {
|
||||
"type": "object",
|
||||
"description": "Secret detection related signals",
|
||||
"properties": {
|
||||
"secret.has_finding": {
|
||||
"type": "boolean",
|
||||
"description": "True if any secret finding exists"
|
||||
},
|
||||
"secret.count": {
|
||||
"type": "integer",
|
||||
"description": "Total number of secret findings",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret.severity.critical": {
|
||||
"type": "boolean",
|
||||
"description": "True if any critical severity secret finding exists"
|
||||
},
|
||||
"secret.severity.high": {
|
||||
"type": "boolean",
|
||||
"description": "True if any high severity secret finding exists"
|
||||
},
|
||||
"secret.severity.medium": {
|
||||
"type": "boolean",
|
||||
"description": "True if any medium severity secret finding exists"
|
||||
},
|
||||
"secret.severity.low": {
|
||||
"type": "boolean",
|
||||
"description": "True if any low severity secret finding exists"
|
||||
},
|
||||
"secret.confidence.high": {
|
||||
"type": "boolean",
|
||||
"description": "True if any high confidence secret finding exists"
|
||||
},
|
||||
"secret.confidence.medium": {
|
||||
"type": "boolean",
|
||||
"description": "True if any medium confidence secret finding exists"
|
||||
},
|
||||
"secret.confidence.low": {
|
||||
"type": "boolean",
|
||||
"description": "True if any low confidence secret finding exists"
|
||||
},
|
||||
"secret.mask.applied": {
|
||||
"type": "boolean",
|
||||
"description": "True if masking was applied to all findings"
|
||||
},
|
||||
"secret.bundle.version": {
|
||||
"type": "string",
|
||||
"description": "The active secret detection bundle version (YYYY.MM format)",
|
||||
"pattern": "^\\d{4}\\.\\d{2}$"
|
||||
},
|
||||
"secret.bundle.id": {
|
||||
"type": "string",
|
||||
"description": "The active bundle identifier"
|
||||
},
|
||||
"secret.bundle.rule_count": {
|
||||
"type": "integer",
|
||||
"description": "Number of rules in the active bundle",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret.bundle.signer_key_id": {
|
||||
"type": "string",
|
||||
"description": "Key ID used to sign the bundle"
|
||||
},
|
||||
"secret.aws.count": {
|
||||
"type": "integer",
|
||||
"description": "Count of AWS-related secret findings",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret.github.count": {
|
||||
"type": "integer",
|
||||
"description": "Count of GitHub-related secret findings",
|
||||
"minimum": 0
|
||||
},
|
||||
"secret.private_key.count": {
|
||||
"type": "integer",
|
||||
"description": "Count of private key findings",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"findingSignals": {
|
||||
"type": "object",
|
||||
"description": "Vulnerability finding related signals",
|
||||
"properties": {
|
||||
"finding.severity": {
|
||||
"type": "string",
|
||||
"description": "Finding severity level",
|
||||
"enum": ["critical", "high", "medium", "low", "unknown"]
|
||||
},
|
||||
"finding.confidence": {
|
||||
"type": "number",
|
||||
"description": "Finding confidence score",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"finding.cve_id": {
|
||||
"type": "string",
|
||||
"description": "CVE identifier if applicable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reachabilitySignals": {
|
||||
"type": "object",
|
||||
"description": "Reachability analysis signals",
|
||||
"properties": {
|
||||
"reachability.state": {
|
||||
"type": "string",
|
||||
"description": "Reachability state",
|
||||
"enum": ["reachable", "unreachable", "unknown"]
|
||||
},
|
||||
"reachability.confidence": {
|
||||
"type": "number",
|
||||
"description": "Reachability confidence score",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"reachability.has_runtime_evidence": {
|
||||
"type": "boolean",
|
||||
"description": "True if runtime evidence exists for reachability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trustSignals": {
|
||||
"type": "object",
|
||||
"description": "Trust and verification signals",
|
||||
"properties": {
|
||||
"trust_score": {
|
||||
"type": "number",
|
||||
"description": "Trust score",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"trust_verified": {
|
||||
"type": "boolean",
|
||||
"description": "True if the source is verified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Conditions evaluated against policy signals. See signals-schema@1.json for available signals.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -138,7 +139,18 @@
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
"maxLength": 256,
|
||||
"description": "Signal name to evaluate. Common signals: secret.has_finding, secret.severity.critical, secret.count, secret.bundle.version, reachability.state, finding.severity",
|
||||
"examples": [
|
||||
"secret.has_finding",
|
||||
"secret.severity.critical",
|
||||
"secret.severity.high",
|
||||
"secret.count",
|
||||
"secret.mask.applied",
|
||||
"secret.bundle.version",
|
||||
"reachability.state",
|
||||
"finding.severity"
|
||||
]
|
||||
},
|
||||
"operator": {
|
||||
"type": "string",
|
||||
@@ -153,8 +165,11 @@
|
||||
"nin",
|
||||
"contains",
|
||||
"startsWith",
|
||||
"endsWith"
|
||||
]
|
||||
"endsWith",
|
||||
"matches",
|
||||
"exists"
|
||||
],
|
||||
"description": "Comparison operator. 'matches' uses glob patterns, 'exists' checks signal presence."
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"apiVersion": "spl.stellaops/v1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "secret-leak-block",
|
||||
"description": "Block deployments with critical or high severity secret findings",
|
||||
"labels": {
|
||||
"category": "security",
|
||||
"domain": "secrets"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"defaultEffect": "allow",
|
||||
"statements": [
|
||||
{
|
||||
"id": "block-critical-secrets",
|
||||
"effect": "deny",
|
||||
"description": "Block any critical severity secret findings",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["deploy", "release"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.severity.critical",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Blocked: Critical severity secret leak detected",
|
||||
"severity": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-high-secrets-unmasked",
|
||||
"effect": "deny",
|
||||
"description": "Block high severity secrets that are not properly masked",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["deploy", "release"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.severity.high",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"field": "secret.mask.applied",
|
||||
"operator": "eq",
|
||||
"value": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Blocked: High severity secret without masking",
|
||||
"severity": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "require-current-bundle",
|
||||
"effect": "deny",
|
||||
"description": "Block scans using outdated detection bundles",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["deploy"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.bundle.version",
|
||||
"operator": "lt",
|
||||
"value": "2025.01"
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Blocked: Secret detection bundle is outdated",
|
||||
"severity": "warn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"apiVersion": "spl.stellaops/v1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "secret-leak-warn",
|
||||
"description": "Warn on secret findings without blocking deployments",
|
||||
"labels": {
|
||||
"category": "security",
|
||||
"domain": "secrets",
|
||||
"mode": "advisory"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"defaultEffect": "allow",
|
||||
"statements": [
|
||||
{
|
||||
"id": "warn-any-secrets",
|
||||
"effect": "allow",
|
||||
"description": "Log warning for any secret findings",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["scan", "deploy", "release"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.has_finding",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Warning: Secret findings detected in scan",
|
||||
"severity": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "warn-aws-credentials",
|
||||
"effect": "allow",
|
||||
"description": "Special warning for AWS credential exposure",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["scan", "deploy"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.aws.count",
|
||||
"operator": "gt",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Warning: AWS credentials detected - consider rotating",
|
||||
"severity": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "warn-github-tokens",
|
||||
"effect": "allow",
|
||||
"description": "Special warning for GitHub token exposure",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["scan", "deploy"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.github.count",
|
||||
"operator": "gt",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Warning: GitHub tokens detected - consider rotating",
|
||||
"severity": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "warn-private-keys",
|
||||
"effect": "allow",
|
||||
"description": "Special warning for private key exposure",
|
||||
"match": {
|
||||
"resource": "*",
|
||||
"actions": ["scan", "deploy"],
|
||||
"conditions": [
|
||||
{
|
||||
"field": "secret.private_key.count",
|
||||
"operator": "gt",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "Warning: Private keys detected - review exposure",
|
||||
"severity": "warn"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretSignalBinder.cs
|
||||
// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration)
|
||||
// Tasks: PSD-003 through PSD-007 - Add secret.* predicates as signals
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Binds secret evidence to policy evaluation signals.
|
||||
/// This class converts secret findings and bundle metadata into signals that can be
|
||||
/// evaluated by the PolicyDsl SignalContext.
|
||||
///
|
||||
/// <para>
|
||||
/// Available signals after binding:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>secret.has_finding</c> - true if any secret finding exists</item>
|
||||
/// <item><c>secret.count</c> - total number of findings</item>
|
||||
/// <item><c>secret.severity.critical</c> - true if any critical finding exists</item>
|
||||
/// <item><c>secret.severity.high</c> - true if any high severity finding exists</item>
|
||||
/// <item><c>secret.severity.medium</c> - true if any medium severity finding exists</item>
|
||||
/// <item><c>secret.severity.low</c> - true if any low severity finding exists</item>
|
||||
/// <item><c>secret.confidence.high</c> - true if any high confidence finding exists</item>
|
||||
/// <item><c>secret.confidence.medium</c> - true if any medium confidence finding exists</item>
|
||||
/// <item><c>secret.confidence.low</c> - true if any low confidence finding exists</item>
|
||||
/// <item><c>secret.mask.applied</c> - true if masking was applied to all findings</item>
|
||||
/// <item><c>secret.bundle.version</c> - the active bundle version string</item>
|
||||
/// <item><c>secret.bundle.id</c> - the active bundle ID</item>
|
||||
/// <item><c>secret.bundle.rule_count</c> - the number of rules in the bundle</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SecretSignalBinder
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name prefix for all secret-related signals.
|
||||
/// </summary>
|
||||
public const string SignalPrefix = "secret";
|
||||
|
||||
/// <summary>
|
||||
/// Binds secret evidence to a dictionary of signals.
|
||||
/// </summary>
|
||||
/// <param name="context">The secret evidence context.</param>
|
||||
/// <returns>A dictionary of signal names to values.</returns>
|
||||
public static ImmutableDictionary<string, object?> BindToSignals(SecretEvidenceContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var signals = ImmutableDictionary.CreateBuilder<string, object?>(StringComparer.Ordinal);
|
||||
|
||||
// Core finding signals
|
||||
signals[$"{SignalPrefix}.has_finding"] = context.HasAnyFinding;
|
||||
signals[$"{SignalPrefix}.count"] = context.FindingCount;
|
||||
|
||||
// Severity signals
|
||||
signals[$"{SignalPrefix}.severity.critical"] = context.HasFindingWithSeverity("critical");
|
||||
signals[$"{SignalPrefix}.severity.high"] = context.HasFindingWithSeverity("high");
|
||||
signals[$"{SignalPrefix}.severity.medium"] = context.HasFindingWithSeverity("medium");
|
||||
signals[$"{SignalPrefix}.severity.low"] = context.HasFindingWithSeverity("low");
|
||||
|
||||
// Confidence signals
|
||||
signals[$"{SignalPrefix}.confidence.high"] = context.HasFindingWithConfidence("high");
|
||||
signals[$"{SignalPrefix}.confidence.medium"] = context.HasFindingWithConfidence("medium");
|
||||
signals[$"{SignalPrefix}.confidence.low"] = context.HasFindingWithConfidence("low");
|
||||
|
||||
// Masking signal
|
||||
signals[$"{SignalPrefix}.mask.applied"] = context.MaskingApplied;
|
||||
|
||||
// Bundle signals
|
||||
var bundle = context.Bundle;
|
||||
if (bundle is not null)
|
||||
{
|
||||
signals[$"{SignalPrefix}.bundle.version"] = bundle.Version;
|
||||
signals[$"{SignalPrefix}.bundle.id"] = bundle.BundleId;
|
||||
signals[$"{SignalPrefix}.bundle.rule_count"] = bundle.RuleCount;
|
||||
signals[$"{SignalPrefix}.bundle.signer_key_id"] = bundle.SignerKeyId;
|
||||
}
|
||||
else
|
||||
{
|
||||
signals[$"{SignalPrefix}.bundle.version"] = null;
|
||||
signals[$"{SignalPrefix}.bundle.id"] = null;
|
||||
signals[$"{SignalPrefix}.bundle.rule_count"] = 0;
|
||||
signals[$"{SignalPrefix}.bundle.signer_key_id"] = null;
|
||||
}
|
||||
|
||||
// Rule-specific counts (for common rule patterns)
|
||||
signals[$"{SignalPrefix}.aws.count"] = context.GetMatchCount("stellaops.secrets.aws-*");
|
||||
signals[$"{SignalPrefix}.github.count"] = context.GetMatchCount("stellaops.secrets.github-*");
|
||||
signals[$"{SignalPrefix}.private_key.count"] = context.GetMatchCount("stellaops.secrets.private-key-*");
|
||||
|
||||
return signals.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds secret evidence to a nested object suitable for member access in policies.
|
||||
/// This creates a hierarchical structure like:
|
||||
/// secret.severity.high, secret.bundle.version, etc.
|
||||
/// </summary>
|
||||
/// <param name="context">The secret evidence context.</param>
|
||||
/// <returns>A nested dictionary structure.</returns>
|
||||
public static ImmutableDictionary<string, object?> BindToNestedObject(SecretEvidenceContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var severity = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["critical"] = context.HasFindingWithSeverity("critical"),
|
||||
["high"] = context.HasFindingWithSeverity("high"),
|
||||
["medium"] = context.HasFindingWithSeverity("medium"),
|
||||
["low"] = context.HasFindingWithSeverity("low"),
|
||||
};
|
||||
|
||||
var confidence = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["high"] = context.HasFindingWithConfidence("high"),
|
||||
["medium"] = context.HasFindingWithConfidence("medium"),
|
||||
["low"] = context.HasFindingWithConfidence("low"),
|
||||
};
|
||||
|
||||
var mask = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["applied"] = context.MaskingApplied,
|
||||
};
|
||||
|
||||
var bundle = context.Bundle;
|
||||
var bundleDict = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["version"] = bundle?.Version,
|
||||
["id"] = bundle?.BundleId,
|
||||
["rule_count"] = bundle?.RuleCount ?? 0,
|
||||
["signer_key_id"] = bundle?.SignerKeyId,
|
||||
};
|
||||
|
||||
var match = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["count"] = context.FindingCount,
|
||||
["aws_count"] = context.GetMatchCount("stellaops.secrets.aws-*"),
|
||||
["github_count"] = context.GetMatchCount("stellaops.secrets.github-*"),
|
||||
["private_key_count"] = context.GetMatchCount("stellaops.secrets.private-key-*"),
|
||||
};
|
||||
|
||||
return new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["has_finding"] = context.HasAnyFinding,
|
||||
["count"] = context.FindingCount,
|
||||
["severity"] = severity,
|
||||
["confidence"] = confidence,
|
||||
["mask"] = mask,
|
||||
["bundle"] = bundleDict,
|
||||
["match"] = match,
|
||||
}.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the bundle version meets a required minimum version.
|
||||
/// </summary>
|
||||
/// <param name="context">The secret evidence context.</param>
|
||||
/// <param name="requiredVersion">The minimum required version (YYYY.MM format).</param>
|
||||
/// <returns>True if the bundle meets the version requirement.</returns>
|
||||
public static bool CheckBundleVersion(SecretEvidenceContext context, string requiredVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(requiredVersion);
|
||||
|
||||
return context.BundleVersionMeetsRequirement(requiredVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all findings are in paths matching the allowlist.
|
||||
/// </summary>
|
||||
/// <param name="context">The secret evidence context.</param>
|
||||
/// <param name="patterns">Glob patterns for allowed paths.</param>
|
||||
/// <returns>True if all findings are in allowed paths.</returns>
|
||||
public static bool CheckPathAllowlist(SecretEvidenceContext context, IReadOnlyList<string> patterns)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(patterns);
|
||||
|
||||
return context.AllFindingsInAllowlist(patterns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates finding summary for policy explanation.
|
||||
/// </summary>
|
||||
/// <param name="context">The secret evidence context.</param>
|
||||
/// <returns>A summary string for audit/explanation purposes.</returns>
|
||||
public static string CreateFindingSummary(SecretEvidenceContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!context.HasAnyFinding)
|
||||
{
|
||||
return "No secret findings detected.";
|
||||
}
|
||||
|
||||
var findings = context.Findings;
|
||||
var severityCounts = findings
|
||||
.GroupBy(f => f.Severity, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var parts = new List<string>();
|
||||
if (severityCounts.TryGetValue("critical", out var critical) && critical > 0)
|
||||
{
|
||||
parts.Add($"{critical} critical");
|
||||
}
|
||||
if (severityCounts.TryGetValue("high", out var high) && high > 0)
|
||||
{
|
||||
parts.Add($"{high} high");
|
||||
}
|
||||
if (severityCounts.TryGetValue("medium", out var medium) && medium > 0)
|
||||
{
|
||||
parts.Add($"{medium} medium");
|
||||
}
|
||||
if (severityCounts.TryGetValue("low", out var low) && low > 0)
|
||||
{
|
||||
parts.Add($"{low} low");
|
||||
}
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} secret finding(s): {1}",
|
||||
findings.Count,
|
||||
string.Join(", ", parts));
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,11 @@
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\signals-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-secret-block@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-secret-warn@1.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretEvidenceContextTests.cs
|
||||
// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration)
|
||||
// Task: PSD-011 - Add unit and integration tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Secrets;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretEvidenceContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
var action = () => new SecretEvidenceContext(null!);
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFinding_NoFindings_ReturnsFalse()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasAnyFinding.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFinding_WithFindings_ReturnsTrue()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.test", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasAnyFinding.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingCount_ReturnsCorrectCount()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("stellaops.secrets.test1", "high"),
|
||||
CreateFinding("stellaops.secrets.test2", "medium"),
|
||||
CreateFinding("stellaops.secrets.test3", "low"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.FindingCount.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithSeverity_MatchingSeverity_ReturnsTrue()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.test", "critical");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithSeverity("critical").Should().BeTrue();
|
||||
context.HasFindingWithSeverity("CRITICAL").Should().BeTrue(); // Case insensitive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithSeverity_NoMatchingSeverity_ReturnsFalse()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.test", "low");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithSeverity("critical").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithConfidence_MatchingConfidence_ReturnsTrue()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.test", "high", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithConfidence("high").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithRuleId_ExactMatch_ReturnsTrue()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.aws-access-key", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithRuleId("stellaops.secrets.aws-access-key").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithRuleId_PatternMatch_ReturnsTrue()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.aws-access-key", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithRuleId("stellaops.secrets.aws-*").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasFindingWithRuleId_NoMatch_ReturnsFalse()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.github-token", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.HasFindingWithRuleId("stellaops.secrets.aws-*").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchCount_WithPattern_ReturnsCorrectCount()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("stellaops.secrets.aws-access-key", "high"),
|
||||
CreateFinding("stellaops.secrets.aws-secret-key", "high"),
|
||||
CreateFinding("stellaops.secrets.github-token", "medium"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.GetMatchCount("stellaops.secrets.aws-*").Should().Be(2);
|
||||
context.GetMatchCount("stellaops.secrets.github-*").Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleVersionMeetsRequirement_ValidVersion_ReturnsTrue()
|
||||
{
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2025.06", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.BundleVersionMeetsRequirement("2025.01").Should().BeTrue();
|
||||
context.BundleVersionMeetsRequirement("2025.06").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleVersionMeetsRequirement_OlderVersion_ReturnsFalse()
|
||||
{
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2024.12", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.BundleVersionMeetsRequirement("2025.01").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleVersionMeetsRequirement_NoBundle_ReturnsFalse()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.BundleVersionMeetsRequirement("2025.01").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskingApplied_ReturnsProviderValue()
|
||||
{
|
||||
var mock = new Mock<ISecretEvidenceProvider>();
|
||||
mock.Setup(p => p.GetFindings()).Returns([]);
|
||||
mock.Setup(p => p.IsMaskingApplied()).Returns(true);
|
||||
var context = new SecretEvidenceContext(mock.Object);
|
||||
|
||||
context.MaskingApplied.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFindingsInAllowlist_NoFindings_ReturnsTrue()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.AllFindingsInAllowlist(["**/test/**"]).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFindingsInAllowlist_AllMatch_ReturnsTrue()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high", "high", "test/data/secrets.txt"),
|
||||
CreateFinding("rule2", "high", "high", "test/fixtures/keys.txt"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.AllFindingsInAllowlist(["test/**"]).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFindingsInAllowlist_SomeNotMatch_ReturnsFalse()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high", "high", "test/data/secrets.txt"),
|
||||
CreateFinding("rule2", "high", "high", "src/app/config.json"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.AllFindingsInAllowlist(["test/**"]).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFindingsInAllowlist_DoubleStarPattern_MatchesNestedPaths()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high", "high", "a/b/c/d/test.txt"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
context.AllFindingsInAllowlist(["**/test.txt"]).Should().BeTrue();
|
||||
}
|
||||
|
||||
private static ISecretEvidenceProvider CreateMockProvider(
|
||||
SecretFinding[] findings,
|
||||
SecretBundleMetadata? bundle = null,
|
||||
bool maskingApplied = true)
|
||||
{
|
||||
var mock = new Mock<ISecretEvidenceProvider>();
|
||||
mock.Setup(p => p.GetFindings()).Returns(findings);
|
||||
mock.Setup(p => p.GetBundleMetadata()).Returns(bundle);
|
||||
mock.Setup(p => p.IsMaskingApplied()).Returns(maskingApplied);
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static SecretFinding CreateFinding(
|
||||
string ruleId,
|
||||
string severity,
|
||||
string confidence = "high",
|
||||
string filePath = "test/file.txt")
|
||||
{
|
||||
return new SecretFinding
|
||||
{
|
||||
RuleId = ruleId,
|
||||
RuleVersion = "1.0.0",
|
||||
Severity = severity,
|
||||
Confidence = confidence,
|
||||
FilePath = filePath,
|
||||
LineNumber = 10,
|
||||
Mask = "***REDACTED***",
|
||||
BundleId = "bundle-1",
|
||||
BundleVersion = "2025.01",
|
||||
DetectedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretSignalBinderTests.cs
|
||||
// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration)
|
||||
// Task: PSD-011 - Add unit and integration tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Secrets;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretSignalBinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BindToSignals_WithNullContext_ThrowsArgumentNullException()
|
||||
{
|
||||
var action = () => SecretSignalBinder.BindToSignals(null!);
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_NoFindings_ReturnsExpectedSignals()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.has_finding"].Should().Be(false);
|
||||
signals["secret.count"].Should().Be(0);
|
||||
signals["secret.severity.critical"].Should().Be(false);
|
||||
signals["secret.severity.high"].Should().Be(false);
|
||||
signals["secret.severity.medium"].Should().Be(false);
|
||||
signals["secret.severity.low"].Should().Be(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_WithCriticalFinding_SetsCriticalSignal()
|
||||
{
|
||||
var finding = CreateFinding("rule1", "critical");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.has_finding"].Should().Be(true);
|
||||
signals["secret.count"].Should().Be(1);
|
||||
signals["secret.severity.critical"].Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_WithMultipleSeverities_SetsAllMatchingSignals()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high"),
|
||||
CreateFinding("rule2", "medium"),
|
||||
CreateFinding("rule3", "high"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.has_finding"].Should().Be(true);
|
||||
signals["secret.count"].Should().Be(3);
|
||||
signals["secret.severity.critical"].Should().Be(false);
|
||||
signals["secret.severity.high"].Should().Be(true);
|
||||
signals["secret.severity.medium"].Should().Be(true);
|
||||
signals["secret.severity.low"].Should().Be(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_WithBundle_SetsBundleSignals()
|
||||
{
|
||||
var bundle = new SecretBundleMetadata(
|
||||
"stellaops-bundle-2025",
|
||||
"2025.06",
|
||||
DateTimeOffset.UtcNow,
|
||||
150,
|
||||
"key-001");
|
||||
var provider = CreateMockProvider([], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.bundle.version"].Should().Be("2025.06");
|
||||
signals["secret.bundle.id"].Should().Be("stellaops-bundle-2025");
|
||||
signals["secret.bundle.rule_count"].Should().Be(150);
|
||||
signals["secret.bundle.signer_key_id"].Should().Be("key-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_NoBundle_SetsBundleSignalsToDefaults()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.bundle.version"].Should().BeNull();
|
||||
signals["secret.bundle.id"].Should().BeNull();
|
||||
signals["secret.bundle.rule_count"].Should().Be(0);
|
||||
signals["secret.bundle.signer_key_id"].Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_WithConfidenceLevels_SetsConfidenceSignals()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high", "high"),
|
||||
CreateFinding("rule2", "high", "medium"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.confidence.high"].Should().Be(true);
|
||||
signals["secret.confidence.medium"].Should().Be(true);
|
||||
signals["secret.confidence.low"].Should().Be(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_WithMasking_SetsMaskSignal()
|
||||
{
|
||||
var provider = CreateMockProvider([], maskingApplied: true);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.mask.applied"].Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToSignals_SetsRuleSpecificCounts()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("stellaops.secrets.aws-access-key", "high"),
|
||||
CreateFinding("stellaops.secrets.aws-secret-key", "high"),
|
||||
CreateFinding("stellaops.secrets.github-token", "medium"),
|
||||
CreateFinding("stellaops.secrets.private-key-rsa", "critical"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var signals = SecretSignalBinder.BindToSignals(context);
|
||||
|
||||
signals["secret.aws.count"].Should().Be(2);
|
||||
signals["secret.github.count"].Should().Be(1);
|
||||
signals["secret.private_key.count"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindToNestedObject_ReturnsHierarchicalStructure()
|
||||
{
|
||||
var finding = CreateFinding("rule1", "critical", "high");
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2025.06", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([finding], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var nested = SecretSignalBinder.BindToNestedObject(context);
|
||||
|
||||
nested["has_finding"].Should().Be(true);
|
||||
nested["count"].Should().Be(1);
|
||||
|
||||
var severity = nested["severity"] as IDictionary<string, object?>;
|
||||
severity.Should().NotBeNull();
|
||||
severity!["critical"].Should().Be(true);
|
||||
|
||||
var bundleDict = nested["bundle"] as IDictionary<string, object?>;
|
||||
bundleDict.Should().NotBeNull();
|
||||
bundleDict!["version"].Should().Be("2025.06");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBundleVersion_ValidRequirement_ReturnsTrue()
|
||||
{
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2025.06", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
SecretSignalBinder.CheckBundleVersion(context, "2025.01").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBundleVersion_InvalidRequirement_ReturnsFalse()
|
||||
{
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2024.12", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([], bundle);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
SecretSignalBinder.CheckBundleVersion(context, "2025.01").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFindingSummary_NoFindings_ReturnsNoFindingsMessage()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var summary = SecretSignalBinder.CreateFindingSummary(context);
|
||||
|
||||
summary.Should().Be("No secret findings detected.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFindingSummary_WithFindings_ReturnsFormattedSummary()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "critical"),
|
||||
CreateFinding("rule2", "high"),
|
||||
CreateFinding("rule3", "high"),
|
||||
CreateFinding("rule4", "medium"),
|
||||
};
|
||||
var provider = CreateMockProvider(findings);
|
||||
var context = new SecretEvidenceContext(provider);
|
||||
|
||||
var summary = SecretSignalBinder.CreateFindingSummary(context);
|
||||
|
||||
summary.Should().Contain("4 secret finding(s)");
|
||||
summary.Should().Contain("1 critical");
|
||||
summary.Should().Contain("2 high");
|
||||
summary.Should().Contain("1 medium");
|
||||
}
|
||||
|
||||
private static ISecretEvidenceProvider CreateMockProvider(
|
||||
SecretFinding[] findings,
|
||||
SecretBundleMetadata? bundle = null,
|
||||
bool maskingApplied = true)
|
||||
{
|
||||
var mock = new Mock<ISecretEvidenceProvider>();
|
||||
mock.Setup(p => p.GetFindings()).Returns(findings);
|
||||
mock.Setup(p => p.GetBundleMetadata()).Returns(bundle);
|
||||
mock.Setup(p => p.IsMaskingApplied()).Returns(maskingApplied);
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static SecretFinding CreateFinding(
|
||||
string ruleId,
|
||||
string severity,
|
||||
string confidence = "high")
|
||||
{
|
||||
return new SecretFinding
|
||||
{
|
||||
RuleId = ruleId,
|
||||
RuleVersion = "1.0.0",
|
||||
Severity = severity,
|
||||
Confidence = confidence,
|
||||
FilePath = "test/file.txt",
|
||||
LineNumber = 10,
|
||||
Mask = "***REDACTED***",
|
||||
BundleId = "bundle-1",
|
||||
BundleVersion = "2025.01",
|
||||
DetectedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretSignalContextExtensionsTests.cs
|
||||
// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration)
|
||||
// Task: PSD-011 - Add unit and integration tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretSignalContextExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithSecretEvidence_OnSignalContext_AddsAllSignals()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.aws-key", "critical", "high");
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2025.06", DateTimeOffset.UtcNow, 100);
|
||||
var provider = CreateMockProvider([finding], bundle, true);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var signalContext = new SignalContext();
|
||||
signalContext.WithSecretEvidence(evidenceContext);
|
||||
|
||||
// Check flat signals
|
||||
signalContext.GetSignal<bool>("secret.has_finding").Should().BeTrue();
|
||||
signalContext.GetSignal<int>("secret.count").Should().Be(1);
|
||||
signalContext.GetSignal<bool>("secret.severity.critical").Should().BeTrue();
|
||||
signalContext.GetSignal<bool>("secret.mask.applied").Should().BeTrue();
|
||||
signalContext.GetSignal<string>("secret.bundle.version").Should().Be("2025.06");
|
||||
|
||||
// Check nested object
|
||||
var secretObj = signalContext.GetSignal("secret") as IDictionary<string, object?>;
|
||||
secretObj.Should().NotBeNull();
|
||||
secretObj!["has_finding"].Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSecretEvidence_OnSignalContextBuilder_AddsAllSignals()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.github-token", "high", "medium");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var context = SignalContext.Builder()
|
||||
.WithSecretEvidence(evidenceContext)
|
||||
.Build();
|
||||
|
||||
context.GetSignal<bool>("secret.has_finding").Should().BeTrue();
|
||||
context.GetSignal<bool>("secret.severity.high").Should().BeTrue();
|
||||
context.GetSignal<bool>("secret.confidence.medium").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSecretEvidence_FromProvider_AddsSignals()
|
||||
{
|
||||
var finding = CreateFinding("stellaops.secrets.private-key-rsa", "critical");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
|
||||
var context = SignalContext.Builder()
|
||||
.WithSecretEvidence(provider)
|
||||
.Build();
|
||||
|
||||
context.GetSignal<bool>("secret.has_finding").Should().BeTrue();
|
||||
context.GetSignal<int>("secret.private_key.count").Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBuilderWithSecrets_ReturnsConfiguredBuilder()
|
||||
{
|
||||
var provider = CreateMockProvider([]);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var builder = SecretSignalContextExtensions.CreateBuilderWithSecrets(evidenceContext);
|
||||
var context = builder.Build();
|
||||
|
||||
context.GetSignal<bool>("secret.has_finding").Should().BeFalse();
|
||||
context.GetSignal<int>("secret.count").Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateContextWithSecrets_ReturnsFullyConfiguredContext()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding("rule1", "high"),
|
||||
CreateFinding("rule2", "medium"),
|
||||
};
|
||||
var bundle = new SecretBundleMetadata("bundle-1", "2025.06", DateTimeOffset.UtcNow, 50);
|
||||
var provider = CreateMockProvider(findings, bundle);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var context = SecretSignalContextExtensions.CreateContextWithSecrets(evidenceContext);
|
||||
|
||||
context.GetSignal<bool>("secret.has_finding").Should().BeTrue();
|
||||
context.GetSignal<int>("secret.count").Should().Be(2);
|
||||
context.GetSignal<string>("secret.bundle.id").Should().Be("bundle-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSecretEvidence_NullContext_ThrowsArgumentNullException()
|
||||
{
|
||||
SignalContext context = null!;
|
||||
var provider = CreateMockProvider([]);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var action = () => context.WithSecretEvidence(evidenceContext);
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSecretEvidence_NullEvidenceContext_ThrowsArgumentNullException()
|
||||
{
|
||||
var context = new SignalContext();
|
||||
SecretEvidenceContext evidenceContext = null!;
|
||||
|
||||
var action = () => context.WithSecretEvidence(evidenceContext);
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalContext_CanCombineSecretSignalsWithOtherSignals()
|
||||
{
|
||||
var finding = CreateFinding("rule1", "high");
|
||||
var provider = CreateMockProvider([finding]);
|
||||
var evidenceContext = new SecretEvidenceContext(provider);
|
||||
|
||||
var context = SignalContext.Builder()
|
||||
.WithFlag("custom.flag", true)
|
||||
.WithNumber("custom.score", 0.85m)
|
||||
.WithSecretEvidence(evidenceContext)
|
||||
.WithFinding("high", 0.9m, "CVE-2025-1234")
|
||||
.Build();
|
||||
|
||||
// Custom signals preserved
|
||||
context.GetSignal<bool>("custom.flag").Should().BeTrue();
|
||||
context.GetSignal<decimal>("custom.score").Should().Be(0.85m);
|
||||
|
||||
// Secret signals added
|
||||
context.GetSignal<bool>("secret.has_finding").Should().BeTrue();
|
||||
|
||||
// Other builder methods work
|
||||
var finding2 = context.GetSignal("finding") as IDictionary<string, object?>;
|
||||
finding2.Should().NotBeNull();
|
||||
finding2!["severity"].Should().Be("high");
|
||||
}
|
||||
|
||||
private static ISecretEvidenceProvider CreateMockProvider(
|
||||
SecretFinding[] findings,
|
||||
SecretBundleMetadata? bundle = null,
|
||||
bool maskingApplied = true)
|
||||
{
|
||||
var mock = new Mock<ISecretEvidenceProvider>();
|
||||
mock.Setup(p => p.GetFindings()).Returns(findings);
|
||||
mock.Setup(p => p.GetBundleMetadata()).Returns(bundle);
|
||||
mock.Setup(p => p.IsMaskingApplied()).Returns(maskingApplied);
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static SecretFinding CreateFinding(
|
||||
string ruleId,
|
||||
string severity,
|
||||
string confidence = "high")
|
||||
{
|
||||
return new SecretFinding
|
||||
{
|
||||
RuleId = ruleId,
|
||||
RuleVersion = "1.0.0",
|
||||
Severity = severity,
|
||||
Confidence = confidence,
|
||||
FilePath = "test/file.txt",
|
||||
LineNumber = 10,
|
||||
Mask = "***REDACTED***",
|
||||
BundleId = "bundle-1",
|
||||
BundleVersion = "2025.01",
|
||||
DetectedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@
|
||||
<!-- Disable Concelier test infra to avoid duplicate package references -->
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user