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:
StellaOps Bot
2026-01-04 15:44:49 +02:00
parent 1f33143bd1
commit f7d27c6fda
44 changed files with 2406 additions and 1107 deletions

View File

@@ -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"
}
}
}
}
}

View File

@@ -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": {}
}

View File

@@ -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"
}
}
]
}
}

View File

@@ -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"
}
}
]
}
}

View File

@@ -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));
}
}

View File

@@ -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>